Compare commits

...

24 Commits

Author SHA1 Message Date
Manfred Riem
019f23548c chore: bump version to 0.1.7 and add CHANGELOG entry for multi-catalog support 2026-02-27 17:27:50 -06:00
copilot-swe-agent[bot]
7279f1d7f1 fix: remove extraneous f-string prefixes (ruff F541)
Remove f-prefix from strings with no placeholders in catalog_remove
and extension_search commands.

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
2026-02-27 21:21:14 +00:00
Manfred Riem
ece831e1a7 Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-02-27 15:16:52 -06:00
copilot-swe-agent[bot]
8a1328cfbd docs: update RFC, user guide, and API reference for multi-catalog support
- RFC: replace FUTURE FEATURE section with full implementation docs,
  add catalog stack resolution order, config file examples, merge
  conflict resolution, and install_allowed behavior
- EXTENSION-USER-GUIDE.md: add multi-catalog section with CLI examples
  for catalogs/catalog-add/catalog-remove, update catalog config docs
- EXTENSION-API-REFERENCE.md: add CatalogEntry class docs, update
  ExtensionCatalog docs with new methods and result annotations,
  add catalog CLI commands (catalogs, catalog add, catalog remove)

Also fix extension_catalogs command to correctly show "Using built-in
default catalog stack" when config file exists but has empty catalogs

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
2026-02-27 21:06:18 +00:00
copilot-swe-agent[bot]
128bb0d790 feat(extensions): implement multi-catalog stack support
- Add CatalogEntry dataclass to represent catalog entries
- Add get_active_catalogs() reading SPECKIT_CATALOG_URL, project config,
  user config, or built-in default stack (org-approved + community)
- Add _load_catalog_config() to parse .specify/extension-catalogs.yml
- Add _validate_catalog_url() HTTPS validation helper
- Add _fetch_single_catalog() with per-URL caching, backward-compat for DEFAULT_CATALOG_URL
- Add _get_merged_extensions() that merges all catalogs (priority wins on conflict)
- Update search() and get_extension_info() to use merged results
  annotated with _catalog_name and _install_allowed
- Update clear_cache() to also remove per-URL hash cache files
- Add extension_catalogs CLI command to list active catalogs
- Add catalog add/remove sub-commands for .specify/extension-catalogs.yml
- Update extension_add to enforce install_allowed=false policy
- Update extension_search to show source catalog per result
- Update extension_info to show source catalog with install_allowed status
- Add 13 new tests covering catalog stack, merge conflict resolution,
  install_allowed enforcement, and catalog metadata

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
2026-02-27 21:00:30 +00:00
copilot-swe-agent[bot]
9d0981a3ff Initial plan 2026-02-27 20:48:52 +00:00
Fabián Silva
b55d00beed fix: prepend YAML frontmatter to Cursor .mdc files (#1699)
* fix: prepend YAML frontmatter to Cursor .mdc files for auto-inclusion

Cursor IDE requires YAML frontmatter with `alwaysApply: true` in .mdc
rule files for them to be automatically loaded. Without this frontmatter,
users must manually configure glob patterns for the rules to take effect.

This fix adds frontmatter generation to both the bash and PowerShell
update-agent-context scripts, handling three scenarios:
- New .mdc file creation (frontmatter prepended after template processing)
- Existing .mdc file update without frontmatter (frontmatter added)
- Existing .mdc file with frontmatter (no duplication)

Closes #669

🤖 Generated with [Claude Code](https://claude.com/code)

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

* refactor: address Copilot review suggestions

- Handle CRLF line endings in frontmatter detection (grep '^---' instead
  of '^---$')
- Fix double blank line after frontmatter in PowerShell New-AgentFile
- Remove unused tempfile import from tests
- Add encoding="utf-8" to all open() calls for cross-platform safety

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 08:20:00 -06:00
dependabot[bot]
525eae7f7e chore(deps): bump astral-sh/setup-uv from 6 to 7 (#1709)
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 6 to 7.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v6...v7)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 08:09:43 -06:00
dependabot[bot]
ce7bed4823 chore(deps): bump actions/setup-python from 5 to 6 (#1710)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 08:07:19 -06:00
Manfred Riem
61b0637a6d chore: Update outdated GitHub Actions versions (#1706)
Co-authored-by: Padraic Slattery <pgoslatara@gmail.com>
2026-02-26 14:43:22 -06:00
Manfred Riem
56deda7be3 docs: Document dual-catalog system for extensions (#1689)
* docs: Document dual-catalog system for extensions

- Clarify distinction between catalog.json (curated) and catalog.community.json (reference)
- Update EXTENSION-DEVELOPMENT-GUIDE.md to explain community catalog submission
- Update EXTENSION-PUBLISHING-GUIDE.md with dual-catalog workflow
- Update EXTENSION-USER-GUIDE.md with catalog selection guidance
- Expand README.md with comprehensive catalog explanation
- Update RFC-EXTENSION-SYSTEM.md with dual-catalog design and current implementation
- Change GitHub references from statsperform to github
- Add SPECKIT_CATALOG_URL environment variable documentation

This clarifies how organizations can curate their own catalog while
browsing community-contributed extensions for discovery.

* Update extensions/README.md

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

* Update extensions/README.md

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

* Update extensions/README.md

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 14:38:56 -06:00
Santosh Bhavani
525cdc17ec Fix version command in documentation (#1685)
Co-authored-by: Santosh Bhavani <sb@mac-mini-i7.local>
2026-02-25 08:20:18 -06:00
dsrednicki
607760e72f Add Cleanup Extension to README (#1678) 2026-02-24 15:50:04 -06:00
Emi
c7ecdfb998 Add retrospective extension to community catalog. (#1681)
Register the new retrospective extension release so users can discover and install it from the community catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 13:56:03 -06:00
dsrednicki
f444ccba3a Add Cleanup Extension to catalog (#1617)
**Repository**: https://github.com/dsrednicki/spec-kit-cleanup
**Version**: 1.0.0
**License**: MIT
**Author**: @dsrednicki

Adds catalog entry for the Cleanup Extension - a post-implementation
quality gate that fixes small issues (scout rule), creates tasks for
medium issues, and generates analysis for large issues.
2026-02-23 13:52:40 -06:00
Manfred Riem
3040d33c31 Fix parameter ordering issues in CLI (#1669)
* chore: bump version to v0.0.6 [skip ci]

* Fix parameter ordering issues in CLI (#1641)

- Add validation to detect when option flags are consumed as values
- Provide clear error messages with helpful hints and examples
- Add 5 comprehensive tests to prevent regressions
- Update CODEOWNERS to @mnriem
- Bump version to 0.1.6 with changelog entry

Fixes: #1641

* Fix ruff linting errors: remove f-string prefix from strings without placeholders

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-23 13:17:08 -06:00
Leonardo Nascimento
6cc61025cb Update V-Model Extension Pack to v0.4.0 (#1665)
- Version: 0.2.0 → 0.4.0
- Commands: 5 → 9 (new: architecture-design, integration-test, module-design, unit-test)
- Download URL: updated to v0.4.0 tag
2026-02-23 13:14:02 -06:00
Emi
c1034f1d9d docs: Fix doc missing step (#1496)
* docs: Fix doc missing step

* docs: Update steps for generating technical plan and defining tasks
2026-02-23 11:40:41 -06:00
Leonardo Nascimento
cee4f26fac Update V-Model Extension Pack to v0.3.0 (#1661)
- Version: 0.2.0 → 0.3.0
- Commands: 5 → 7 (architecture-design, integration-test)
- Download URL updated to v0.3.0 tag
2026-02-23 09:02:24 -06:00
Manfred Riem
6f523ede22 Fix #1658: Add commands_subdir field to support non-standard agent directory structures (#1660)
- Added commands_subdir field to AGENT_CONFIG for all agents
- Updated install_ai_skills() to use commands_subdir instead of hardcoded 'commands'
- Fixed --ai-skills flag for copilot, opencode, windsurf, codex, kilocode, q, and agy
- Bumped version to 0.1.5
- Updated AGENTS.md documentation with new field

Affected agents now correctly locate their command templates:
- copilot: .github/agents/
- opencode: .opencode/command/ (singular)
- windsurf: .windsurf/workflows/
- codex: .codex/prompts/
- kilocode: .kilocode/workflows/
- q: .amazonq/prompts/
- agy: .agent/workflows/

All 51 tests pass.
2026-02-21 08:05:58 -06:00
Manfred Riem
68d1d3a0fc feat: add GitHub issue templates (#1655)
* feat: add issue templates for agent requests, bug reports, feature requests, extension submissions, and configuration

* Update .github/ISSUE_TEMPLATE/config.yml

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

* Update .github/ISSUE_TEMPLATE/agent_request.yml

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

* Update .github/ISSUE_TEMPLATE/config.yml

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

* Update .github/ISSUE_TEMPLATE/bug_report.yml

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

* Update .github/ISSUE_TEMPLATE/feature_request.yml

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-20 17:35:48 -06:00
Leonardo Nascimento
07077d0fc2 Update V-Model Extension Pack to v0.2.0 in community catalog (#1656)
- Version: 0.1.0 → 0.2.0
- Download URL updated to v0.2.0 tag
- Commands: 3 → 5 (added system-design, system-test)
2026-02-20 17:29:58 -06:00
Leonardo Nascimento
aeed11f735 Add V-Model Extension Pack to catalog (#1640)
* Add V-Model Extension Pack to catalog

Second community extension: V-Model paired dev-spec and test-spec
generation with regulatory-grade traceability.

- 3 commands: requirements, acceptance, trace
- Deterministic coverage validation (regex-based, not AI)
- Targets: IEC 62304, ISO 26262, DO-178C, FDA 21 CFR Part 820
- Repository: https://github.com/leocamello/spec-kit-v-model
- Release: v0.1.0

* Fix catalog entry: provides as number, add timestamps and statistics

Address review feedback:
- provides.commands: array → number (3), add hooks (1)
- Add created_at and updated_at timestamps
- Add statistics block (downloads: 0, stars: 0)

* Address review: use catalog.community.json and add extensions README

Per maintainer feedback:
- Revert catalog.json to its original empty state (empty by design)
- Rename catalog.example.json → catalog.community.json
- Replace example entries with real V-Model Extension Pack entry
- Add extensions/README.md with community extensions table
2026-02-20 15:11:51 -06:00
Manfred Riem
12405c01e1 refactor: remove OpenAPI/GraphQL bias from templates (#1652)
* chore: bump version to v0.0.5 [skip ci]

* refactor: update documentation for interface contracts and integration patterns

* Update pyproject.toml

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

* Update templates/commands/tasks.md

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

* Update templates/commands/tasks.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-02-20 14:46:56 -06:00
33 changed files with 2565 additions and 196 deletions

2
.github/CODEOWNERS vendored
View File

@@ -1,3 +1,3 @@
# Global code owner
* @localden
* @mnriem

141
.github/ISSUE_TEMPLATE/agent_request.yml vendored Normal file
View File

@@ -0,0 +1,141 @@
name: Agent Request
description: Request support for a new AI agent/assistant in Spec Kit
title: "[Agent]: Add support for "
labels: ["agent-request", "enhancement", "needs-triage"]
body:
- type: markdown
attributes:
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, Amazon Q Developer CLI, Amp, SHAI, IBM Bob, Antigravity
- type: input
id: agent-name
attributes:
label: Agent Name
description: What is the name of the AI agent/assistant?
placeholder: "e.g., SuperCoder AI"
validations:
required: true
- type: input
id: website
attributes:
label: Official Website
description: Link to the agent's official website or documentation
placeholder: "https://..."
validations:
required: true
- type: dropdown
id: agent-type
attributes:
label: Agent Type
description: How is the agent accessed?
options:
- CLI tool (command-line interface)
- IDE extension/plugin
- Both CLI and IDE
- Other
validations:
required: true
- type: input
id: cli-command
attributes:
label: CLI Command (if applicable)
description: What command is used to invoke the agent from terminal?
placeholder: "e.g., supercode, ai-assistant"
- type: input
id: install-method
attributes:
label: Installation Method
description: How is the agent installed?
placeholder: "e.g., npm install -g supercode, pip install supercode, IDE marketplace"
validations:
required: true
- type: textarea
id: command-structure
attributes:
label: Command/Workflow Structure
description: How does the agent define custom commands or workflows?
placeholder: |
- Command file format (Markdown, YAML, TOML, etc.)
- Directory location (e.g., .supercode/commands/)
- Example command file structure
validations:
required: true
- type: textarea
id: argument-pattern
attributes:
label: Argument Passing Pattern
description: How does the agent handle arguments in commands?
placeholder: |
e.g., Uses {{args}}, $ARGUMENTS, %ARGS%, or other placeholder format
Example: "Run test suite with {{args}}"
- type: dropdown
id: popularity
attributes:
label: Popularity/Usage
description: How widely is this agent used?
options:
- Widely used (thousands+ of users)
- Growing adoption (hundreds of users)
- New/emerging (less than 100 users)
- Unknown
validations:
required: true
- type: textarea
id: documentation
attributes:
label: Documentation Links
description: Links to relevant documentation for custom commands/workflows
placeholder: |
- Command documentation: https://...
- API/CLI reference: https://...
- Examples: https://...
- type: textarea
id: use-case
attributes:
label: Use Case
description: Why do you want this agent supported in Spec Kit?
placeholder: Explain your workflow and how this agent fits into your development process
validations:
required: true
- type: textarea
id: example-command
attributes:
label: Example Command File
description: If possible, provide an example of a command file for this agent
render: markdown
placeholder: |
```toml
description = "Example command"
prompt = "Do something with {{args}}"
```
- type: checkboxes
id: contribution
attributes:
label: Contribution
description: Are you willing to help implement support for this agent?
options:
- label: I can help test the integration
- label: I can provide example command files
- label: I can help with documentation
- label: I can submit a pull request for the integration
- type: textarea
id: context
attributes:
label: Additional Context
description: Any other relevant information about this agent
placeholder: Screenshots, community links, comparison to existing agents, etc.

118
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,118 @@
name: Bug Report
description: Report a bug or unexpected behavior in Specify CLI or Spec Kit
title: "[Bug]: "
labels: ["bug", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the sections below to help us diagnose and fix the issue.
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is.
placeholder: What went wrong?
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Run command '...'
2. Execute script '...'
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: Describe the expected outcome
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened?
placeholder: Describe what happened instead
validations:
required: true
- type: input
id: version
attributes:
label: Specify CLI Version
description: "Run `specify version` or `pip show spec-kit`"
placeholder: "e.g., 1.3.0"
validations:
required: true
- type: dropdown
id: ai-agent
attributes:
label: AI Agent
description: Which AI agent are you using?
options:
- Claude Code
- Gemini CLI
- GitHub Copilot
- Cursor
- Qwen Code
- opencode
- Codex CLI
- Windsurf
- Kilo Code
- Auggie CLI
- Roo Code
- CodeBuddy
- Qoder CLI
- Amazon Q Developer CLI
- Amp
- SHAI
- IBM Bob
- Antigravity
- Not applicable
validations:
required: true
- type: input
id: os
attributes:
label: Operating System
description: Your operating system and version
placeholder: "e.g., macOS 14.2, Ubuntu 22.04, Windows 11"
validations:
required: true
- type: input
id: python
attributes:
label: Python Version
description: "Run `python --version` or `python3 --version`"
placeholder: "e.g., Python 3.11.5"
validations:
required: true
- type: textarea
id: logs
attributes:
label: Error Logs
description: Please paste any relevant error messages or logs
render: shell
placeholder: Paste error output here
- type: textarea
id: context
attributes:
label: Additional Context
description: Add any other context about the problem
placeholder: Screenshots, related issues, workarounds attempted, etc.

17
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
blank_issues_enabled: false
contact_links:
- name: 💬 General Discussion
url: https://github.com/github/spec-kit/discussions
about: Ask questions, share ideas, or discuss Spec-Driven Development
- name: 📖 Documentation
url: https://github.com/github/spec-kit/blob/main/README.md
about: Read the Spec Kit documentation and guides
- name: 🛠️ Extension Development Guide
url: https://github.com/manfredseee/spec-kit/blob/main/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
about: Learn how to develop and publish Spec Kit extensions
- name: 🤝 Contributing Guide
url: https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md
about: Learn how to contribute to Spec Kit
- name: 🔒 Security Issues
url: https://github.com/github/spec-kit/blob/main/SECURITY.md
about: Report security vulnerabilities privately

View File

@@ -0,0 +1,280 @@
name: Extension Submission
description: Submit your extension to the Spec Kit catalog
title: "[Extension]: Add "
labels: ["extension-submission", "enhancement", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Thanks for contributing an extension! This template helps you submit your extension to the community catalog.
**Before submitting:**
- Review the [Extension Publishing Guide](https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-PUBLISHING-GUIDE.md)
- Ensure your extension has a valid `extension.yml` manifest
- Create a GitHub release with a version tag (e.g., v1.0.0)
- Test installation: `specify extension add --from <your-release-url>`
- type: input
id: extension-id
attributes:
label: Extension ID
description: Unique extension identifier (lowercase with hyphens only)
placeholder: "e.g., jira-integration"
validations:
required: true
- type: input
id: extension-name
attributes:
label: Extension Name
description: Human-readable extension name
placeholder: "e.g., Jira Integration"
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: Semantic version number
placeholder: "e.g., 1.0.0"
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: Brief description of what your extension does (under 200 characters)
placeholder: Integrates Jira issue tracking with Spec Kit workflows for seamless task management
validations:
required: true
- type: input
id: author
attributes:
label: Author
description: Your name or organization
placeholder: "e.g., John Doe or Acme Corp"
validations:
required: true
- type: input
id: repository
attributes:
label: Repository URL
description: GitHub repository URL for your extension
placeholder: "https://github.com/your-org/spec-kit-your-extension"
validations:
required: true
- type: input
id: download-url
attributes:
label: Download URL
description: URL to the GitHub release archive (e.g., v1.0.0.zip)
placeholder: "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip"
validations:
required: true
- type: input
id: license
attributes:
label: License
description: Open source license type
placeholder: "e.g., MIT, Apache-2.0"
validations:
required: true
- type: input
id: homepage
attributes:
label: Homepage (optional)
description: Link to extension homepage or documentation site
placeholder: "https://..."
- type: input
id: documentation
attributes:
label: Documentation URL (optional)
description: Link to detailed documentation
placeholder: "https://github.com/your-org/spec-kit-your-extension/blob/main/docs/"
- type: input
id: changelog
attributes:
label: Changelog URL (optional)
description: Link to changelog file
placeholder: "https://github.com/your-org/spec-kit-your-extension/blob/main/CHANGELOG.md"
- type: input
id: speckit-version
attributes:
label: Required Spec Kit Version
description: Minimum Spec Kit version required
placeholder: "e.g., >=0.1.0"
validations:
required: true
- type: textarea
id: required-tools
attributes:
label: Required Tools (optional)
description: List any external tools or dependencies required
placeholder: |
- jira-cli (>=1.0.0) - required
- python (>=3.8) - optional
render: markdown
- type: input
id: commands-count
attributes:
label: Number of Commands
description: How many commands does your extension provide?
placeholder: "e.g., 3"
validations:
required: true
- type: input
id: hooks-count
attributes:
label: Number of Hooks (optional)
description: How many hooks does your extension provide?
placeholder: "e.g., 0"
- type: textarea
id: tags
attributes:
label: Tags
description: 2-5 relevant tags (lowercase, separated by commas)
placeholder: "issue-tracking, jira, atlassian, automation"
validations:
required: true
- type: textarea
id: features
attributes:
label: Key Features
description: List the main features and capabilities of your extension
placeholder: |
- Create Jira issues from specs
- Sync task status with Jira
- Link specs to existing issues
- Generate Jira reports
validations:
required: true
- type: checkboxes
id: testing
attributes:
label: Testing Checklist
description: Confirm that your extension has been tested
options:
- label: Extension installs successfully via download URL
required: true
- label: All commands execute without errors
required: true
- label: Documentation is complete and accurate
required: true
- label: No security vulnerabilities identified
required: true
- label: Tested on at least one real project
required: true
- type: checkboxes
id: requirements
attributes:
label: Submission Requirements
description: Verify your extension meets all requirements
options:
- label: Valid `extension.yml` manifest included
required: true
- label: README.md with installation and usage instructions
required: true
- label: LICENSE file included
required: true
- label: GitHub release created with version tag
required: true
- label: All command files exist and are properly formatted
required: true
- label: Extension ID follows naming conventions (lowercase-with-hyphens)
required: true
- type: textarea
id: testing-details
attributes:
label: Testing Details
description: Describe how you tested your extension
placeholder: |
**Tested on:**
- macOS 14.0 with Spec Kit v0.1.0
- Linux Ubuntu 22.04 with Spec Kit v0.1.0
**Test project:** [Link or description]
**Test scenarios:**
1. Installed extension
2. Configured settings
3. Ran all commands
4. Verified outputs
validations:
required: true
- type: textarea
id: example-usage
attributes:
label: Example Usage
description: Provide a simple example of using your extension
render: markdown
placeholder: |
```bash
# Install extension
specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip
# Use a command
/speckit.your-extension.command-name arg1 arg2
```
validations:
required: true
- type: textarea
id: catalog-entry
attributes:
label: Proposed Catalog Entry
description: Provide the JSON entry for catalog.json (helps reviewers)
render: json
placeholder: |
{
"your-extension": {
"name": "Your Extension",
"id": "your-extension",
"description": "Brief description",
"author": "Your Name",
"version": "1.0.0",
"download_url": "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/your-org/spec-kit-your-extension",
"homepage": "https://github.com/your-org/spec-kit-your-extension",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 3
},
"tags": ["category", "tool"],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-02-20T00:00:00Z",
"updated_at": "2026-02-20T00:00:00Z"
}
}
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Any other information that would help reviewers
placeholder: Screenshots, demo videos, links to related projects, etc.

View File

@@ -0,0 +1,104 @@
name: Feature Request
description: Suggest a new feature or enhancement for Specify CLI or Spec Kit
title: "[Feature]: "
labels: ["enhancement", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a feature! Please provide details below to help us understand and evaluate your request.
- type: textarea
id: problem
attributes:
label: Problem Statement
description: Is your feature request related to a problem? Please describe.
placeholder: "I'm frustrated when..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the solution you'd like
placeholder: What would you like to happen?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you considered any alternative solutions or workarounds?
placeholder: What other approaches might work?
- type: dropdown
id: component
attributes:
label: Component
description: Which component does this feature relate to?
options:
- Specify CLI (initialization, commands)
- Spec templates (BDD, Testing Strategy, etc.)
- Agent integrations (command files, workflows)
- Scripts (Bash/PowerShell utilities)
- Documentation
- CI/CD workflows
- Other
validations:
required: true
- type: dropdown
id: ai-agent
attributes:
label: AI Agent (if applicable)
description: Does this feature relate to a specific AI agent?
options:
- All agents
- Claude Code
- Gemini CLI
- GitHub Copilot
- Cursor
- Qwen Code
- opencode
- Codex CLI
- Windsurf
- Kilo Code
- Auggie CLI
- Roo Code
- CodeBuddy
- Qoder CLI
- Amazon Q Developer CLI
- Amp
- SHAI
- IBM Bob
- Antigravity
- Not applicable
- type: textarea
id: use-cases
attributes:
label: Use Cases
description: Describe specific use cases where this feature would be valuable
placeholder: |
1. When working on large projects...
2. During spec review...
3. When integrating with CI/CD...
- type: textarea
id: acceptance
attributes:
label: Acceptance Criteria
description: How would you know this feature is complete and working?
placeholder: |
- [ ] Feature does X
- [ ] Documentation is updated
- [ ] Works with all supported agents
- type: textarea
id: context
attributes:
label: Additional Context
description: Add any other context, screenshots, or examples
placeholder: Links to similar features, mockups, related discussions, etc.

View File

@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0 # Fetch all history for git info

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Run markdownlint-cli2
uses: DavidAnson/markdownlint-cli2-action@v19

View File

@@ -19,7 +19,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -16,10 +16,10 @@ jobs:
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@v7
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13"
@@ -36,10 +36,10 @@ jobs:
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}

View File

@@ -66,6 +66,7 @@ AGENT_CONFIG = {
"new-agent-cli": { # Use the ACTUAL CLI tool name (what users type in terminal)
"name": "New Agent Display Name",
"folder": ".newagent/", # Directory for agent files
"commands_subdir": "commands", # Subdirectory name for command files (default: "commands")
"install_url": "https://example.com/install", # URL for installation docs (or None if IDE-based)
"requires_cli": True, # True if CLI tool required, False for IDE-based agents
},
@@ -83,6 +84,10 @@ This eliminates the need for special-case mappings throughout the codebase.
- `name`: Human-readable display name shown to users
- `folder`: Directory where agent-specific files are stored (relative to project root)
- `commands_subdir`: Subdirectory name within the agent folder where command/prompt files are stored (default: `"commands"`)
- Most agents use `"commands"` (e.g., `.claude/commands/`)
- Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode, agy), `"prompts"` (codex, q), `"command"` (opencode - singular)
- This field enables `--ai-skills` to locate command templates correctly for skill generation
- `install_url`: Installation documentation URL (set to `None` for IDE-based agents)
- `requires_cli`: Whether the agent requires a CLI tool check during initialization

View File

@@ -2,18 +2,57 @@
<!-- markdownlint-disable MD024 -->
All notable changes to the Specify CLI and templates are documented here.
Recent changes to the Specify CLI and templates are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.4] - Unreleased
## [0.1.7] - 2026-02-27
### Added
- **Multi-Catalog Support (#1707)**: Extension catalog system now supports multiple active catalogs simultaneously via a catalog stack
- New `specify extension catalogs` command lists all active catalogs with name, URL, priority, and `install_allowed` status
- New `specify extension catalog add` and `specify extension catalog remove` commands for project-scoped catalog management
- Default built-in stack includes `catalog.json` (org-approved, installable) and `catalog.community.json` (discovery only) — community extensions are now surfaced in search results out of the box
- `specify extension search` aggregates results across all active catalogs, annotating each result with source catalog
- `specify extension add` enforces `install_allowed` policy — extensions from discovery-only catalogs cannot be installed directly
- Project-level `.specify/extension-catalogs.yml` and user-level `~/.specify/extension-catalogs.yml` config files supported, with project-level taking precedence
- `SPECKIT_CATALOG_URL` environment variable still works for backward compatibility (replaces full stack with single catalog)
- All catalog URLs require HTTPS (HTTP allowed for localhost development)
- New `CatalogEntry` dataclass in `extensions.py` for catalog stack representation
- Per-URL hash-based caching for non-default catalogs; legacy cache preserved for default catalog
- Higher-priority catalogs win on merge conflicts (same extension id in multiple catalogs)
- 13 new tests covering catalog stack resolution, merge conflicts, URL validation, and `install_allowed` enforcement
- Updated RFC, Extension User Guide, and Extension API Reference documentation
## [0.1.6] - 2026-02-23
### Fixed
- **Parameter Ordering Issues (#1641)**: Fixed CLI parameter parsing issue where option flags were incorrectly consumed as values for preceding options
- Added validation to detect when `--ai` or `--ai-commands-dir` incorrectly consume following flags like `--here` or `--ai-skills`
- Now provides clear error messages: "Invalid value for --ai: '--here'"
- Includes helpful hints suggesting proper usage and listing available agents
- Commands like `specify init --ai-skills --ai --here` now fail with actionable feedback instead of confusing "Must specify project name" errors
- Added comprehensive test suite (5 new tests) to prevent regressions
## [0.1.5] - 2026-02-21
### Fixed
- **AI Skills Installation Bug (#1658)**: Fixed `--ai-skills` flag not generating skill files for GitHub Copilot and other agents with non-standard command directory structures
- Added `commands_subdir` field to `AGENT_CONFIG` to explicitly specify the subdirectory name for each agent
- Affected agents now work correctly: copilot (`.github/agents/`), opencode (`.opencode/command/`), windsurf (`.windsurf/workflows/`), codex (`.codex/prompts/`), kilocode (`.kilocode/workflows/`), q (`.amazonq/prompts/`), and agy (`.agent/workflows/`)
- The `install_ai_skills()` function now uses the correct path for all agents instead of assuming `commands/` for everyone
## [0.1.4] - 2026-02-20
### Fixed
- **Qoder CLI detection**: Renamed `AGENT_CONFIG` key from `"qoder"` to `"qodercli"` to match the actual executable name, fixing `specify check` and `specify init --ai` detection failures
## [0.1.3] - Unreleased
## [0.1.3] - 2026-02-20
### Added
@@ -60,7 +99,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.0.94] - 2026-02-11
- Add stale workflow for 180-day inactive issues and PRs (#1594)
## [0.0.93] - 2026-02-10
- Add modular extension system (#1551)

View File

@@ -81,6 +81,9 @@ Then, use the `/speckit.implement` slash command to execute the plan.
/speckit.implement
```
> [!TIP]
> **Phased Implementation**: For complex projects, implement in phases to avoid overwhelming the agent's context. Start with core functionality, validate it works, then add features incrementally.
## Detailed Example: Building Taskify
Here's a complete example of building a team productivity platform:
@@ -135,7 +138,15 @@ Be specific about your tech stack and technical requirements:
/speckit.plan We are going to generate this using .NET Aspire, using Postgres as the database. The frontend should use Blazor server with drag-and-drop task boards, real-time updates. There should be a REST API created with a projects API, tasks API, and a notifications API.
```
### Step 6: Validate and Implement
### Step 6: Define Tasks
Generate an actionable task list using the `/speckit.tasks` command:
```bash
/speckit.tasks
```
### Step 7: Validate and Implement
Have your AI agent audit the implementation plan using `/speckit.analyze`:
@@ -149,6 +160,9 @@ Finally, implement the solution:
/speckit.implement
```
> [!TIP]
> **Phased Implementation**: For large projects like Taskify, consider implementing in phases (e.g., Phase 1: Basic project/task structure, Phase 2: Kanban functionality, Phase 3: Comments and assignments). This prevents context saturation and allows for validation at each stage.
## Key Principles
- **Be explicit** about what you're building and why

View File

@@ -243,6 +243,32 @@ manager.check_compatibility(
) # Raises: CompatibilityError if incompatible
```
### CatalogEntry
**Module**: `specify_cli.extensions`
Represents a single catalog in the active catalog stack.
```python
from specify_cli.extensions import CatalogEntry
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="org-approved",
priority=1,
install_allowed=True,
)
```
**Fields**:
| Field | Type | Description |
|-------|------|-------------|
| `url` | `str` | Catalog URL (must use HTTPS, or HTTP for localhost) |
| `name` | `str` | Human-readable catalog name |
| `priority` | `int` | Sort order (lower = higher priority, wins on conflicts) |
| `install_allowed` | `bool` | Whether extensions from this catalog can be installed |
### ExtensionCatalog
**Module**: `specify_cli.extensions`
@@ -253,30 +279,65 @@ from specify_cli.extensions import ExtensionCatalog
catalog = ExtensionCatalog(project_root)
```
**Class attributes**:
```python
ExtensionCatalog.DEFAULT_CATALOG_URL # org-approved catalog URL
ExtensionCatalog.COMMUNITY_CATALOG_URL # community catalog URL
```
**Methods**:
```python
# Fetch catalog
# Get the ordered list of active catalogs
entries = catalog.get_active_catalogs() # List[CatalogEntry]
# Fetch catalog (primary catalog, backward compat)
catalog_data = catalog.fetch_catalog(force_refresh: bool = False) # Dict
# Search extensions
# Search extensions across all active catalogs
# Each result includes _catalog_name and _install_allowed
results = catalog.search(
query: Optional[str] = None,
tag: Optional[str] = None,
author: Optional[str] = None,
verified_only: bool = False
) # Returns: List[Dict]
) # Returns: List[Dict] — each dict includes _catalog_name, _install_allowed
# Get extension info
# Get extension info (searches all active catalogs)
# Returns None if not found; includes _catalog_name and _install_allowed
ext_info = catalog.get_extension_info(extension_id: str) # Optional[Dict]
# Check cache validity
# Check cache validity (primary catalog)
is_valid = catalog.is_cache_valid() # bool
# Clear cache
# Clear all catalog caches
catalog.clear_cache()
```
**Result annotation fields**:
Each extension dict returned by `search()` and `get_extension_info()` includes:
| Field | Type | Description |
|-------|------|-------------|
| `_catalog_name` | `str` | Name of the source catalog |
| `_install_allowed` | `bool` | Whether installation is allowed from this catalog |
**Catalog config file** (`.specify/extension-catalogs.yml`):
```yaml
catalogs:
- name: "org-approved"
url: "https://example.com/catalog.json"
priority: 1
install_allowed: true
- name: "community"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
priority: 2
install_allowed: false
```
### HookExecutor
**Module**: `specify_cli.extensions`
@@ -543,6 +604,38 @@ EXECUTE_COMMAND: {command}
**Output**: List of installed extensions with metadata
### extension catalogs
**Usage**: `specify extension catalogs`
Lists all active catalogs in the current catalog stack, showing name, URL, priority, and `install_allowed` status.
### extension catalog add
**Usage**: `specify extension catalog add URL [OPTIONS]`
**Options**:
- `--name NAME` - Catalog name (required)
- `--priority INT` - Priority (lower = higher priority, default: 10)
- `--install-allowed / --no-install-allowed` - Allow installs from this catalog (default: false)
**Arguments**:
- `URL` - Catalog URL (must use HTTPS)
Adds a catalog entry to `.specify/extension-catalogs.yml`.
### extension catalog remove
**Usage**: `specify extension catalog remove NAME`
**Arguments**:
- `NAME` - Catalog name to remove
Removes a catalog entry from `.specify/extension-catalogs.yml`.
### extension add
**Usage**: `specify extension add EXTENSION [OPTIONS]`
@@ -551,13 +644,13 @@ EXECUTE_COMMAND: {command}
- `--from URL` - Install from custom URL
- `--dev PATH` - Install from local directory
- `--version VERSION` - Install specific version
- `--no-register` - Skip command registration
**Arguments**:
- `EXTENSION` - Extension name or URL
**Note**: Extensions from catalogs with `install_allowed: false` cannot be installed via this command.
### extension remove
**Usage**: `specify extension remove EXTENSION [OPTIONS]`
@@ -575,6 +668,8 @@ EXECUTE_COMMAND: {command}
**Usage**: `specify extension search [QUERY] [OPTIONS]`
Searches all active catalogs simultaneously. Results include source catalog name and install_allowed status.
**Options**:
- `--tag TAG` - Filter by tag
@@ -589,6 +684,8 @@ EXECUTE_COMMAND: {command}
**Usage**: `specify extension info EXTENSION`
Shows source catalog and install_allowed status.
**Arguments**:
- `EXTENSION` - Extension ID

View File

@@ -456,18 +456,20 @@ Users install with:
specify extension add --from https://github.com/.../spec-kit-my-ext-1.0.0.zip
```
### Option 3: Extension Catalog (Future)
### Option 3: Community Reference Catalog
Submit to official catalog:
Submit to the community catalog for public discovery:
1. **Fork** spec-kit repository
2. **Add entry** to `extensions/catalog.json`
3. **Create PR**
4. **After merge**, users can install with:
2. **Add entry** to `extensions/catalog.community.json`
3. **Update** `extensions/README.md` with your extension
4. **Create PR** following the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md)
5. **After merge**, your extension becomes available:
- Users can browse `catalog.community.json` to discover your extension
- Users copy the entry to their own `catalog.json`
- Users install with: `specify extension add my-ext` (from their catalog)
```bash
specify extension add my-ext # No URL needed!
```
See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed submission instructions.
---

View File

@@ -129,26 +129,32 @@ specify extension add --from https://github.com/your-org/spec-kit-your-extension
## Submit to Catalog
### Understanding the Catalogs
Spec Kit uses a dual-catalog system. For details about how catalogs work, see the main [Extensions README](README.md#extension-catalogs).
**For extension publishing**: All community extensions should be added to `catalog.community.json`. Users browse this catalog and copy extensions they trust into their own `catalog.json`.
### 1. Fork the spec-kit Repository
```bash
# Fork on GitHub
# https://github.com/statsperform/spec-kit/fork
# https://github.com/github/spec-kit/fork
# Clone your fork
git clone https://github.com/YOUR-USERNAME/spec-kit.git
cd spec-kit
```
### 2. Add Extension to Catalog
### 2. Add Extension to Community Catalog
Edit `extensions/catalog.json` and add your extension:
Edit `extensions/catalog.community.json` and add your extension:
```json
{
"schema_version": "1.0",
"updated_at": "2026-01-28T15:54:00Z",
"catalog_url": "https://raw.githubusercontent.com/statsperform/spec-kit/main/extensions/catalog.json",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"your-extension": {
"name": "Your Extension Name",
@@ -198,15 +204,25 @@ Edit `extensions/catalog.json` and add your extension:
- Use current timestamp for `created_at` and `updated_at`
- Update the top-level `updated_at` to current time
### 3. Submit Pull Request
### 3. Update Extensions README
Add your extension to the Available Extensions table in `extensions/README.md`:
```markdown
| Your Extension Name | Brief description of what it does | [repo-name](https://github.com/your-org/spec-kit-your-extension) |
```
Insert your extension in alphabetical order in the table.
### 4. Submit Pull Request
```bash
# Create a branch
git checkout -b add-your-extension
# Commit your changes
git add extensions/catalog.json
git commit -m "Add your-extension to catalog
git add extensions/catalog.community.json extensions/README.md
git commit -m "Add your-extension to community catalog
- Extension ID: your-extension
- Version: 1.0.0
@@ -218,7 +234,7 @@ git commit -m "Add your-extension to catalog
git push origin add-your-extension
# Create Pull Request on GitHub
# https://github.com/statsperform/spec-kit/compare
# https://github.com/github/spec-kit/compare
```
**Pull Request Template**:
@@ -243,6 +259,8 @@ Brief description of what your extension does.
- [x] Extension tested on real project
- [x] All commands working
- [x] No security vulnerabilities
- [x] Added to extensions/catalog.community.json
- [x] Added to extensions/README.md Available Extensions table
### Testing
Tested on:

View File

@@ -46,7 +46,7 @@ Extensions are modular packages that add new commands and functionality to Spec
### Check Your Version
```bash
specify --version
specify version
# Should show 0.1.0 or higher
```
@@ -76,13 +76,15 @@ vim .specify/extensions/jira/jira-config.yml
## Finding Extensions
`specify extension search` searches **all active catalogs** simultaneously, including the community catalog by default. Results are annotated with their source catalog and install status.
### Browse All Extensions
```bash
specify extension search
```
Shows all available extensions in the catalog.
Shows all extensions across all active catalogs (org-approved and community by default).
### Search by Keyword
@@ -400,13 +402,13 @@ In addition to extension-specific environment variables (`SPECKIT_{EXT_ID}_*`),
| Variable | Description | Default |
|----------|-------------|---------|
| `SPECKIT_CATALOG_URL` | Override the extension catalog URL | GitHub-hosted catalog |
| `SPECKIT_CATALOG_URL` | Override the full catalog stack with a single URL (backward compat) | Built-in default stack |
| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub API token for downloads | None |
#### Example: Using a custom catalog for testing
```bash
# Point to a local or alternative catalog
# Point to a local or alternative catalog (replaces the full stack)
export SPECKIT_CATALOG_URL="http://localhost:8000/catalog.json"
# Or use a staging catalog
@@ -415,11 +417,75 @@ export SPECKIT_CATALOG_URL="https://example.com/staging/catalog.json"
---
## Extension Catalogs
Spec Kit uses a **catalog stack** — an ordered list of catalogs searched simultaneously. By default, two catalogs are active:
| Priority | Catalog | Install Allowed | Purpose |
|----------|---------|-----------------|---------|
| 1 | `catalog.json` (org-approved) | ✅ Yes | Extensions your org approves for installation |
| 2 | `catalog.community.json` (community) | ❌ No (discovery only) | Browse community extensions |
### Listing Active Catalogs
```bash
specify extension catalogs
```
### Adding a Catalog (Project-scoped)
```bash
# Add an internal catalog that allows installs
specify extension catalog add \
--name "internal" \
--priority 2 \
--install-allowed \
https://internal.company.com/spec-kit/catalog.json
# Add a discovery-only catalog
specify extension catalog add \
--name "partner" \
--priority 5 \
https://partner.example.com/spec-kit/catalog.json
```
This creates or updates `.specify/extension-catalogs.yml`.
### Removing a Catalog
```bash
specify extension catalog remove internal
```
### Manual Config File
You can also edit `.specify/extension-catalogs.yml` directly:
```yaml
catalogs:
- name: "org-approved"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
priority: 1
install_allowed: true
- name: "internal"
url: "https://internal.company.com/spec-kit/catalog.json"
priority: 2
install_allowed: true
- name: "community"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
priority: 3
install_allowed: false
```
A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. Project-level config takes full precedence when present.
## Organization Catalog Customization
### Why the Default Catalog is Empty
### Why Customize Your Catalog
The default spec-kit catalog ships empty by design. This allows organizations to:
Organizations customize their catalogs to:
- **Control available extensions** - Curate which extensions your team can install
- **Host private extensions** - Internal tools that shouldn't be public
@@ -497,24 +563,40 @@ Options for hosting your catalog:
#### 3. Configure Your Environment
##### Option A: Environment variable (recommended for CI/CD)
##### Option A: Catalog stack config file (recommended)
Add to `.specify/extension-catalogs.yml` in your project:
```yaml
catalogs:
- name: "org-approved"
url: "https://your-org.com/spec-kit/catalog.json"
priority: 1
install_allowed: true
```
Or use the CLI:
```bash
specify extension catalog add \
--name "org-approved" \
--install-allowed \
https://your-org.com/spec-kit/catalog.json
```
##### Option B: Environment variable (recommended for CI/CD, single-catalog)
```bash
# In ~/.bashrc, ~/.zshrc, or CI pipeline
export SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json"
```
##### Option B: Per-project configuration
Create `.env` or set in your shell before running spec-kit commands:
```bash
SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json" specify extension search
```
#### 4. Verify Configuration
```bash
# List active catalogs
specify extension catalogs
# Search should now show your catalog's extensions
specify extension search

119
extensions/README.md Normal file
View File

@@ -0,0 +1,119 @@
# Spec Kit Extensions
Extension system for [Spec Kit](https://github.com/github/spec-kit) - add new functionality without bloating the core framework.
## Extension Catalogs
Spec Kit provides two catalog files with different purposes:
### Your Catalog (`catalog.json`)
- **Purpose**: Default upstream catalog of extensions used by the Spec Kit CLI
- **Default State**: Empty by design in the upstream project - you or your organization populate a fork/copy with extensions you trust
- **Location (upstream)**: `extensions/catalog.json` in the GitHub-hosted spec-kit repo
- **CLI Default**: The `specify extension` commands use the upstream catalog URL by default, unless overridden
- **Org Catalog**: Point `SPECKIT_CATALOG_URL` at your organization's fork or hosted catalog JSON to use it instead of the upstream default
- **Customization**: Copy entries from the community catalog into your org catalog, or add your own extensions directly
**Example override:**
```bash
# Override the default upstream catalog with your organization's catalog
export SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json"
specify extension search # Now uses your organization's catalog instead of the upstream default
```
### Community Reference Catalog (`catalog.community.json`)
- **Purpose**: Browse available community-contributed extensions
- **Status**: Active - contains extensions submitted by the community
- **Location**: `extensions/catalog.community.json`
- **Usage**: Reference catalog for discovering available extensions
- **Submission**: Open to community contributions via Pull Request
**How It Works:**
## Making Extensions Available
You control which extensions your team can discover and install:
### Option 1: Curated Catalog (Recommended for Organizations)
Populate your `catalog.json` with approved extensions:
1. **Discover** extensions from various sources:
- Browse `catalog.community.json` for community extensions
- Find private/internal extensions in your organization's repos
- Discover extensions from trusted third parties
2. **Review** extensions and choose which ones you want to make available
3. **Add** those extension entries to your own `catalog.json`
4. **Team members** can now discover and install them:
- `specify extension search` shows your curated catalog
- `specify extension add <name>` installs from your catalog
**Benefits**: Full control over available extensions, team consistency, organizational approval workflow
**Example**: Copy an entry from `catalog.community.json` to your `catalog.json`, then your team can discover and install it by name.
### Option 2: Direct URLs (For Ad-hoc Use)
Skip catalog curation - team members install directly using URLs:
```bash
specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/tags/v1.0.0.zip
```
**Benefits**: Quick for one-off testing or private extensions
**Tradeoff**: Extensions installed this way won't appear in `specify extension search` for other team members unless you also add them to your `catalog.json`.
## Available Community Extensions
The following community-contributed extensions are available in [`catalog.community.json`](catalog.community.json):
| Extension | Purpose | URL |
|-----------|---------|-----|
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
## Adding Your Extension
### Submission Process
To add your extension to the community catalog:
1. **Prepare your extension** following the [Extension Development Guide](EXTENSION-DEVELOPMENT-GUIDE.md)
2. **Create a GitHub release** for your extension
3. **Submit a Pull Request** that:
- Adds your extension to `extensions/catalog.community.json`
- Updates this README with your extension in the Available Extensions table
4. **Wait for review** - maintainers will review and merge if criteria are met
See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed step-by-step instructions.
### Submission Checklist
Before submitting, ensure:
- ✅ Valid `extension.yml` manifest
- ✅ Complete README with installation and usage instructions
- ✅ LICENSE file included
- ✅ GitHub release created with semantic version (e.g., v1.0.0)
- ✅ Extension tested on a real project
- ✅ All commands working as documented
## Installing Extensions
Once extensions are available (either in your catalog or via direct URL), install them:
```bash
# From your curated catalog (by name)
specify extension search # See what's in your catalog
specify extension add <extension-name> # Install by name
# Direct from URL (bypasses catalog)
specify extension add --from https://github.com/<org>/<repo>/archive/refs/tags/<version>.zip
# List installed extensions
specify extension list
```
For more information, see the [Extension User Guide](EXTENSION-USER-GUIDE.md).

View File

@@ -858,11 +858,41 @@ def should_execute_hook(hook: dict, config: dict) -> bool:
## Extension Discovery & Catalog
### Central Catalog
### Dual Catalog System
Spec Kit uses two catalog files with different purposes:
#### User Catalog (`catalog.json`)
**URL**: `https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json`
**Format**:
- **Purpose**: Organization's curated catalog of approved extensions
- **Default State**: Empty by design - users populate with extensions they trust
- **Usage**: Primary catalog (priority 1, `install_allowed: true`) in the default stack
- **Control**: Organizations maintain their own fork/version for their teams
#### Community Reference Catalog (`catalog.community.json`)
**URL**: `https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json`
- **Purpose**: Reference catalog of available community-contributed extensions
- **Verification**: Community extensions may have `verified: false` initially
- **Status**: Active - open for community contributions
- **Submission**: Via Pull Request following the Extension Publishing Guide
- **Usage**: Secondary catalog (priority 2, `install_allowed: false`) in the default stack — discovery only
**How It Works (default stack):**
1. **Discover**: `specify extension search` searches both catalogs — community extensions appear automatically
2. **Review**: Evaluate community extensions for security, quality, and organizational fit
3. **Curate**: Copy approved entries from community catalog to your `catalog.json`, or add to `.specify/extension-catalogs.yml` with `install_allowed: true`
4. **Install**: Use `specify extension add <name>` — only allowed from `install_allowed: true` catalogs
This approach gives organizations full control over which extensions can be installed while still providing community discoverability out of the box.
### Catalog Format
**Format** (same for both catalogs):
```json
{
@@ -931,24 +961,110 @@ specify extension info jira
### Custom Catalogs
Organizations can host private catalogs:
Spec Kit supports a **catalog stack** — an ordered list of catalogs that the CLI merges and searches across. This allows organizations to benefit from org-approved extensions, an internal catalog, and community discovery all at once.
```bash
# Add custom catalog
specify extension add-catalog https://internal.company.com/spec-kit/catalog.json
#### Catalog Stack Resolution
# Set as default
specify extension set-catalog --default https://internal.company.com/spec-kit/catalog.json
The active catalog stack is resolved in this order (first match wins):
# List catalogs
specify extension catalogs
1. **`SPECKIT_CATALOG_URL` environment variable** — single catalog replacing all defaults (backward compat)
2. **Project-level `.specify/extension-catalogs.yml`** — full control for the project
3. **User-level `~/.specify/extension-catalogs.yml`** — personal defaults
4. **Built-in default stack** — `catalog.json` (install_allowed: true) + `catalog.community.json` (install_allowed: false)
#### Default Built-in Stack
When no config file exists, the CLI uses:
| Priority | Catalog | install_allowed | Purpose |
|----------|---------|-----------------|---------|
| 1 | `catalog.json` (org-approved) | `true` | Extensions your org approves for installation |
| 2 | `catalog.community.json` (community) | `false` | Discovery only — browse but not install |
This means `specify extension search` surfaces community extensions out of the box, while `specify extension add` is still restricted to org-approved entries.
#### `.specify/extension-catalogs.yml` Config File
```yaml
catalogs:
- name: "org-approved"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
priority: 1 # Highest — only approved entries can be installed
install_allowed: true
- name: "internal"
url: "https://internal.company.com/spec-kit/catalog.json"
priority: 2
install_allowed: true
- name: "community"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
priority: 3 # Lowest — discovery only, not installable
install_allowed: false
```
**Catalog priority**:
A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. When a project-level config is present, it takes full control and the built-in defaults are not applied.
1. Project-specific catalog (`.specify/extension-catalogs.yml`)
2. User-level catalog (`~/.specify/extension-catalogs.yml`)
3. Default GitHub catalog
#### Catalog CLI Commands
```bash
# List active catalogs with name, URL, priority, and install_allowed
specify extension catalogs
# Add a catalog (project-scoped)
specify extension catalog add --name "internal" --install-allowed \
https://internal.company.com/spec-kit/catalog.json
# Add a discovery-only catalog
specify extension catalog add --name "community" \
https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json
# Remove a catalog
specify extension catalog remove internal
# Show which catalog an extension came from
specify extension info jira
# → Source catalog: org-approved
```
#### Merge Conflict Resolution
When the same extension `id` appears in multiple catalogs, the higher-priority (lower priority number) catalog wins. Extensions from lower-priority catalogs with the same `id` are ignored.
#### `install_allowed: false` Behavior
Extensions from discovery-only catalogs are shown in `specify extension search` results but cannot be installed directly:
```
⚠ 'linear' is available in the 'community' catalog but installation is not allowed from that catalog.
To enable installation, add 'linear' to an approved catalog (install_allowed: true) in .specify/extension-catalogs.yml.
```
#### `SPECKIT_CATALOG_URL` (Backward Compatibility)
The `SPECKIT_CATALOG_URL` environment variable still works — it is treated as a single `install_allowed: true` catalog, **replacing both defaults** for full backward compatibility:
```bash
# Point to your organization's catalog
export SPECKIT_CATALOG_URL="https://internal.company.com/spec-kit/catalog.json"
# All extension commands now use your custom catalog
specify extension search # Uses custom catalog
specify extension add jira # Installs from custom catalog
```
**Requirements:**
- URL must use HTTPS (HTTP only allowed for localhost testing)
- Catalog must follow the standard catalog.json schema
- Must be publicly accessible or accessible within your network
**Example for testing:**
```bash
# Test with localhost during development
export SPECKIT_CATALOG_URL="http://localhost:8000/catalog.json"
specify extension search
```
---

View File

@@ -0,0 +1,85 @@
{
"schema_version": "1.0",
"updated_at": "2026-02-24T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"cleanup": {
"name": "Cleanup Extension",
"id": "cleanup",
"description": "Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues.",
"author": "dsrednicki",
"version": "1.0.0",
"download_url": "https://github.com/dsrednicki/spec-kit-cleanup/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/dsrednicki/spec-kit-cleanup",
"homepage": "https://github.com/dsrednicki/spec-kit-cleanup",
"documentation": "https://github.com/dsrednicki/spec-kit-cleanup/blob/main/README.md",
"changelog": "https://github.com/dsrednicki/spec-kit-cleanup/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 1,
"hooks": 1
},
"tags": ["quality", "tech-debt", "review", "cleanup", "scout-rule"],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-02-22T00:00:00Z",
"updated_at": "2026-02-22T00:00:00Z"
},
"retrospective": {
"name": "Retrospective Extension",
"id": "retrospective",
"description": "Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates.",
"author": "emi-dm",
"version": "1.0.0",
"download_url": "https://github.com/emi-dm/spec-kit-retrospective/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/emi-dm/spec-kit-retrospective",
"homepage": "https://github.com/emi-dm/spec-kit-retrospective",
"documentation": "https://github.com/emi-dm/spec-kit-retrospective/blob/main/README.md",
"changelog": "https://github.com/emi-dm/spec-kit-retrospective/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 1,
"hooks": 1
},
"tags": ["retrospective", "spec-drift", "quality", "analysis", "governance"],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-02-24T00:00:00Z",
"updated_at": "2026-02-24T00: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.4.0",
"download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.4.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",
"changelog": "https://github.com/leocamello/spec-kit-v-model/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 9,
"hooks": 1
},
"tags": ["v-model", "traceability", "testing", "compliance", "safety-critical"],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-02-20T00:00:00Z",
"updated_at": "2026-02-22T00:00:00Z"
}
}
}

View File

@@ -1,60 +0,0 @@
{
"schema_version": "1.0",
"updated_at": "2026-02-03T00:00:00Z",
"catalog_url": "https://your-org.example.com/speckit/catalog.json",
"extensions": {
"jira": {
"name": "Jira Integration",
"id": "jira",
"description": "Create Jira Epics, Stories, and Issues from spec-kit artifacts",
"author": "Your Organization",
"version": "2.1.0",
"download_url": "https://github.com/your-org/spec-kit-jira/archive/refs/tags/v2.1.0.zip",
"repository": "https://github.com/your-org/spec-kit-jira",
"homepage": "https://github.com/your-org/spec-kit-jira",
"documentation": "https://github.com/your-org/spec-kit-jira/blob/main/README.md",
"changelog": "https://github.com/your-org/spec-kit-jira/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "atlassian",
"version": ">=1.0.0",
"required": true
}
]
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": ["jira", "atlassian", "issue-tracking"],
"verified": true,
"downloads": 0,
"stars": 0,
"created_at": "2026-01-28T00:00:00Z",
"updated_at": "2026-02-03T00:00:00Z"
},
"linear": {
"name": "Linear Integration",
"id": "linear",
"description": "Sync specs and tasks with Linear issues",
"author": "Your Organization",
"version": "1.0.0",
"download_url": "https://github.com/your-org/spec-kit-linear/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/your-org/spec-kit-linear",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 2
},
"tags": ["linear", "issue-tracking"],
"verified": false,
"created_at": "2026-01-30T00:00:00Z",
"updated_at": "2026-01-30T00:00:00Z"
}
}
}

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.1.4"
version = "0.1.7"
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

@@ -351,10 +351,19 @@ create_new_agent_file() {
# Convert \n sequences to actual newlines
newline=$(printf '\n')
sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file"
# Clean up backup files
rm -f "$temp_file.bak" "$temp_file.bak2"
# Prepend Cursor frontmatter for .mdc files so rules are auto-included
if [[ "$target_file" == *.mdc ]]; then
local frontmatter_file
frontmatter_file=$(mktemp) || return 1
printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file"
cat "$temp_file" >> "$frontmatter_file"
mv "$frontmatter_file" "$temp_file"
fi
return 0
}
@@ -492,13 +501,24 @@ update_existing_agent_file() {
changes_entries_added=true
fi
# Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion
if [[ "$target_file" == *.mdc ]]; then
if ! head -1 "$temp_file" | grep -q '^---'; then
local frontmatter_file
frontmatter_file=$(mktemp) || { rm -f "$temp_file"; return 1; }
printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file"
cat "$temp_file" >> "$frontmatter_file"
mv "$frontmatter_file" "$temp_file"
fi
fi
# Move temp file to target atomically
if ! mv "$temp_file" "$target_file"; then
log_error "Failed to update target file"
rm -f "$temp_file"
return 1
fi
return 0
}
#==============================================================================

View File

@@ -258,6 +258,12 @@ function New-AgentFile {
# Convert literal \n sequences introduced by Escape to real newlines
$content = $content -replace '\\n',[Environment]::NewLine
# Prepend Cursor frontmatter for .mdc files so rules are auto-included
if ($TargetFile -match '\.mdc$') {
$frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') -join [Environment]::NewLine
$content = $frontmatter + $content
}
$parent = Split-Path -Parent $TargetFile
if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null }
Set-Content -LiteralPath $TargetFile -Value $content -NoNewline -Encoding utf8
@@ -334,6 +340,12 @@ function Update-ExistingAgentFile {
$newTechEntries | ForEach-Object { $output.Add($_) }
}
# Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion
if ($TargetFile -match '\.mdc$' -and $output.Count -gt 0 -and $output[0] -ne '---') {
$frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','')
$output.InsertRange(0, $frontmatter)
}
Set-Content -LiteralPath $TargetFile -Value ($output -join [Environment]::NewLine) -Encoding utf8
return $true
}

View File

@@ -123,119 +123,138 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str)
return "\n".join(lines)
# Agent configuration with name, folder, install URL, and CLI tool requirement
# Agent configuration with name, folder, install URL, CLI tool requirement, and commands subdirectory
AGENT_CONFIG = {
"copilot": {
"name": "GitHub Copilot",
"folder": ".github/",
"commands_subdir": "agents", # Special: uses agents/ not commands/
"install_url": None, # IDE-based, no CLI check needed
"requires_cli": False,
},
"claude": {
"name": "Claude Code",
"folder": ".claude/",
"commands_subdir": "commands",
"install_url": "https://docs.anthropic.com/en/docs/claude-code/setup",
"requires_cli": True,
},
"gemini": {
"name": "Gemini CLI",
"folder": ".gemini/",
"commands_subdir": "commands",
"install_url": "https://github.com/google-gemini/gemini-cli",
"requires_cli": True,
},
"cursor-agent": {
"name": "Cursor",
"folder": ".cursor/",
"commands_subdir": "commands",
"install_url": None, # IDE-based
"requires_cli": False,
},
"qwen": {
"name": "Qwen Code",
"folder": ".qwen/",
"commands_subdir": "commands",
"install_url": "https://github.com/QwenLM/qwen-code",
"requires_cli": True,
},
"opencode": {
"name": "opencode",
"folder": ".opencode/",
"commands_subdir": "command", # Special: singular 'command' not 'commands'
"install_url": "https://opencode.ai",
"requires_cli": True,
},
"codex": {
"name": "Codex CLI",
"folder": ".codex/",
"commands_subdir": "prompts", # Special: uses prompts/ not commands/
"install_url": "https://github.com/openai/codex",
"requires_cli": True,
},
"windsurf": {
"name": "Windsurf",
"folder": ".windsurf/",
"commands_subdir": "workflows", # Special: uses workflows/ not commands/
"install_url": None, # IDE-based
"requires_cli": False,
},
"kilocode": {
"name": "Kilo Code",
"folder": ".kilocode/",
"commands_subdir": "workflows", # Special: uses workflows/ not commands/
"install_url": None, # IDE-based
"requires_cli": False,
},
"auggie": {
"name": "Auggie CLI",
"folder": ".augment/",
"commands_subdir": "commands",
"install_url": "https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli",
"requires_cli": True,
},
"codebuddy": {
"name": "CodeBuddy",
"folder": ".codebuddy/",
"commands_subdir": "commands",
"install_url": "https://www.codebuddy.ai/cli",
"requires_cli": True,
},
"qodercli": {
"name": "Qoder CLI",
"folder": ".qoder/",
"commands_subdir": "commands",
"install_url": "https://qoder.com/cli",
"requires_cli": True,
},
"roo": {
"name": "Roo Code",
"folder": ".roo/",
"commands_subdir": "commands",
"install_url": None, # IDE-based
"requires_cli": False,
},
"q": {
"name": "Amazon Q Developer CLI",
"folder": ".amazonq/",
"commands_subdir": "prompts", # Special: uses prompts/ not commands/
"install_url": "https://aws.amazon.com/developer/learning/q-developer-cli/",
"requires_cli": True,
},
"amp": {
"name": "Amp",
"folder": ".agents/",
"commands_subdir": "commands",
"install_url": "https://ampcode.com/manual#install",
"requires_cli": True,
},
"shai": {
"name": "SHAI",
"folder": ".shai/",
"commands_subdir": "commands",
"install_url": "https://github.com/ovh/shai",
"requires_cli": True,
},
"agy": {
"name": "Antigravity",
"folder": ".agent/",
"commands_subdir": "workflows", # Special: uses workflows/ not commands/
"install_url": None, # IDE-based
"requires_cli": False,
},
"bob": {
"name": "IBM Bob",
"folder": ".bob/",
"commands_subdir": "commands",
"install_url": None, # IDE-based
"requires_cli": False,
},
"generic": {
"name": "Generic (bring your own agent)",
"folder": None, # Set dynamically via --ai-commands-dir
"commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
},
@@ -1056,10 +1075,11 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
# download_and_extract_template() already placed the .md files here.
agent_config = AGENT_CONFIG.get(selected_ai, {})
agent_folder = agent_config.get("folder", "")
commands_subdir = agent_config.get("commands_subdir", "commands")
if agent_folder:
templates_dir = project_path / agent_folder.rstrip("/") / "commands"
templates_dir = project_path / agent_folder.rstrip("/") / commands_subdir
else:
templates_dir = project_path / "commands"
templates_dir = project_path / commands_subdir
if not templates_dir.exists() or not any(templates_dir.glob("*.md")):
# Fallback: try the repo-relative path (for running from source checkout)
@@ -1236,6 +1256,20 @@ def init(
show_banner()
# Detect when option values are likely misinterpreted flags (parameter ordering issue)
if ai_assistant and ai_assistant.startswith("--"):
console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'")
console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?")
console.print("[yellow]Example:[/yellow] specify init --ai claude --here")
console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}")
raise typer.Exit(1)
if ai_commands_dir and ai_commands_dir.startswith("--"):
console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'")
console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?")
console.print("[yellow]Example:[/yellow] specify init --ai generic --ai-commands-dir .myagent/commands/")
raise typer.Exit(1)
if project_name == ".":
here = True
project_name = None # Clear project_name to use existing validation logic
@@ -1686,6 +1720,13 @@ extension_app = typer.Typer(
)
app.add_typer(extension_app, name="extension")
catalog_app = typer.Typer(
name="catalog",
help="Manage extension catalogs",
add_completion=False,
)
extension_app.add_typer(catalog_app, name="catalog")
def get_speckit_version() -> str:
"""Get current spec-kit version."""
@@ -1751,6 +1792,157 @@ def extension_list(
console.print(" [cyan]specify extension add <name>[/cyan]")
@extension_app.command("catalogs")
def extension_catalogs():
"""List all active extension catalogs."""
from .extensions import ExtensionCatalog, ValidationError
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
catalog = ExtensionCatalog(project_root)
try:
active_catalogs = catalog.get_active_catalogs()
except ValidationError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
console.print("\n[bold cyan]Active Extension Catalogs:[/bold cyan]\n")
for entry in active_catalogs:
install_str = (
"[green]install allowed[/green]"
if entry.install_allowed
else "[yellow]discovery only[/yellow]"
)
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
console.print(f" URL: {entry.url}")
console.print(f" Install: {install_str}")
console.print()
config_path = project_root / ".specify" / "extension-catalogs.yml"
if config_path.exists() and catalog._load_catalog_config(config_path) is not None:
console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]")
elif os.environ.get("SPECKIT_CATALOG_URL"):
console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]")
else:
console.print("[dim]Using built-in default catalog stack.[/dim]")
console.print(
"[dim]Add .specify/extension-catalogs.yml to customize.[/dim]"
)
@catalog_app.command("add")
def catalog_add(
url: str = typer.Argument(help="Catalog URL (must use HTTPS)"),
name: str = typer.Option(..., "--name", help="Catalog name"),
priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"),
install_allowed: bool = typer.Option(
False, "--install-allowed/--no-install-allowed",
help="Allow extensions from this catalog to be installed",
),
):
"""Add a catalog to .specify/extension-catalogs.yml."""
from .extensions import ExtensionCatalog, ValidationError
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
# Validate URL
tmp_catalog = ExtensionCatalog(project_root)
try:
tmp_catalog._validate_catalog_url(url)
except ValidationError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
config_path = specify_dir / "extension-catalogs.yml"
# Load existing config
if config_path.exists():
try:
config = yaml.safe_load(config_path.read_text()) or {}
except Exception:
config = {}
else:
config = {}
catalogs = config.get("catalogs", [])
# Check for duplicate name
for existing in catalogs:
if existing.get("name") == name:
console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
console.print("Use 'specify extension catalog remove' first, or choose a different name.")
raise typer.Exit(1)
catalogs.append({
"name": name,
"url": url,
"priority": priority,
"install_allowed": install_allowed,
})
config["catalogs"] = catalogs
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
install_label = "install allowed" if install_allowed else "discovery only"
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
console.print(f" URL: {url}")
console.print(f" Priority: {priority}")
console.print(f"\nConfig saved to {config_path.relative_to(project_root)}")
@catalog_app.command("remove")
def catalog_remove(
name: str = typer.Argument(help="Catalog name to remove"),
):
"""Remove a catalog from .specify/extension-catalogs.yml."""
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
config_path = specify_dir / "extension-catalogs.yml"
if not config_path.exists():
console.print("[red]Error:[/red] No catalog config found. Nothing to remove.")
raise typer.Exit(1)
try:
config = yaml.safe_load(config_path.read_text()) or {}
except Exception:
console.print("[red]Error:[/red] Failed to read catalog config.")
raise typer.Exit(1)
catalogs = config.get("catalogs", [])
original_count = len(catalogs)
catalogs = [c for c in catalogs if c.get("name") != name]
if len(catalogs) == original_count:
console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
raise typer.Exit(1)
config["catalogs"] = catalogs
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
console.print(f"[green]✓[/green] Removed catalog '{name}'")
if not catalogs:
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
@extension_app.command("add")
def extension_add(
extension: str = typer.Argument(help="Extension name or path"),
@@ -1839,6 +2031,19 @@ def extension_add(
console.print(" specify extension search")
raise typer.Exit(1)
# Enforce install_allowed policy
if not ext_info.get("_install_allowed", True):
catalog_name = ext_info.get("_catalog_name", "community")
console.print(
f"[red]Error:[/red] '{extension}' is available in the "
f"'{catalog_name}' catalog but installation is not allowed from that catalog."
)
console.print(
f"\nTo enable installation, add '{extension}' to an approved catalog "
f"(install_allowed: true) in .specify/extension-catalogs.yml."
)
raise typer.Exit(1)
# Download extension ZIP
console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...")
zip_path = catalog.download_extension(extension)
@@ -1983,6 +2188,15 @@ def extension_search(
tags_str = ", ".join(ext['tags'])
console.print(f" [dim]Tags:[/dim] {tags_str}")
# Source catalog
catalog_name = ext.get("_catalog_name", "")
install_allowed = ext.get("_install_allowed", True)
if catalog_name:
if install_allowed:
console.print(f" [dim]Catalog:[/dim] {catalog_name}")
else:
console.print(f" [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]")
# Stats
stats = []
if ext.get('downloads') is not None:
@@ -1996,8 +2210,15 @@ def extension_search(
if ext.get('repository'):
console.print(f" [dim]Repository:[/dim] {ext['repository']}")
# Install command
console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}")
# Install command (show warning if not installable)
if install_allowed:
console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}")
else:
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.")
console.print(
" Add to an approved catalog with install_allowed: true, "
"or use: specify extension add --from <url>"
)
console.print()
except ExtensionError as e:
@@ -2046,6 +2267,12 @@ def extension_info(
# Author and License
console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}")
console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}")
# Source catalog
if ext_info.get("_catalog_name"):
install_allowed = ext_info.get("_install_allowed", True)
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
console.print(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}")
console.print()
# Requirements
@@ -2102,12 +2329,21 @@ def extension_info(
# Installation status and command
is_installed = manager.registry.is_installed(ext_info['id'])
install_allowed = ext_info.get("_install_allowed", True)
if is_installed:
console.print("[green]✓ Installed[/green]")
console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
else:
elif install_allowed:
console.print("[yellow]Not installed[/yellow]")
console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}")
else:
catalog_name = ext_info.get("_catalog_name", "community")
console.print("[yellow]Not installed[/yellow]")
console.print(
f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog "
f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml "
f"with install_allowed: true to enable installation."
)
except ExtensionError as e:
console.print(f"\n[red]Error:[/red] {e}")

View File

@@ -8,9 +8,11 @@ without bloating the core framework.
import json
import hashlib
import os
import tempfile
import zipfile
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Dict, List, Any
from datetime import datetime, timezone
@@ -36,6 +38,15 @@ class CompatibilityError(ExtensionError):
pass
@dataclass
class CatalogEntry:
"""Represents a single catalog entry in the catalog stack."""
url: str
name: str
priority: int
install_allowed: bool
class ExtensionManifest:
"""Represents and validates an extension manifest (extension.yml)."""
@@ -940,6 +951,7 @@ class ExtensionCatalog:
"""Manages extension catalog fetching, caching, and searching."""
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
CACHE_DURATION = 3600 # 1 hour in seconds
def __init__(self, project_root: Path):
@@ -954,43 +966,82 @@ class ExtensionCatalog:
self.cache_file = self.cache_dir / "catalog.json"
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
def get_catalog_url(self) -> str:
"""Get catalog URL from config or use default.
def _validate_catalog_url(self, url: str) -> None:
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).
Checks in order:
1. SPECKIT_CATALOG_URL environment variable
2. Default catalog URL
Returns:
URL to fetch catalog from
Args:
url: URL to validate
Raises:
ValidationError: If custom URL is invalid (non-HTTPS)
ValidationError: If URL is invalid or uses non-HTTPS scheme
"""
import os
import sys
from urllib.parse import urlparse
# Environment variable override (useful for testing)
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise ValidationError(
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.netloc:
raise ValidationError("Catalog URL must be a valid URL with a host.")
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
"""Load catalog stack configuration from a YAML file.
Args:
config_path: Path to extension-catalogs.yml
Returns:
Ordered list of CatalogEntry objects, or None if file doesn't exist
or contains no valid catalog entries.
"""
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text()) or {}
catalogs_data = data.get("catalogs", [])
if not catalogs_data:
return None
entries: List[CatalogEntry] = []
for idx, item in enumerate(catalogs_data):
url = str(item.get("url", "")).strip()
if not url:
continue
self._validate_catalog_url(url)
entries.append(CatalogEntry(
url=url,
name=str(item.get("name", f"catalog-{idx + 1}")),
priority=int(item.get("priority", idx + 1)),
install_allowed=bool(item.get("install_allowed", True)),
))
entries.sort(key=lambda e: e.priority)
return entries if entries else None
except (yaml.YAMLError, OSError):
return None
def get_active_catalogs(self) -> List[CatalogEntry]:
"""Get the ordered list of active catalogs.
Resolution order:
1. SPECKIT_CATALOG_URL env var — single catalog replacing all defaults
2. Project-level .specify/extension-catalogs.yml
3. User-level ~/.specify/extension-catalogs.yml
4. Built-in default stack (org-approved + community)
Returns:
List of CatalogEntry objects sorted by priority (ascending)
Raises:
ValidationError: If a catalog URL is invalid
"""
import sys
# 1. SPECKIT_CATALOG_URL env var replaces all defaults for backward compat
if env_value := os.environ.get("SPECKIT_CATALOG_URL"):
catalog_url = env_value.strip()
parsed = urlparse(catalog_url)
# Require HTTPS for security (prevent man-in-the-middle attacks)
# Allow http://localhost for local development/testing
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise ValidationError(
f"Invalid SPECKIT_CATALOG_URL: must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.netloc:
raise ValidationError(
"Invalid SPECKIT_CATALOG_URL: must be a valid URL with a host."
)
# Warn users when using a non-default catalog (once per instance)
self._validate_catalog_url(catalog_url)
if catalog_url != self.DEFAULT_CATALOG_URL:
if not getattr(self, "_non_default_catalog_warning_shown", False):
print(
@@ -999,11 +1050,161 @@ class ExtensionCatalog:
file=sys.stderr,
)
self._non_default_catalog_warning_shown = True
return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True)]
return catalog_url
# 2. Project-level config overrides all defaults
project_config_path = self.project_root / ".specify" / "extension-catalogs.yml"
catalogs = self._load_catalog_config(project_config_path)
if catalogs is not None:
return catalogs
# TODO: Support custom catalogs from .specify/extension-catalogs.yml
return self.DEFAULT_CATALOG_URL
# 3. User-level config
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
catalogs = self._load_catalog_config(user_config_path)
if catalogs is not None:
return catalogs
# 4. Built-in default stack
return [
CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="org-approved", priority=1, install_allowed=True),
CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False),
]
def get_catalog_url(self) -> str:
"""Get the primary catalog URL.
Returns the URL of the highest-priority catalog. Kept for backward
compatibility. Use get_active_catalogs() for full multi-catalog support.
Returns:
URL of the primary catalog
Raises:
ValidationError: If a catalog URL is invalid
"""
active = self.get_active_catalogs()
return active[0].url if active else self.DEFAULT_CATALOG_URL
def _fetch_single_catalog(self, entry: CatalogEntry, force_refresh: bool = False) -> Dict[str, Any]:
"""Fetch a single catalog with per-URL caching.
For the DEFAULT_CATALOG_URL, uses legacy cache files (self.cache_file /
self.cache_metadata_file) for backward compatibility. For all other URLs,
uses URL-hash-based cache files in self.cache_dir.
Args:
entry: CatalogEntry describing the catalog to fetch
force_refresh: If True, bypass cache
Returns:
Catalog data dictionary
Raises:
ExtensionError: If catalog cannot be fetched or has invalid format
"""
import urllib.request
import urllib.error
# Determine cache file paths (backward compat for default catalog)
if entry.url == self.DEFAULT_CATALOG_URL:
cache_file = self.cache_file
cache_meta_file = self.cache_metadata_file
is_valid = not force_refresh and self.is_cache_valid()
else:
url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16]
cache_file = self.cache_dir / f"catalog-{url_hash}.json"
cache_meta_file = self.cache_dir / f"catalog-{url_hash}-metadata.json"
is_valid = False
if not force_refresh and cache_file.exists() and cache_meta_file.exists():
try:
metadata = json.loads(cache_meta_file.read_text())
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
is_valid = age < self.CACHE_DURATION
except (json.JSONDecodeError, ValueError, KeyError):
# If metadata is invalid or missing expected fields, treat cache as invalid
pass
# Use cache if valid
if is_valid:
try:
return json.loads(cache_file.read_text())
except json.JSONDecodeError:
pass
# Fetch from network
try:
with urllib.request.urlopen(entry.url, timeout=10) as response:
catalog_data = json.loads(response.read())
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
raise ExtensionError(f"Invalid catalog format from {entry.url}")
# Save to cache
self.cache_dir.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(catalog_data, indent=2))
cache_meta_file.write_text(json.dumps({
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": entry.url,
}, indent=2))
return catalog_data
except urllib.error.URLError as e:
raise ExtensionError(f"Failed to fetch catalog from {entry.url}: {e}")
except json.JSONDecodeError as e:
raise ExtensionError(f"Invalid JSON in catalog from {entry.url}: {e}")
def _get_merged_extensions(self, force_refresh: bool = False) -> List[Dict[str, Any]]:
"""Fetch and merge extensions from all active catalogs.
Higher-priority (lower priority number) catalogs win on conflicts
(same extension id in two catalogs). Each extension dict is annotated with:
- _catalog_name: name of the source catalog
- _install_allowed: whether installation is allowed from this catalog
Catalogs that fail to fetch are skipped. Raises ExtensionError only if
ALL catalogs fail.
Args:
force_refresh: If True, bypass all caches
Returns:
List of merged extension dicts
Raises:
ExtensionError: If all catalogs fail to fetch
"""
import sys
active_catalogs = self.get_active_catalogs()
merged: Dict[str, Dict[str, Any]] = {}
any_success = False
for catalog_entry in active_catalogs:
try:
catalog_data = self._fetch_single_catalog(catalog_entry, force_refresh)
any_success = True
except ExtensionError as e:
print(
f"Warning: Could not fetch catalog '{catalog_entry.name}': {e}",
file=sys.stderr,
)
continue
for ext_id, ext_data in catalog_data.get("extensions", {}).items():
if ext_id not in merged: # Higher-priority catalog wins
merged[ext_id] = {
"id": ext_id,
**ext_data,
"_catalog_name": catalog_entry.name,
"_install_allowed": catalog_entry.install_allowed,
}
if not any_success and active_catalogs:
raise ExtensionError("Failed to fetch any extension catalog")
return list(merged.values())
def is_cache_valid(self) -> bool:
"""Check if cached catalog is still valid.
@@ -1080,7 +1281,7 @@ class ExtensionCatalog:
author: Optional[str] = None,
verified_only: bool = False,
) -> List[Dict[str, Any]]:
"""Search catalog for extensions.
"""Search catalog for extensions across all active catalogs.
Args:
query: Search query (searches name, description, tags)
@@ -1089,14 +1290,16 @@ class ExtensionCatalog:
verified_only: If True, show only verified extensions
Returns:
List of matching extension metadata
List of matching extension metadata, each annotated with
``_catalog_name`` and ``_install_allowed`` from its source catalog.
"""
catalog = self.fetch_catalog()
extensions = catalog.get("extensions", {})
all_extensions = self._get_merged_extensions()
results = []
for ext_id, ext_data in extensions.items():
for ext_data in all_extensions:
ext_id = ext_data["id"]
# Apply filters
if verified_only and not ext_data.get("verified", False):
continue
@@ -1122,25 +1325,26 @@ class ExtensionCatalog:
if query_lower not in searchable_text:
continue
results.append({"id": ext_id, **ext_data})
results.append(ext_data)
return results
def get_extension_info(self, extension_id: str) -> Optional[Dict[str, Any]]:
"""Get detailed information about a specific extension.
Searches all active catalogs in priority order.
Args:
extension_id: ID of the extension
Returns:
Extension metadata or None if not found
Extension metadata (annotated with ``_catalog_name`` and
``_install_allowed``) or None if not found.
"""
catalog = self.fetch_catalog()
extensions = catalog.get("extensions", {})
if extension_id in extensions:
return {"id": extension_id, **extensions[extension_id]}
all_extensions = self._get_merged_extensions()
for ext_data in all_extensions:
if ext_data["id"] == extension_id:
return ext_data
return None
def download_extension(self, extension_id: str, target_dir: Optional[Path] = None) -> Path:
@@ -1200,11 +1404,18 @@ class ExtensionCatalog:
raise ExtensionError(f"Failed to save extension ZIP: {e}")
def clear_cache(self):
"""Clear the catalog cache."""
"""Clear the catalog cache (both legacy and URL-hash-based files)."""
if self.cache_file.exists():
self.cache_file.unlink()
if self.cache_metadata_file.exists():
self.cache_metadata_file.unlink()
# Also clear any per-URL hash-based cache files
if self.cache_dir.exists():
for extra_cache in self.cache_dir.glob("catalog-*.json"):
if extra_cache != self.cache_file:
extra_cache.unlink(missing_ok=True)
for extra_meta in self.cache_dir.glob("catalog-*-metadata.json"):
extra_meta.unlink(missing_ok=True)
class ConfigManager:

View File

@@ -75,10 +75,11 @@ You **MUST** consider the user input before proceeding (if not empty).
- Validation rules from requirements
- State transitions if applicable
2. **Generate API contracts** from functional requirements:
- For each user action → endpoint
- Use standard REST/GraphQL patterns
- Output OpenAPI/GraphQL schema to `/contracts/`
2. **Define interface contracts** (if project has external interfaces) → `/contracts/`:
- Identify what interfaces the project exposes to users or other systems
- Document the contract format appropriate for the project type
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
- Skip if project is purely internal (build scripts, one-off tools, etc.)
3. **Agent context update**:
- Run `{AGENT_SCRIPT}`

View File

@@ -235,7 +235,7 @@ When creating this spec from a user prompt:
- Performance targets: Standard web/mobile app expectations unless specified
- Error handling: User-friendly messages with appropriate fallbacks
- Authentication method: Standard session-based or OAuth2 for web apps
- Integration patterns: RESTful APIs unless specified otherwise
- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.)
### Success Criteria Guidelines

View File

@@ -28,14 +28,14 @@ You **MUST** consider the user input before proceeding (if not empty).
2. **Load design documents**: Read from FEATURE_DIR:
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
- **Optional**: data-model.md (entities), contracts/ (API endpoints), research.md (decisions), quickstart.md (test scenarios)
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
- Note: Not all projects have all documents. Generate tasks based on what's available.
3. **Execute task generation workflow**:
- Load plan.md and extract tech stack, libraries, project structure
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
- If data-model.md exists: Extract entities and map to user stories
- If contracts/ exists: Map endpoints to user stories
- If contracts/ exists: Map interface contracts to user stories
- If research.md exists: Extract decisions for setup tasks
- Generate tasks organized by user story (see Task Generation Rules below)
- Generate dependency graph showing user story completion order
@@ -112,13 +112,13 @@ Every task MUST strictly follow this format:
- Map all related components to their story:
- Models needed for that story
- Services needed for that story
- Endpoints/UI needed for that story
- Interfaces/UI needed for that story
- If tests requested: Tests specific to that story
- Mark story dependencies (most stories should be independent)
2. **From Contracts**:
- Map each contract/endpoint → to the user story it serves
- If tests requested: Each contract → contract test task [P] before implementation in that story's phase
- Map each interface contract → to the user story it serves
- If tests requested: Each interface contract → contract test task [P] before implementation in that story's phase
3. **From Data Model**:
- Map each entity to the user story(ies) that need it

View File

@@ -22,7 +22,7 @@
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
**Project Type**: [single/web/mobile - determines source structure]
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]

View File

@@ -630,3 +630,65 @@ class TestCliValidation:
plain = re.sub(r'\x1b\[[0-9;]*m', '', result.output)
assert "--ai-skills" in plain
assert "agent skills" in plain.lower()
class TestParameterOrderingIssue:
"""Test fix for GitHub issue #1641: parameter ordering issues."""
def test_ai_flag_consuming_here_flag(self):
"""--ai without value should not consume --here flag (issue #1641)."""
from typer.testing import CliRunner
runner = CliRunner()
# This used to fail with "Must specify project name" because --here was consumed by --ai
result = runner.invoke(app, ["init", "--ai-skills", "--ai", "--here"])
assert result.exit_code == 1
assert "Invalid value for --ai" in result.output
assert "--here" in result.output # Should mention the invalid value
def test_ai_flag_consuming_ai_skills_flag(self):
"""--ai without value should not consume --ai-skills flag."""
from typer.testing import CliRunner
runner = CliRunner()
# This should fail with helpful error about missing --ai value
result = runner.invoke(app, ["init", "--here", "--ai", "--ai-skills"])
assert result.exit_code == 1
assert "Invalid value for --ai" in result.output
assert "--ai-skills" in result.output # Should mention the invalid value
def test_error_message_provides_hint(self):
"""Error message should provide helpful hint about missing value."""
from typer.testing import CliRunner
runner = CliRunner()
result = runner.invoke(app, ["init", "--ai", "--here"])
assert result.exit_code == 1
assert "Hint:" in result.output or "hint" in result.output.lower()
assert "forget to provide a value" in result.output.lower()
def test_error_message_lists_available_agents(self):
"""Error message should list available agents."""
from typer.testing import CliRunner
runner = CliRunner()
result = runner.invoke(app, ["init", "--ai", "--here"])
assert result.exit_code == 1
# Should mention some known agents
output_lower = result.output.lower()
assert any(agent in output_lower for agent in ["claude", "copilot", "gemini"])
def test_ai_commands_dir_consuming_flag(self):
"""--ai-commands-dir without value should not consume next flag."""
from typer.testing import CliRunner
runner = CliRunner()
result = runner.invoke(app, ["init", "myproject", "--ai", "generic", "--ai-commands-dir", "--here"])
assert result.exit_code == 1
assert "Invalid value for --ai-commands-dir" in result.output
assert "--here" in result.output

View File

@@ -0,0 +1,263 @@
"""
Tests for Cursor .mdc frontmatter generation (issue #669).
Verifies that update-agent-context.sh properly prepends YAML frontmatter
to .mdc files so that Cursor IDE auto-includes the rules.
"""
import os
import shutil
import subprocess
import textwrap
import pytest
SCRIPT_PATH = os.path.join(
os.path.dirname(__file__),
os.pardir,
"scripts",
"bash",
"update-agent-context.sh",
)
EXPECTED_FRONTMATTER_LINES = [
"---",
"description: Project Development Guidelines",
'globs: ["**/*"]',
"alwaysApply: true",
"---",
]
requires_git = pytest.mark.skipif(
shutil.which("git") is None,
reason="git is not installed",
)
class TestScriptFrontmatterPattern:
"""Static analysis — no git required."""
def test_create_new_has_mdc_frontmatter_logic(self):
"""create_new_agent_file() must contain .mdc frontmatter logic."""
with open(SCRIPT_PATH, encoding="utf-8") as f:
content = f.read()
assert 'if [[ "$target_file" == *.mdc ]]' in content
assert "alwaysApply: true" in content
def test_update_existing_has_mdc_frontmatter_logic(self):
"""update_existing_agent_file() must also handle .mdc frontmatter."""
with open(SCRIPT_PATH, encoding="utf-8") as f:
content = f.read()
# There should be two occurrences of the .mdc check — one per function
occurrences = content.count('if [[ "$target_file" == *.mdc ]]')
assert occurrences >= 2, (
f"Expected at least 2 .mdc frontmatter checks, found {occurrences}"
)
def test_powershell_script_has_mdc_frontmatter_logic(self):
"""PowerShell script must also handle .mdc frontmatter."""
ps_path = os.path.join(
os.path.dirname(__file__),
os.pardir,
"scripts",
"powershell",
"update-agent-context.ps1",
)
with open(ps_path, encoding="utf-8") as f:
content = f.read()
assert "alwaysApply: true" in content
occurrences = content.count(r"\.mdc$")
assert occurrences >= 2, (
f"Expected at least 2 .mdc frontmatter checks in PS script, found {occurrences}"
)
@requires_git
class TestCursorFrontmatterIntegration:
"""Integration tests using a real git repo."""
@pytest.fixture
def git_repo(self, tmp_path):
"""Create a minimal git repo with the spec-kit structure."""
repo = tmp_path / "repo"
repo.mkdir()
# Init git repo
subprocess.run(
["git", "init"], cwd=str(repo), capture_output=True, check=True
)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=str(repo),
capture_output=True,
check=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=str(repo),
capture_output=True,
check=True,
)
# Create .specify dir with config
specify_dir = repo / ".specify"
specify_dir.mkdir()
(specify_dir / "config.yaml").write_text(
textwrap.dedent("""\
project_type: webapp
language: python
framework: fastapi
database: N/A
""")
)
# Create template
templates_dir = specify_dir / "templates"
templates_dir.mkdir()
(templates_dir / "agent-file-template.md").write_text(
"# [PROJECT NAME] Development Guidelines\n\n"
"Auto-generated from all feature plans. Last updated: [DATE]\n\n"
"## Active Technologies\n\n"
"[EXTRACTED FROM ALL PLAN.MD FILES]\n\n"
"## Project Structure\n\n"
"[ACTUAL STRUCTURE FROM PLANS]\n\n"
"## Development Commands\n\n"
"[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES]\n\n"
"## Coding Conventions\n\n"
"[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE]\n\n"
"## Recent Changes\n\n"
"[LAST 3 FEATURES AND WHAT THEY ADDED]\n"
)
# Create initial commit
subprocess.run(
["git", "add", "-A"], cwd=str(repo), capture_output=True, check=True
)
subprocess.run(
["git", "commit", "-m", "init"],
cwd=str(repo),
capture_output=True,
check=True,
)
# Create a feature branch so CURRENT_BRANCH detection works
subprocess.run(
["git", "checkout", "-b", "001-test-feature"],
cwd=str(repo),
capture_output=True,
check=True,
)
# Create a spec so the script detects the feature
spec_dir = repo / "specs" / "001-test-feature"
spec_dir.mkdir(parents=True)
(spec_dir / "plan.md").write_text(
"# Test Feature Plan\n\n"
"## Technology Stack\n\n"
"- Language: Python\n"
"- Framework: FastAPI\n"
)
return repo
def _run_update(self, repo, agent_type="cursor-agent"):
"""Run update-agent-context.sh for a specific agent type."""
script = os.path.abspath(SCRIPT_PATH)
result = subprocess.run(
["bash", script, agent_type],
cwd=str(repo),
capture_output=True,
text=True,
timeout=30,
)
return result
def test_new_mdc_file_has_frontmatter(self, git_repo):
"""Creating a new .mdc file must include YAML frontmatter."""
result = self._run_update(git_repo)
assert result.returncode == 0, f"Script failed: {result.stderr}"
mdc_file = git_repo / ".cursor" / "rules" / "specify-rules.mdc"
assert mdc_file.exists(), "Cursor .mdc file was not created"
content = mdc_file.read_text()
lines = content.splitlines()
# First line must be the opening ---
assert lines[0] == "---", f"Expected frontmatter start, got: {lines[0]}"
# Check all frontmatter lines are present
for expected in EXPECTED_FRONTMATTER_LINES:
assert expected in content, f"Missing frontmatter line: {expected}"
# Content after frontmatter should be the template content
assert "Development Guidelines" in content
def test_existing_mdc_without_frontmatter_gets_it_added(self, git_repo):
"""Updating an existing .mdc file that lacks frontmatter must add it."""
# First, create the file WITHOUT frontmatter (simulating pre-fix state)
cursor_dir = git_repo / ".cursor" / "rules"
cursor_dir.mkdir(parents=True, exist_ok=True)
mdc_file = cursor_dir / "specify-rules.mdc"
mdc_file.write_text(
"# repo Development Guidelines\n\n"
"Auto-generated from all feature plans. Last updated: 2025-01-01\n\n"
"## Active Technologies\n\n"
"- Python + FastAPI (main)\n\n"
"## Recent Changes\n\n"
"- main: Added Python + FastAPI\n"
)
result = self._run_update(git_repo)
assert result.returncode == 0, f"Script failed: {result.stderr}"
content = mdc_file.read_text()
lines = content.splitlines()
assert lines[0] == "---", f"Expected frontmatter start, got: {lines[0]}"
for expected in EXPECTED_FRONTMATTER_LINES:
assert expected in content, f"Missing frontmatter line: {expected}"
def test_existing_mdc_with_frontmatter_not_duplicated(self, git_repo):
"""Updating an .mdc file that already has frontmatter must not duplicate it."""
cursor_dir = git_repo / ".cursor" / "rules"
cursor_dir.mkdir(parents=True, exist_ok=True)
mdc_file = cursor_dir / "specify-rules.mdc"
frontmatter = (
"---\n"
"description: Project Development Guidelines\n"
'globs: ["**/*"]\n'
"alwaysApply: true\n"
"---\n\n"
)
body = (
"# repo Development Guidelines\n\n"
"Auto-generated from all feature plans. Last updated: 2025-01-01\n\n"
"## Active Technologies\n\n"
"- Python + FastAPI (main)\n\n"
"## Recent Changes\n\n"
"- main: Added Python + FastAPI\n"
)
mdc_file.write_text(frontmatter + body)
result = self._run_update(git_repo)
assert result.returncode == 0, f"Script failed: {result.stderr}"
content = mdc_file.read_text()
# Count occurrences of the frontmatter delimiter
assert content.count("alwaysApply: true") == 1, (
"Frontmatter was duplicated"
)
def test_non_mdc_file_has_no_frontmatter(self, git_repo):
"""Non-.mdc agent files (e.g., Claude) must NOT get frontmatter."""
result = self._run_update(git_repo, agent_type="claude")
assert result.returncode == 0, f"Script failed: {result.stderr}"
claude_file = git_repo / ".claude" / "CLAUDE.md"
if claude_file.exists():
content = claude_file.read_text()
assert not content.startswith("---"), (
"Non-mdc file should not have frontmatter"
)

View File

@@ -6,6 +6,7 @@ Tests cover:
- Extension registry operations
- Extension manager installation/removal
- Command registration
- Catalog stack (multi-catalog support)
"""
import pytest
@@ -16,6 +17,7 @@ from pathlib import Path
from datetime import datetime, timezone
from specify_cli.extensions import (
CatalogEntry,
ExtensionManifest,
ExtensionRegistry,
ExtensionManager,
@@ -734,10 +736,29 @@ class TestExtensionCatalog:
def test_search_all_extensions(self, temp_dir):
"""Test searching all extensions without filters."""
import yaml as yaml_module
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
# Use a single-catalog config so community extensions don't interfere
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir)
# Create mock catalog
@@ -987,3 +1008,373 @@ class TestExtensionCatalog:
assert not catalog.cache_file.exists()
assert not catalog.cache_metadata_file.exists()
# ===== CatalogEntry Tests =====
class TestCatalogEntry:
"""Test CatalogEntry dataclass."""
def test_catalog_entry_creation(self):
"""Test creating a CatalogEntry."""
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="test",
priority=1,
install_allowed=True,
)
assert entry.url == "https://example.com/catalog.json"
assert entry.name == "test"
assert entry.priority == 1
assert entry.install_allowed is True
# ===== Catalog Stack Tests =====
class TestCatalogStack:
"""Test multi-catalog stack support."""
def _make_project(self, temp_dir: Path) -> Path:
"""Create a minimal spec-kit project directory."""
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
return project_dir
def _write_catalog_config(self, project_dir: Path, catalogs: list) -> None:
"""Write extension-catalogs.yml to project .specify dir."""
import yaml as yaml_module
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump({"catalogs": catalogs}, f)
def _write_valid_cache(
self, catalog: ExtensionCatalog, extensions: dict, url: str = "http://test.com"
) -> None:
"""Populate the primary cache file with mock extension data."""
catalog_data = {"schema_version": "1.0", "extensions": extensions}
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": url,
}
)
)
# --- get_active_catalogs ---
def test_default_stack(self, temp_dir):
"""Default stack includes org-approved and community catalogs."""
project_dir = self._make_project(temp_dir)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
assert len(entries) == 2
assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL
assert entries[0].name == "org-approved"
assert entries[0].priority == 1
assert entries[0].install_allowed is True
assert entries[1].url == ExtensionCatalog.COMMUNITY_CATALOG_URL
assert entries[1].name == "community"
assert entries[1].priority == 2
assert entries[1].install_allowed is False
def test_env_var_overrides_default_stack(self, temp_dir, monkeypatch):
"""SPECKIT_CATALOG_URL replaces the entire default stack."""
project_dir = self._make_project(temp_dir)
custom_url = "https://example.com/catalog.json"
monkeypatch.setenv("SPECKIT_CATALOG_URL", custom_url)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
assert len(entries) == 1
assert entries[0].url == custom_url
assert entries[0].install_allowed is True
def test_env_var_invalid_url_raises(self, temp_dir, monkeypatch):
"""SPECKIT_CATALOG_URL with http:// (non-localhost) raises ValidationError."""
project_dir = self._make_project(temp_dir)
monkeypatch.setenv("SPECKIT_CATALOG_URL", "http://example.com/catalog.json")
catalog = ExtensionCatalog(project_dir)
with pytest.raises(ValidationError, match="HTTPS"):
catalog.get_active_catalogs()
def test_project_config_overrides_defaults(self, temp_dir):
"""Project-level extension-catalogs.yml overrides default stack."""
project_dir = self._make_project(temp_dir)
self._write_catalog_config(
project_dir,
[
{
"name": "custom",
"url": "https://example.com/catalog.json",
"priority": 1,
"install_allowed": True,
}
],
)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
assert len(entries) == 1
assert entries[0].url == "https://example.com/catalog.json"
assert entries[0].name == "custom"
def test_project_config_sorted_by_priority(self, temp_dir):
"""Catalog entries are sorted by priority (ascending)."""
project_dir = self._make_project(temp_dir)
self._write_catalog_config(
project_dir,
[
{
"name": "secondary",
"url": "https://example.com/secondary.json",
"priority": 5,
"install_allowed": False,
},
{
"name": "primary",
"url": "https://example.com/primary.json",
"priority": 1,
"install_allowed": True,
},
],
)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
assert len(entries) == 2
assert entries[0].name == "primary"
assert entries[1].name == "secondary"
def test_project_config_invalid_url_raises(self, temp_dir):
"""Project config with HTTP (non-localhost) URL raises ValidationError."""
project_dir = self._make_project(temp_dir)
self._write_catalog_config(
project_dir,
[
{
"name": "bad",
"url": "http://example.com/catalog.json",
"priority": 1,
"install_allowed": True,
}
],
)
catalog = ExtensionCatalog(project_dir)
with pytest.raises(ValidationError, match="HTTPS"):
catalog.get_active_catalogs()
def test_empty_project_config_falls_back_to_defaults(self, temp_dir):
"""Empty catalogs list in config falls back to default stack."""
import yaml as yaml_module
project_dir = self._make_project(temp_dir)
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump({"catalogs": []}, f)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
# Falls back to default stack
assert len(entries) == 2
assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL
# --- _load_catalog_config ---
def test_load_catalog_config_missing_file(self, temp_dir):
"""Returns None when config file doesn't exist."""
project_dir = self._make_project(temp_dir)
catalog = ExtensionCatalog(project_dir)
result = catalog._load_catalog_config(project_dir / ".specify" / "nonexistent.yml")
assert result is None
def test_load_catalog_config_localhost_allowed(self, temp_dir):
"""Localhost HTTP URLs are allowed in config."""
project_dir = self._make_project(temp_dir)
self._write_catalog_config(
project_dir,
[
{
"name": "local",
"url": "http://localhost:8000/catalog.json",
"priority": 1,
"install_allowed": True,
}
],
)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
assert len(entries) == 1
assert entries[0].url == "http://localhost:8000/catalog.json"
# --- Merge conflict resolution ---
def test_merge_conflict_higher_priority_wins(self, temp_dir):
"""When same extension id is in two catalogs, higher priority wins."""
import yaml as yaml_module
project_dir = self._make_project(temp_dir)
# Write project config with two catalogs
self._write_catalog_config(
project_dir,
[
{
"name": "primary",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
},
{
"name": "secondary",
"url": ExtensionCatalog.COMMUNITY_CATALOG_URL,
"priority": 2,
"install_allowed": False,
},
],
)
catalog = ExtensionCatalog(project_dir)
# Write primary cache with jira v2.0.0
primary_data = {
"schema_version": "1.0",
"extensions": {
"jira": {
"name": "Jira Integration",
"id": "jira",
"version": "2.0.0",
"description": "Primary Jira",
}
},
}
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(primary_data))
catalog.cache_metadata_file.write_text(
json.dumps({"cached_at": datetime.now(timezone.utc).isoformat(), "catalog_url": "http://test.com"})
)
# Write secondary cache (URL-hash-based) with jira v1.0.0 (should lose)
import hashlib
url_hash = hashlib.sha256(ExtensionCatalog.COMMUNITY_CATALOG_URL.encode()).hexdigest()[:16]
secondary_cache = catalog.cache_dir / f"catalog-{url_hash}.json"
secondary_meta = catalog.cache_dir / f"catalog-{url_hash}-metadata.json"
secondary_data = {
"schema_version": "1.0",
"extensions": {
"jira": {
"name": "Jira Integration Community",
"id": "jira",
"version": "1.0.0",
"description": "Community Jira",
},
"linear": {
"name": "Linear",
"id": "linear",
"version": "0.9.0",
"description": "Linear from secondary",
},
},
}
secondary_cache.write_text(json.dumps(secondary_data))
secondary_meta.write_text(
json.dumps({"cached_at": datetime.now(timezone.utc).isoformat(), "catalog_url": ExtensionCatalog.COMMUNITY_CATALOG_URL})
)
results = catalog.search()
jira_results = [r for r in results if r["id"] == "jira"]
assert len(jira_results) == 1
# Primary catalog wins
assert jira_results[0]["version"] == "2.0.0"
assert jira_results[0]["_catalog_name"] == "primary"
assert jira_results[0]["_install_allowed"] is True
# linear comes from secondary
linear_results = [r for r in results if r["id"] == "linear"]
assert len(linear_results) == 1
assert linear_results[0]["_catalog_name"] == "secondary"
assert linear_results[0]["_install_allowed"] is False
def test_install_allowed_false_from_get_extension_info(self, temp_dir):
"""get_extension_info includes _install_allowed from source catalog."""
project_dir = self._make_project(temp_dir)
# Single catalog that is install_allowed=False
self._write_catalog_config(
project_dir,
[
{
"name": "discovery",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": False,
}
],
)
catalog = ExtensionCatalog(project_dir)
self._write_valid_cache(
catalog,
{
"jira": {
"name": "Jira Integration",
"id": "jira",
"version": "1.0.0",
"description": "Jira integration",
}
},
)
info = catalog.get_extension_info("jira")
assert info is not None
assert info["_install_allowed"] is False
assert info["_catalog_name"] == "discovery"
def test_search_results_include_catalog_metadata(self, temp_dir):
"""Search results include _catalog_name and _install_allowed."""
project_dir = self._make_project(temp_dir)
self._write_catalog_config(
project_dir,
[
{
"name": "org",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
],
)
catalog = ExtensionCatalog(project_dir)
self._write_valid_cache(
catalog,
{
"jira": {
"name": "Jira Integration",
"id": "jira",
"version": "1.0.0",
"description": "Jira integration",
}
},
)
results = catalog.search()
assert len(results) == 1
assert results[0]["_catalog_name"] == "org"
assert results[0]["_install_allowed"] is True