mirror of
https://github.com/github/spec-kit.git
synced 2026-03-18 19:33:09 +00:00
Compare commits
38 Commits
v0.0.93
...
019f23548c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
019f23548c | ||
|
|
7279f1d7f1 | ||
|
|
ece831e1a7 | ||
|
|
8a1328cfbd | ||
|
|
128bb0d790 | ||
|
|
9d0981a3ff | ||
|
|
b55d00beed | ||
|
|
525eae7f7e | ||
|
|
ce7bed4823 | ||
|
|
61b0637a6d | ||
|
|
56deda7be3 | ||
|
|
525cdc17ec | ||
|
|
607760e72f | ||
|
|
c7ecdfb998 | ||
|
|
f444ccba3a | ||
|
|
3040d33c31 | ||
|
|
6cc61025cb | ||
|
|
c1034f1d9d | ||
|
|
cee4f26fac | ||
|
|
6f523ede22 | ||
|
|
68d1d3a0fc | ||
|
|
07077d0fc2 | ||
|
|
aeed11f735 | ||
|
|
12405c01e1 | ||
|
|
fc3b98ea09 | ||
|
|
6150f1e317 | ||
|
|
6fca5d83b2 | ||
|
|
465acd9024 | ||
|
|
04fc3fd1ba | ||
|
|
24d76b5d92 | ||
|
|
0f7d04b12b | ||
|
|
9402ebd00a | ||
|
|
d410d188fc | ||
|
|
686c91f94e | ||
|
|
22036732d8 | ||
|
|
c78f8423f6 | ||
|
|
76cca34293 | ||
|
|
9a1e3037b0 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1,3 +1,3 @@
|
||||
# Global code owner
|
||||
* @localden
|
||||
* @mnriem
|
||||
|
||||
|
||||
141
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
Normal file
141
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
Normal 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
118
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
17
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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
|
||||
280
.github/ISSUE_TEMPLATE/extension_submission.yml
vendored
Normal file
280
.github/ISSUE_TEMPLATE/extension_submission.yml
vendored
Normal 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.
|
||||
104
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
104
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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.
|
||||
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
## Description
|
||||
|
||||
<!-- What does this PR do? Why is it needed? -->
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- How did you test your changes? -->
|
||||
|
||||
- [ ] Tested locally with `uv run specify --help`
|
||||
- [ ] Ran existing tests with `uv sync && uv run pytest`
|
||||
- [ ] Tested with a sample project (if applicable)
|
||||
|
||||
## AI Disclosure
|
||||
|
||||
<!-- Per our Contributing guidelines, AI assistance must be disclosed. -->
|
||||
<!-- See: https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md#ai-contributions-in-spec-kit -->
|
||||
|
||||
- [ ] I **did not** use AI assistance for this contribution
|
||||
- [ ] I **did** use AI assistance (describe below)
|
||||
|
||||
<!-- If you used AI, briefly describe how (e.g., "Code generated by Copilot", "Consulted ChatGPT for approach"): -->
|
||||
|
||||
11
.github/dependabot.yml
vendored
Normal file
11
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
32
.github/workflows/codeql.yml
vendored
Normal file
32
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'actions', 'python' ]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -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
|
||||
|
||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
paths:
|
||||
- 'memory/**'
|
||||
- 'scripts/**'
|
||||
- 'src/**'
|
||||
- 'templates/**'
|
||||
- '.github/workflows/**'
|
||||
workflow_dispatch:
|
||||
@@ -18,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 }}
|
||||
@@ -57,4 +58,12 @@ jobs:
|
||||
run: |
|
||||
chmod +x .github/workflows/scripts/update-version.sh
|
||||
.github/workflows/scripts/update-version.sh ${{ steps.get_tag.outputs.new_version }}
|
||||
- name: Commit version bump to main
|
||||
if: steps.check_release.outputs.exists == 'false'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add pyproject.toml
|
||||
git diff --cached --quiet || git commit -m "chore: bump version to ${{ steps.get_tag.outputs.new_version }} [skip ci]"
|
||||
git push
|
||||
|
||||
|
||||
@@ -40,15 +40,19 @@ gh release create "$VERSION" \
|
||||
.genreleases/spec-kit-template-roo-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-codebuddy-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-codebuddy-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-qoder-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-qoder-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-qodercli-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-qodercli-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-amp-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-amp-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-shai-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-shai-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-q-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-q-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-agy-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-agy-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-bob-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-bob-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-generic-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-generic-ps-"$VERSION".zip \
|
||||
--title "Spec Kit Templates - $VERSION_NO_V" \
|
||||
--notes-file release_notes.md
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
.PARAMETER Agents
|
||||
Comma or space separated subset of agents to build (default: all)
|
||||
Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, q, bob, qoder
|
||||
Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, q, bob, qodercli, shai, agy, generic
|
||||
|
||||
.PARAMETER Scripts
|
||||
Comma or space separated subset of script types to build (default: both)
|
||||
@@ -343,9 +343,13 @@ function Build-Variant {
|
||||
$cmdDir = Join-Path $baseDir ".bob/commands"
|
||||
Generate-Commands -Agent 'bob' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
}
|
||||
'qoder' {
|
||||
'qodercli' {
|
||||
$cmdDir = Join-Path $baseDir ".qoder/commands"
|
||||
Generate-Commands -Agent 'qoder' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
Generate-Commands -Agent 'qodercli' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
}
|
||||
'generic' {
|
||||
$cmdDir = Join-Path $baseDir ".speckit/commands"
|
||||
Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +360,7 @@ function Build-Variant {
|
||||
}
|
||||
|
||||
# Define all agents and scripts
|
||||
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'q', 'bob', 'qoder')
|
||||
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'q', 'bob', 'qodercli', 'shai', 'agy', 'generic')
|
||||
$AllScripts = @('sh', 'ps')
|
||||
|
||||
function Normalize-List {
|
||||
|
||||
@@ -6,7 +6,7 @@ set -euo pipefail
|
||||
# Usage: .github/workflows/scripts/create-release-packages.sh <version>
|
||||
# Version argument should include leading 'v'.
|
||||
# Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built.
|
||||
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex amp shai bob (default: all)
|
||||
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex amp shai bob generic (default: all)
|
||||
# SCRIPTS : space or comma separated subset of: sh ps (default: both)
|
||||
# Examples:
|
||||
# AGENTS=claude SCRIPTS=sh $0 v0.2.0
|
||||
@@ -203,9 +203,9 @@ build_variant() {
|
||||
codebuddy)
|
||||
mkdir -p "$base_dir/.codebuddy/commands"
|
||||
generate_commands codebuddy md "\$ARGUMENTS" "$base_dir/.codebuddy/commands" "$script" ;;
|
||||
qoder)
|
||||
qodercli)
|
||||
mkdir -p "$base_dir/.qoder/commands"
|
||||
generate_commands qoder md "\$ARGUMENTS" "$base_dir/.qoder/commands" "$script" ;;
|
||||
generate_commands qodercli md "\$ARGUMENTS" "$base_dir/.qoder/commands" "$script" ;;
|
||||
amp)
|
||||
mkdir -p "$base_dir/.agents/commands"
|
||||
generate_commands amp md "\$ARGUMENTS" "$base_dir/.agents/commands" "$script" ;;
|
||||
@@ -215,16 +215,22 @@ build_variant() {
|
||||
q)
|
||||
mkdir -p "$base_dir/.amazonq/prompts"
|
||||
generate_commands q md "\$ARGUMENTS" "$base_dir/.amazonq/prompts" "$script" ;;
|
||||
agy)
|
||||
mkdir -p "$base_dir/.agent/workflows"
|
||||
generate_commands agy md "\$ARGUMENTS" "$base_dir/.agent/workflows" "$script" ;;
|
||||
bob)
|
||||
mkdir -p "$base_dir/.bob/commands"
|
||||
generate_commands bob md "\$ARGUMENTS" "$base_dir/.bob/commands" "$script" ;;
|
||||
generic)
|
||||
mkdir -p "$base_dir/.speckit/commands"
|
||||
generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;;
|
||||
esac
|
||||
( cd "$base_dir" && zip -r "../spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip" . )
|
||||
echo "Created $GENRELEASES_DIR/spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip"
|
||||
}
|
||||
|
||||
# Determine agent list
|
||||
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai q bob qoder)
|
||||
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai q agy bob qodercli generic)
|
||||
ALL_SCRIPTS=(sh ps)
|
||||
|
||||
norm_list() {
|
||||
|
||||
42
.github/workflows/stale.yml
vendored
Normal file
42
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: 'Close stale issues and PRs'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Run daily at midnight UTC
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
# Days of inactivity before an issue or PR becomes stale
|
||||
days-before-stale: 150
|
||||
# Days of inactivity before a stale issue or PR is closed (after being marked stale)
|
||||
days-before-close: 30
|
||||
|
||||
# Stale issue settings
|
||||
stale-issue-message: 'This issue has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.'
|
||||
close-issue-message: 'This issue has been automatically closed due to inactivity (180 days total). If you believe this issue is still relevant, please reopen it or create a new issue.'
|
||||
stale-issue-label: 'stale'
|
||||
|
||||
# Stale PR settings
|
||||
stale-pr-message: 'This pull request has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.'
|
||||
close-pr-message: 'This pull request has been automatically closed due to inactivity (180 days total). If you believe this PR is still relevant, please reopen it or create a new PR.'
|
||||
stale-pr-label: 'stale'
|
||||
|
||||
# Exempt issues and PRs with these labels from being marked as stale
|
||||
exempt-issue-labels: 'pinned,security'
|
||||
exempt-pr-labels: 'pinned,security'
|
||||
|
||||
# Only issues or PRs with all of these labels are checked
|
||||
# Leave empty to check all issues and PRs
|
||||
any-of-labels: ''
|
||||
|
||||
# Operations per run (helps avoid rate limits)
|
||||
operations-per-run: 100
|
||||
50
.github/workflows/test.yml
vendored
Normal file
50
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Test & Lint Python
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
ruff:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Run ruff check
|
||||
run: uvx ruff check src/
|
||||
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra test
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -44,10 +44,9 @@ env/
|
||||
.genreleases/
|
||||
*.zip
|
||||
sdd-*/
|
||||
|
||||
docs/dev
|
||||
|
||||
# Extension system
|
||||
.specify/extensions/.cache/
|
||||
.specify/extensions/.backup/
|
||||
.specify/extensions/*/local-config.yml
|
||||
.specify/extensions/*/local-config.yml
|
||||
|
||||
10
AGENTS.md
10
AGENTS.md
@@ -43,11 +43,12 @@ Specify supports multiple AI agents by generating agent-specific command files a
|
||||
| **Auggie CLI** | `.augment/rules/` | Markdown | `auggie` | Auggie CLI |
|
||||
| **Roo Code** | `.roo/rules/` | Markdown | N/A (IDE-based) | Roo Code IDE |
|
||||
| **CodeBuddy CLI** | `.codebuddy/commands/` | Markdown | `codebuddy` | CodeBuddy CLI |
|
||||
| **Qoder CLI** | `.qoder/commands/` | Markdown | `qoder` | Qoder CLI |
|
||||
| **Qoder CLI** | `.qoder/commands/` | Markdown | `qodercli` | Qoder CLI |
|
||||
| **Amazon Q Developer CLI** | `.amazonq/prompts/` | Markdown | `q` | Amazon Q Developer CLI |
|
||||
| **Amp** | `.agents/commands/` | Markdown | `amp` | Amp CLI |
|
||||
| **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI |
|
||||
| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE |
|
||||
| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent |
|
||||
|
||||
### Step-by-Step Integration Guide
|
||||
|
||||
@@ -65,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
|
||||
},
|
||||
@@ -82,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
|
||||
|
||||
@@ -313,7 +319,7 @@ Require a command-line tool to be installed:
|
||||
- **opencode**: `opencode` CLI
|
||||
- **Amazon Q Developer CLI**: `q` CLI
|
||||
- **CodeBuddy CLI**: `codebuddy` CLI
|
||||
- **Qoder CLI**: `qoder` CLI
|
||||
- **Qoder CLI**: `qodercli` CLI
|
||||
- **Amp**: `amp` CLI
|
||||
- **SHAI**: `shai` CLI
|
||||
|
||||
|
||||
356
CHANGELOG.md
356
CHANGELOG.md
@@ -2,296 +2,100 @@
|
||||
|
||||
<!-- 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.0] - 2026-01-28
|
||||
## [0.1.7] - 2026-02-27
|
||||
|
||||
### Added
|
||||
|
||||
- **Extension System**: Introduced modular extension architecture for Spec Kit
|
||||
- Extensions are self-contained packages that add commands and functionality without bloating core
|
||||
- Extension manifest schema (`extension.yml`) with validation
|
||||
- Extension registry (`.specify/extensions/.registry`) for tracking installed extensions
|
||||
- Extension manager module (`src/specify_cli/extensions.py`) for installation/removal
|
||||
- New CLI commands:
|
||||
- `specify extension list` - List installed extensions
|
||||
- `specify extension add` - Install extension from local directory or URL
|
||||
- `specify extension remove` - Uninstall extension
|
||||
- `specify extension search` - Search extension catalog
|
||||
- `specify extension info` - Show detailed extension information
|
||||
- Semantic versioning compatibility checks
|
||||
- Support for extension configuration files
|
||||
- Command registration system for AI agents (Claude support initially)
|
||||
- Added dependencies: `pyyaml>=6.0`, `packaging>=23.0`
|
||||
- **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
|
||||
|
||||
- **Extension Catalog**: Extension discovery and distribution system
|
||||
- Central catalog (`extensions/catalog.json`) for published extensions
|
||||
- Extension catalog manager (`ExtensionCatalog` class) with:
|
||||
- Catalog fetching from GitHub
|
||||
- 1-hour local caching for performance
|
||||
- Search by query, tag, author, or verification status
|
||||
- Extension info retrieval
|
||||
- Catalog cache stored in `.specify/extensions/.cache/`
|
||||
- Search and info commands with rich console output
|
||||
- Added 9 catalog-specific unit tests (100% pass rate)
|
||||
|
||||
- **Jira Extension**: First official extension for Jira integration
|
||||
- Extension ID: `jira`
|
||||
- Version: 1.0.0
|
||||
- Commands:
|
||||
- `/speckit.jira.specstoissues` - Create Jira hierarchy from spec and tasks
|
||||
- `/speckit.jira.discover-fields` - Discover Jira custom fields
|
||||
- `/speckit.jira.sync-status` - Sync task completion status
|
||||
- Comprehensive documentation (README, usage guide, examples)
|
||||
- MIT licensed
|
||||
|
||||
- **Hook System**: Extension lifecycle hooks for automation
|
||||
- `HookExecutor` class for managing extension hooks
|
||||
- Hooks registered in `.specify/extensions.yml`
|
||||
- Hook registration during extension installation
|
||||
- Hook unregistration during extension removal
|
||||
- Support for optional and mandatory hooks
|
||||
- Hook execution messages for AI agent integration
|
||||
- Condition support for conditional hook execution (placeholder)
|
||||
|
||||
- **Extension Management**: Advanced extension management commands
|
||||
- `specify extension update` - Check and update extensions to latest version
|
||||
- `specify extension enable` - Enable a disabled extension
|
||||
- `specify extension disable` - Disable extension without removing it
|
||||
- Version comparison with catalog
|
||||
- Update notifications
|
||||
- Preserve configuration during updates
|
||||
|
||||
- **Multi-Agent Support**: Extensions now work with all supported AI agents (Phase 6)
|
||||
- Automatic detection and registration for all agents in project
|
||||
- Support for 16+ AI agents (Claude, Gemini, Copilot, Cursor, Qwen, and more)
|
||||
- Agent-specific command formats (Markdown and TOML)
|
||||
- Automatic argument placeholder conversion ($ARGUMENTS → {{args}})
|
||||
- Commands registered for all detected agents during installation
|
||||
- Multi-agent command unregistration on extension removal
|
||||
- `CommandRegistrar.register_commands_for_agent()` method
|
||||
- `CommandRegistrar.register_commands_for_all_agents()` method
|
||||
|
||||
- **Configuration Layers**: Full configuration cascade system (Phase 6)
|
||||
- **Layer 1**: Defaults from extension manifest (`extension.yml`)
|
||||
- **Layer 2**: Project config (`.specify/extensions/{ext-id}/{ext-id}-config.yml`)
|
||||
- **Layer 3**: Local config (`.specify/extensions/{ext-id}/local-config.yml`, gitignored)
|
||||
- **Layer 4**: Environment variables (`SPECKIT_{EXT_ID}_{KEY}` pattern)
|
||||
- Recursive config merging with proper precedence
|
||||
- `ConfigManager` class for programmatic config access
|
||||
- `get_config()`, `get_value()`, `has_value()` methods
|
||||
- Support for nested configuration paths with dot-notation
|
||||
|
||||
- **Hook Condition Evaluation**: Smart hook execution based on runtime conditions (Phase 6)
|
||||
- Config conditions: `config.key.path is set`, `config.key == 'value'`, `config.key != 'value'`
|
||||
- Environment conditions: `env.VAR is set`, `env.VAR == 'value'`, `env.VAR != 'value'`
|
||||
- Automatic filtering of hooks based on condition evaluation
|
||||
- Safe fallback behavior on evaluation errors
|
||||
- Case-insensitive pattern matching
|
||||
|
||||
- **Hook Integration**: Agent-level hook checking and execution (Phase 6)
|
||||
- `check_hooks_for_event()` method for AI agents to query hooks after core commands
|
||||
- Condition-aware hook filtering before execution
|
||||
- `enable_hooks()` and `disable_hooks()` methods per extension
|
||||
- Formatted hook messages for agent display
|
||||
- `execute_hook()` method for hook execution information
|
||||
|
||||
- **Documentation Suite**: Comprehensive documentation for users and developers
|
||||
- **EXTENSION-USER-GUIDE.md**: Complete user guide with installation, usage, configuration, and troubleshooting
|
||||
- **EXTENSION-API-REFERENCE.md**: Technical API reference with manifest schema, Python API, and CLI commands
|
||||
- **EXTENSION-PUBLISHING-GUIDE.md**: Publishing guide for extension authors
|
||||
- **RFC-EXTENSION-SYSTEM.md**: Extension architecture design document
|
||||
|
||||
- **Extension Template**: Starter template in `extensions/template/` for creating new extensions
|
||||
- Fully commented `extension.yml` manifest template
|
||||
- Example command file with detailed explanations
|
||||
- Configuration template with all options
|
||||
- Complete project structure (README, LICENSE, CHANGELOG, .gitignore)
|
||||
- EXAMPLE-README.md showing final documentation format
|
||||
|
||||
- **Unit Tests**: Comprehensive test suite with 39 tests covering all extension system components
|
||||
- Test coverage: 83% of extension module code
|
||||
- Test dependencies: `pytest>=7.0`, `pytest-cov>=4.0`
|
||||
- Configured pytest in `pyproject.toml`
|
||||
|
||||
### Changed
|
||||
|
||||
- Version bumped to 0.1.0 (minor release for new feature)
|
||||
|
||||
## [0.0.22] - 2025-11-07
|
||||
|
||||
- Support for VS Code/Copilot agents, and moving away from prompts to proper agents with hand-offs.
|
||||
- Move to use `AGENTS.md` for Copilot workloads, since it's already supported out-of-the-box.
|
||||
- Adds support for the version command. ([#486](https://github.com/github/spec-kit/issues/486))
|
||||
- Fixes potential bug with the `create-new-feature.ps1` script that ignores existing feature branches when determining next feature number ([#975](https://github.com/github/spec-kit/issues/975))
|
||||
- Add graceful fallback and logging for GitHub API rate-limiting during template fetch ([#970](https://github.com/github/spec-kit/issues/970))
|
||||
|
||||
## [0.0.21] - 2025-10-21
|
||||
|
||||
- Fixes [#975](https://github.com/github/spec-kit/issues/975) (thank you [@fgalarraga](https://github.com/fgalarraga)).
|
||||
- Adds support for Amp CLI.
|
||||
- Adds support for VS Code hand-offs and moves prompts to be full-fledged chat modes.
|
||||
- Adds support for `version` command (addresses [#811](https://github.com/github/spec-kit/issues/811) and [#486](https://github.com/github/spec-kit/issues/486), thank you [@mcasalaina](https://github.com/mcasalaina) and [@dentity007](https://github.com/dentity007)).
|
||||
- Adds support for rendering the rate limit errors from the CLI when encountered ([#970](https://github.com/github/spec-kit/issues/970), thank you [@psmman](https://github.com/psmman)).
|
||||
|
||||
## [0.0.20] - 2025-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Intelligent Branch Naming**: `create-new-feature` scripts now support `--short-name` parameter for custom branch names
|
||||
- When `--short-name` provided: Uses the custom name directly (cleaned and formatted)
|
||||
- When omitted: Automatically generates meaningful names using stop word filtering and length-based filtering
|
||||
- Filters out common stop words (I, want, to, the, for, etc.)
|
||||
- Removes words shorter than 3 characters (unless they're uppercase acronyms)
|
||||
- Takes 3-4 most meaningful words from the description
|
||||
- **Enforces GitHub's 244-byte branch name limit** with automatic truncation and warnings
|
||||
- Examples:
|
||||
- "I want to create user authentication" → `001-create-user-authentication`
|
||||
- "Implement OAuth2 integration for API" → `001-implement-oauth2-integration-api`
|
||||
- "Fix payment processing bug" → `001-fix-payment-processing`
|
||||
- Very long descriptions are automatically truncated at word boundaries to stay within limits
|
||||
- Designed for AI agents to provide semantic short names while maintaining standalone usability
|
||||
|
||||
### Changed
|
||||
|
||||
- Enhanced help documentation for `create-new-feature.sh` and `create-new-feature.ps1` scripts with examples
|
||||
- Branch names now validated against GitHub's 244-byte limit with automatic truncation if needed
|
||||
|
||||
## [0.0.19] - 2025-10-10
|
||||
|
||||
### Added
|
||||
|
||||
- Support for CodeBuddy (thank you to [@lispking](https://github.com/lispking) for the contribution).
|
||||
- You can now see Git-sourced errors in the Specify CLI.
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed the path to the constitution in `plan.md` (thank you to [@lyzno1](https://github.com/lyzno1) for spotting).
|
||||
- Fixed backslash escapes in generated TOML files for Gemini (thank you to [@hsin19](https://github.com/hsin19) for the contribution).
|
||||
- Implementation command now ensures that the correct ignore files are added (thank you to [@sigent-amazon](https://github.com/sigent-amazon) for the contribution).
|
||||
|
||||
## [0.0.18] - 2025-10-06
|
||||
|
||||
### Added
|
||||
|
||||
- Support for using `.` as a shorthand for current directory in `specify init .` command, equivalent to `--here` flag but more intuitive for users.
|
||||
- Use the `/speckit.` command prefix to easily discover Spec Kit-related commands.
|
||||
- Refactor the prompts and templates to simplify their capabilities and how they are tracked. No more polluting things with tests when they are not needed.
|
||||
- Ensure that tasks are created per user story (simplifies testing and validation).
|
||||
- Add support for Visual Studio Code prompt shortcuts and automatic script execution.
|
||||
|
||||
### Changed
|
||||
|
||||
- All command files now prefixed with `speckit.` (e.g., `speckit.specify.md`, `speckit.plan.md`) for better discoverability and differentiation in IDE/CLI command palettes and file explorers
|
||||
|
||||
## [0.0.17] - 2025-09-22
|
||||
|
||||
### Added
|
||||
|
||||
- New `/clarify` command template to surface up to 5 targeted clarification questions for an existing spec and persist answers into a Clarifications section in the spec.
|
||||
- New `/analyze` command template providing a non-destructive cross-artifact discrepancy and alignment report (spec, clarifications, plan, tasks, constitution) inserted after `/tasks` and before `/implement`.
|
||||
- Note: Constitution rules are explicitly treated as non-negotiable; any conflict is a CRITICAL finding requiring artifact remediation, not weakening of principles.
|
||||
|
||||
## [0.0.16] - 2025-09-22
|
||||
|
||||
### Added
|
||||
|
||||
- `--force` flag for `init` command to bypass confirmation when using `--here` in a non-empty directory and proceed with merging/overwriting files.
|
||||
|
||||
## [0.0.15] - 2025-09-21
|
||||
|
||||
### Added
|
||||
|
||||
- Support for Roo Code.
|
||||
|
||||
## [0.0.14] - 2025-09-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Error messages are now shown consistently.
|
||||
|
||||
## [0.0.13] - 2025-09-21
|
||||
|
||||
### Added
|
||||
|
||||
- Support for Kilo Code. Thank you [@shahrukhkhan489](https://github.com/shahrukhkhan489) with [#394](https://github.com/github/spec-kit/pull/394).
|
||||
- Support for Auggie CLI. Thank you [@hungthai1401](https://github.com/hungthai1401) with [#137](https://github.com/github/spec-kit/pull/137).
|
||||
- Agent folder security notice displayed after project provisioning completion, warning users that some agents may store credentials or auth tokens in their agent folders and recommending adding relevant folders to `.gitignore` to prevent accidental credential leakage.
|
||||
|
||||
### Changed
|
||||
|
||||
- Warning displayed to ensure that folks are aware that they might need to add their agent folder to `.gitignore`.
|
||||
- Cleaned up the `check` command output.
|
||||
|
||||
## [0.0.12] - 2025-09-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Added additional context for OpenAI Codex users - they need to set an additional environment variable, as described in [#417](https://github.com/github/spec-kit/issues/417).
|
||||
|
||||
## [0.0.11] - 2025-09-20
|
||||
|
||||
### Added
|
||||
|
||||
- Codex CLI support (thank you [@honjo-hiroaki-gtt](https://github.com/honjo-hiroaki-gtt) for the contribution in [#14](https://github.com/github/spec-kit/pull/14))
|
||||
- Codex-aware context update tooling (Bash and PowerShell) so feature plans refresh `AGENTS.md` alongside existing assistants without manual edits.
|
||||
|
||||
## [0.0.10] - 2025-09-20
|
||||
## [0.1.6] - 2026-02-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- Addressed [#378](https://github.com/github/spec-kit/issues/378) where a GitHub token may be attached to the request when it was empty.
|
||||
- **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.0.9] - 2025-09-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved agent selector UI with cyan highlighting for agent keys and gray parentheses for full names
|
||||
|
||||
## [0.0.8] - 2025-09-19
|
||||
|
||||
### Added
|
||||
|
||||
- Windsurf IDE support as additional AI assistant option (thank you [@raedkit](https://github.com/raedkit) for the work in [#151](https://github.com/github/spec-kit/pull/151))
|
||||
- GitHub token support for API requests to handle corporate environments and rate limiting (contributed by [@zryfish](https://github.com/@zryfish) in [#243](https://github.com/github/spec-kit/pull/243))
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated README with Windsurf examples and GitHub token usage
|
||||
- Enhanced release workflow to include Windsurf templates
|
||||
|
||||
## [0.0.7] - 2025-09-18
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated command instructions in the CLI.
|
||||
- Cleaned up the code to not render agent-specific information when it's generic.
|
||||
|
||||
## [0.0.6] - 2025-09-17
|
||||
|
||||
### Added
|
||||
|
||||
- opencode support as additional AI assistant option
|
||||
|
||||
## [0.0.5] - 2025-09-17
|
||||
|
||||
### Added
|
||||
|
||||
- Qwen Code support as additional AI assistant option
|
||||
|
||||
## [0.0.4] - 2025-09-14
|
||||
|
||||
### Added
|
||||
|
||||
- SOCKS proxy support for corporate environments via `httpx[socks]` dependency
|
||||
## [0.1.5] - 2026-02-21
|
||||
|
||||
### Fixed
|
||||
|
||||
N/A
|
||||
- **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
|
||||
|
||||
### Changed
|
||||
## [0.1.4] - 2026-02-20
|
||||
|
||||
N/A
|
||||
### 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] - 2026-02-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Generic Agent Support**: Added `--ai generic` option for unsupported AI agents ("bring your own agent")
|
||||
- Requires `--ai-commands-dir <path>` to specify where the agent reads commands from
|
||||
- Generates Markdown commands with `$ARGUMENTS` format (compatible with most agents)
|
||||
- Example: `specify init my-project --ai generic --ai-commands-dir .myagent/commands/`
|
||||
- Enables users to start with Spec Kit immediately while their agent awaits formal support
|
||||
|
||||
## [0.0.102] - 2026-02-20
|
||||
|
||||
- fix: include 'src/**' path in release workflow triggers (#1646)
|
||||
|
||||
## [0.0.101] - 2026-02-19
|
||||
|
||||
- chore(deps): bump github/codeql-action from 3 to 4 (#1635)
|
||||
|
||||
## [0.0.100] - 2026-02-19
|
||||
|
||||
- Add pytest and Python linting (ruff) to CI (#1637)
|
||||
- feat: add pull request template for better contribution guidelines (#1634)
|
||||
|
||||
## [0.0.99] - 2026-02-19
|
||||
|
||||
- Feat/ai skills (#1632)
|
||||
|
||||
## [0.0.98] - 2026-02-19
|
||||
|
||||
- chore(deps): bump actions/stale from 9 to 10 (#1623)
|
||||
- feat: add dependabot configuration for pip and GitHub Actions updates (#1622)
|
||||
|
||||
## [0.0.97] - 2026-02-18
|
||||
|
||||
- Remove Maintainers section from README.md (#1618)
|
||||
|
||||
## [0.0.96] - 2026-02-17
|
||||
|
||||
- fix: typo in plan-template.md (#1446)
|
||||
|
||||
## [0.0.95] - 2026-02-12
|
||||
|
||||
- Feat: add a new agent: Google Anti Gravity (#1220)
|
||||
|
||||
## [0.0.94] - 2026-02-11
|
||||
|
||||
- Add stale workflow for 180-day inactive issues and PRs (#1594)
|
||||
|
||||
25
README.md
25
README.md
@@ -31,7 +31,6 @@
|
||||
- [📖 Learn More](#-learn-more)
|
||||
- [📋 Detailed Process](#-detailed-process)
|
||||
- [🔍 Troubleshooting](#-troubleshooting)
|
||||
- [👥 Maintainers](#-maintainers)
|
||||
- [💬 Support](#-support)
|
||||
- [🙏 Acknowledgements](#-acknowledgements)
|
||||
- [📄 License](#-license)
|
||||
@@ -162,6 +161,8 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c
|
||||
| [Roo Code](https://roocode.com/) | ✅ | |
|
||||
| [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | |
|
||||
| [Windsurf](https://windsurf.com/) | ✅ | |
|
||||
| [Antigravity (agy)](https://agy.ai/) | ✅ | |
|
||||
| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir <path>` for unsupported agents |
|
||||
|
||||
## 🔧 Specify CLI Reference
|
||||
|
||||
@@ -172,14 +173,15 @@ The `specify` command supports the following options:
|
||||
| Command | Description |
|
||||
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `init` | Initialize a new Specify project from the latest template |
|
||||
| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `shai`, `qoder`) |
|
||||
| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `shai`, `qodercli`) |
|
||||
|
||||
### `specify init` Arguments & Options
|
||||
|
||||
| Argument/Option | Type | Description |
|
||||
| ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `<project-name>` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) |
|
||||
| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `q`, `bob`, or `qoder` |
|
||||
| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `q`, `agy`, `bob`, `qodercli`, or `generic` (requires `--ai-commands-dir`) |
|
||||
| `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) |
|
||||
| `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code |
|
||||
| `--no-git` | Flag | Skip git repository initialization |
|
||||
@@ -188,6 +190,7 @@ The `specify` command supports the following options:
|
||||
| `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) |
|
||||
| `--debug` | Flag | Enable detailed debug output for troubleshooting |
|
||||
| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) |
|
||||
| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`) |
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -202,7 +205,7 @@ specify init my-project --ai claude
|
||||
specify init my-project --ai cursor-agent
|
||||
|
||||
# Initialize with Qoder support
|
||||
specify init my-project --ai qoder
|
||||
specify init my-project --ai qodercli
|
||||
|
||||
# Initialize with Windsurf support
|
||||
specify init my-project --ai windsurf
|
||||
@@ -216,6 +219,9 @@ specify init my-project --ai shai
|
||||
# Initialize with IBM Bob support
|
||||
specify init my-project --ai bob
|
||||
|
||||
# Initialize with an unsupported agent (generic / bring your own agent)
|
||||
specify init my-project --ai generic --ai-commands-dir .myagent/commands/
|
||||
|
||||
# Initialize with PowerShell scripts (Windows/cross-platform)
|
||||
specify init my-project --ai copilot --script ps
|
||||
|
||||
@@ -238,6 +244,12 @@ specify init my-project --ai claude --debug
|
||||
# Use GitHub token for API requests (helpful for corporate environments)
|
||||
specify init my-project --ai claude --github-token ghp_your_token_here
|
||||
|
||||
# Install agent skills with the project
|
||||
specify init my-project --ai claude --ai-skills
|
||||
|
||||
# Initialize in current directory with agent skills
|
||||
specify init --here --ai gemini --ai-skills
|
||||
|
||||
# Check system requirements
|
||||
specify check
|
||||
```
|
||||
@@ -636,11 +648,6 @@ echo "Cleaning up..."
|
||||
rm gcm-linux_amd64.2.6.1.deb
|
||||
```
|
||||
|
||||
## 👥 Maintainers
|
||||
|
||||
- Den Delimarsky ([@localden](https://github.com/localden))
|
||||
- John Lam ([@jflam](https://github.com/jflam))
|
||||
|
||||
## 💬 Support
|
||||
|
||||
For support, please open a [GitHub issue](https://github.com/github/spec-kit/issues/new). We welcome bug reports, feature requests, and questions about using Spec-Driven Development.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
119
extensions/README.md
Normal 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).
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
85
extensions/catalog.community.json
Normal file
85
extensions/catalog.community.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.1.0"
|
||||
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 = [
|
||||
"typer",
|
||||
"click>=8.1",
|
||||
"rich",
|
||||
"httpx[socks]",
|
||||
"platformdirs",
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
#
|
||||
# 5. Multi-Agent Support
|
||||
# - Handles agent-specific file paths and naming conventions
|
||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, or Amazon Q Developer CLI
|
||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Amazon Q Developer CLI, or Antigravity
|
||||
# - Can update single agents or all existing agent files
|
||||
# - Creates default Claude file if no agent files exist
|
||||
#
|
||||
# Usage: ./update-agent-context.sh [agent_type]
|
||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|shai|q|bob|qoder
|
||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli
|
||||
# Leave empty to update all existing agent files
|
||||
|
||||
set -e
|
||||
@@ -74,6 +74,7 @@ QODER_FILE="$REPO_ROOT/QODER.md"
|
||||
AMP_FILE="$REPO_ROOT/AGENTS.md"
|
||||
SHAI_FILE="$REPO_ROOT/SHAI.md"
|
||||
Q_FILE="$REPO_ROOT/AGENTS.md"
|
||||
AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md"
|
||||
BOB_FILE="$REPO_ROOT/AGENTS.md"
|
||||
|
||||
# Template file
|
||||
@@ -350,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
|
||||
}
|
||||
|
||||
@@ -491,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
|
||||
}
|
||||
#==============================================================================
|
||||
@@ -618,7 +639,7 @@ update_specific_agent() {
|
||||
codebuddy)
|
||||
update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI"
|
||||
;;
|
||||
qoder)
|
||||
qodercli)
|
||||
update_agent_file "$QODER_FILE" "Qoder CLI"
|
||||
;;
|
||||
amp)
|
||||
@@ -630,12 +651,18 @@ update_specific_agent() {
|
||||
q)
|
||||
update_agent_file "$Q_FILE" "Amazon Q Developer CLI"
|
||||
;;
|
||||
agy)
|
||||
update_agent_file "$AGY_FILE" "Antigravity"
|
||||
;;
|
||||
bob)
|
||||
update_agent_file "$BOB_FILE" "IBM Bob"
|
||||
;;
|
||||
generic)
|
||||
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown agent type '$agent_type'"
|
||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|amp|shai|q|bob|qoder"
|
||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli|generic"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -714,7 +741,11 @@ update_all_existing_agents() {
|
||||
update_agent_file "$Q_FILE" "Amazon Q Developer CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
|
||||
if [[ -f "$AGY_FILE" ]]; then
|
||||
update_agent_file "$AGY_FILE" "Antigravity"
|
||||
found_agent=true
|
||||
fi
|
||||
if [[ -f "$BOB_FILE" ]]; then
|
||||
update_agent_file "$BOB_FILE" "IBM Bob"
|
||||
found_agent=true
|
||||
@@ -744,7 +775,7 @@ print_summary() {
|
||||
|
||||
echo
|
||||
|
||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|codebuddy|shai|q|bob|qoder]"
|
||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli]"
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
|
||||
@@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh:
|
||||
2. Plan Data Extraction
|
||||
3. Agent File Management (create from template or update existing)
|
||||
4. Content Generation (technology stack, recent changes, timestamp)
|
||||
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, q, bob, qoder)
|
||||
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, q, agy, bob, qodercli)
|
||||
|
||||
.PARAMETER AgentType
|
||||
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
|
||||
@@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1
|
||||
#>
|
||||
param(
|
||||
[Parameter(Position=0)]
|
||||
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','q','bob','qoder')]
|
||||
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','q','agy','bob','qodercli','generic')]
|
||||
[string]$AgentType
|
||||
)
|
||||
|
||||
@@ -59,6 +59,7 @@ $QODER_FILE = Join-Path $REPO_ROOT 'QODER.md'
|
||||
$AMP_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||
$SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md'
|
||||
$Q_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||
$AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md'
|
||||
$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||
|
||||
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'
|
||||
@@ -257,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
|
||||
@@ -333,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
|
||||
}
|
||||
@@ -383,12 +396,14 @@ function Update-SpecificAgent {
|
||||
'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' }
|
||||
'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' }
|
||||
'codebuddy' { Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI' }
|
||||
'qoder' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' }
|
||||
'qodercli' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' }
|
||||
'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' }
|
||||
'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' }
|
||||
'q' { Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI' }
|
||||
'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' }
|
||||
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
|
||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|bob|qoder'; return $false }
|
||||
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
|
||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli|generic'; return $false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,6 +424,7 @@ function Update-AllExistingAgents {
|
||||
if (Test-Path $QODER_FILE) { if (-not (Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $SHAI_FILE) { if (-not (Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $Q_FILE) { if (-not (Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $AGY_FILE) { if (-not (Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true }
|
||||
if (-not $found) {
|
||||
Write-Info 'No existing agent files found, creating default Claude file...'
|
||||
@@ -424,7 +440,7 @@ function Print-Summary {
|
||||
if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" }
|
||||
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" }
|
||||
Write-Host ''
|
||||
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|bob|qoder]'
|
||||
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli|generic]'
|
||||
}
|
||||
|
||||
function Main {
|
||||
|
||||
@@ -32,6 +32,7 @@ import tempfile
|
||||
import shutil
|
||||
import shlex
|
||||
import json
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
@@ -122,110 +123,141 @@ 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,
|
||||
},
|
||||
"qoder": {
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
||||
SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
|
||||
@@ -663,7 +695,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
|
||||
except ValueError as je:
|
||||
raise RuntimeError(f"Failed to parse release JSON: {je}\nRaw (truncated 400): {response.text[:400]}")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error fetching release information[/red]")
|
||||
console.print("[red]Error fetching release information[/red]")
|
||||
console.print(Panel(str(e), title="Fetch Error", border_style="red"))
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -693,7 +725,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
|
||||
|
||||
zip_path = download_dir / filename
|
||||
if verbose:
|
||||
console.print(f"[cyan]Downloading template...[/cyan]")
|
||||
console.print("[cyan]Downloading template...[/cyan]")
|
||||
|
||||
try:
|
||||
with client.stream(
|
||||
@@ -732,7 +764,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
|
||||
for chunk in response.iter_bytes(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error downloading template[/red]")
|
||||
console.print("[red]Error downloading template[/red]")
|
||||
detail = str(e)
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
@@ -816,7 +848,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
|
||||
tracker.add("flatten", "Flatten nested directory")
|
||||
tracker.complete("flatten")
|
||||
elif verbose:
|
||||
console.print(f"[cyan]Found nested directory structure[/cyan]")
|
||||
console.print("[cyan]Found nested directory structure[/cyan]")
|
||||
|
||||
for item in source_dir.iterdir():
|
||||
dest_path = project_path / item.name
|
||||
@@ -841,7 +873,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
|
||||
console.print(f"[yellow]Overwriting file:[/yellow] {item.name}")
|
||||
shutil.copy2(item, dest_path)
|
||||
if verbose and not tracker:
|
||||
console.print(f"[cyan]Template files merged into current directory[/cyan]")
|
||||
console.print("[cyan]Template files merged into current directory[/cyan]")
|
||||
else:
|
||||
zip_ref.extractall(project_path)
|
||||
|
||||
@@ -867,7 +899,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
|
||||
tracker.add("flatten", "Flatten nested directory")
|
||||
tracker.complete("flatten")
|
||||
elif verbose:
|
||||
console.print(f"[cyan]Flattened nested directory structure[/cyan]")
|
||||
console.print("[cyan]Flattened nested directory structure[/cyan]")
|
||||
|
||||
except Exception as e:
|
||||
if tracker:
|
||||
@@ -917,13 +949,17 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
st = script.stat(); mode = st.st_mode
|
||||
st = script.stat()
|
||||
mode = st.st_mode
|
||||
if mode & 0o111:
|
||||
continue
|
||||
new_mode = mode
|
||||
if mode & 0o400: new_mode |= 0o100
|
||||
if mode & 0o040: new_mode |= 0o010
|
||||
if mode & 0o004: new_mode |= 0o001
|
||||
if mode & 0o400:
|
||||
new_mode |= 0o100
|
||||
if mode & 0o040:
|
||||
new_mode |= 0o010
|
||||
if mode & 0o004:
|
||||
new_mode |= 0o001
|
||||
if not (new_mode & 0o100):
|
||||
new_mode |= 0o100
|
||||
os.chmod(script, new_mode)
|
||||
@@ -969,7 +1005,7 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker |
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.complete("constitution", "copied from template")
|
||||
else:
|
||||
console.print(f"[cyan]Initialized constitution from template[/cyan]")
|
||||
console.print("[cyan]Initialized constitution from template[/cyan]")
|
||||
except Exception as e:
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
@@ -977,10 +1013,209 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker |
|
||||
else:
|
||||
console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]")
|
||||
|
||||
# Agent-specific skill directory overrides for agents whose skills directory
|
||||
# doesn't follow the standard <agent_folder>/skills/ pattern
|
||||
AGENT_SKILLS_DIR_OVERRIDES = {
|
||||
"codex": ".agents/skills", # Codex agent layout override
|
||||
}
|
||||
|
||||
# Default skills directory for agents not in AGENT_CONFIG
|
||||
DEFAULT_SKILLS_DIR = ".agents/skills"
|
||||
|
||||
# Enhanced descriptions for each spec-kit command skill
|
||||
SKILL_DESCRIPTIONS = {
|
||||
"specify": "Create or update feature specifications from natural language descriptions. Use when starting new features or refining requirements. Generates spec.md with user stories, functional requirements, and acceptance criteria following spec-driven development methodology.",
|
||||
"plan": "Generate technical implementation plans from feature specifications. Use after creating a spec to define architecture, tech stack, and implementation phases. Creates plan.md with detailed technical design.",
|
||||
"tasks": "Break down implementation plans into actionable task lists. Use after planning to create a structured task breakdown. Generates tasks.md with ordered, dependency-aware tasks.",
|
||||
"implement": "Execute all tasks from the task breakdown to build the feature. Use after task generation to systematically implement the planned solution following TDD approach where applicable.",
|
||||
"analyze": "Perform cross-artifact consistency analysis across spec.md, plan.md, and tasks.md. Use after task generation to identify gaps, duplications, and inconsistencies before implementation.",
|
||||
"clarify": "Structured clarification workflow for underspecified requirements. Use before planning to resolve ambiguities through coverage-based questioning. Records answers in spec clarifications section.",
|
||||
"constitution": "Create or update project governing principles and development guidelines. Use at project start to establish code quality, testing standards, and architectural constraints that guide all development.",
|
||||
"checklist": "Generate custom quality checklists for validating requirements completeness and clarity. Use to create unit tests for English that ensure spec quality before implementation.",
|
||||
"taskstoissues": "Convert tasks from tasks.md into GitHub issues. Use after task breakdown to track work items in GitHub project management.",
|
||||
}
|
||||
|
||||
|
||||
def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
|
||||
"""Resolve the agent-specific skills directory for the given AI assistant.
|
||||
|
||||
Uses ``AGENT_SKILLS_DIR_OVERRIDES`` first, then falls back to
|
||||
``AGENT_CONFIG[agent]["folder"] + "skills"``, and finally to
|
||||
``DEFAULT_SKILLS_DIR``.
|
||||
"""
|
||||
if selected_ai in AGENT_SKILLS_DIR_OVERRIDES:
|
||||
return project_path / AGENT_SKILLS_DIR_OVERRIDES[selected_ai]
|
||||
|
||||
agent_config = AGENT_CONFIG.get(selected_ai, {})
|
||||
agent_folder = agent_config.get("folder", "")
|
||||
if agent_folder:
|
||||
return project_path / agent_folder.rstrip("/") / "skills"
|
||||
|
||||
return project_path / DEFAULT_SKILLS_DIR
|
||||
|
||||
|
||||
def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker | None = None) -> bool:
|
||||
"""Install Prompt.MD files from templates/commands/ as agent skills.
|
||||
|
||||
Skills are written to the agent-specific skills directory following the
|
||||
`agentskills.io <https://agentskills.io/specification>`_ specification.
|
||||
Installation is additive — existing files are never removed and prompt
|
||||
command files in the agent's commands directory are left untouched.
|
||||
|
||||
Args:
|
||||
project_path: Target project directory.
|
||||
selected_ai: AI assistant key from ``AGENT_CONFIG``.
|
||||
tracker: Optional progress tracker.
|
||||
|
||||
Returns:
|
||||
``True`` if at least one skill was installed or all skills were
|
||||
already present (idempotent re-run), ``False`` otherwise.
|
||||
"""
|
||||
# Locate command templates in the agent's extracted commands directory.
|
||||
# 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_subdir
|
||||
else:
|
||||
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)
|
||||
# This also covers agents whose extracted commands are in a different
|
||||
# format (e.g. gemini uses .toml, not .md).
|
||||
script_dir = Path(__file__).parent.parent.parent # up from src/specify_cli/
|
||||
fallback_dir = script_dir / "templates" / "commands"
|
||||
if fallback_dir.exists() and any(fallback_dir.glob("*.md")):
|
||||
templates_dir = fallback_dir
|
||||
|
||||
if not templates_dir.exists() or not any(templates_dir.glob("*.md")):
|
||||
if tracker:
|
||||
tracker.error("ai-skills", "command templates not found")
|
||||
else:
|
||||
console.print("[yellow]Warning: command templates not found, skipping skills installation[/yellow]")
|
||||
return False
|
||||
|
||||
command_files = sorted(templates_dir.glob("*.md"))
|
||||
if not command_files:
|
||||
if tracker:
|
||||
tracker.skip("ai-skills", "no command templates found")
|
||||
else:
|
||||
console.print("[yellow]No command templates found to install[/yellow]")
|
||||
return False
|
||||
|
||||
# Resolve the correct skills directory for this agent
|
||||
skills_dir = _get_skills_dir(project_path, selected_ai)
|
||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if tracker:
|
||||
tracker.start("ai-skills")
|
||||
|
||||
installed_count = 0
|
||||
skipped_count = 0
|
||||
for command_file in command_files:
|
||||
try:
|
||||
content = command_file.read_text(encoding="utf-8")
|
||||
|
||||
# Parse YAML frontmatter
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
frontmatter = yaml.safe_load(parts[1])
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
body = parts[2].strip()
|
||||
else:
|
||||
# File starts with --- but has no closing ---
|
||||
console.print(f"[yellow]Warning: {command_file.name} has malformed frontmatter (no closing ---), treating as plain content[/yellow]")
|
||||
frontmatter = {}
|
||||
body = content
|
||||
else:
|
||||
frontmatter = {}
|
||||
body = content
|
||||
|
||||
command_name = command_file.stem
|
||||
# Normalize: extracted commands may be named "speckit.<cmd>.md";
|
||||
# strip the "speckit." prefix so skill names stay clean and
|
||||
# SKILL_DESCRIPTIONS lookups work.
|
||||
if command_name.startswith("speckit."):
|
||||
command_name = command_name[len("speckit."):]
|
||||
skill_name = f"speckit-{command_name}"
|
||||
|
||||
# Create skill directory (additive — never removes existing content)
|
||||
skill_dir = skills_dir / skill_name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Select the best description available
|
||||
original_desc = frontmatter.get("description", "")
|
||||
enhanced_desc = SKILL_DESCRIPTIONS.get(command_name, original_desc or f"Spec-kit workflow command: {command_name}")
|
||||
|
||||
# Build SKILL.md following agentskills.io spec
|
||||
# Use yaml.safe_dump to safely serialise the frontmatter and
|
||||
# avoid YAML injection from descriptions containing colons,
|
||||
# quotes, or newlines.
|
||||
# Normalize source filename for metadata — strip speckit. prefix
|
||||
# so it matches the canonical templates/commands/<cmd>.md path.
|
||||
source_name = command_file.name
|
||||
if source_name.startswith("speckit."):
|
||||
source_name = source_name[len("speckit."):]
|
||||
|
||||
frontmatter_data = {
|
||||
"name": skill_name,
|
||||
"description": enhanced_desc,
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
"metadata": {
|
||||
"author": "github-spec-kit",
|
||||
"source": f"templates/commands/{source_name}",
|
||||
},
|
||||
}
|
||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
f"{frontmatter_text}\n"
|
||||
f"---\n\n"
|
||||
f"# Speckit {command_name.title()} Skill\n\n"
|
||||
f"{body}\n"
|
||||
)
|
||||
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
if skill_file.exists():
|
||||
# Do not overwrite user-customized skills on re-runs
|
||||
skipped_count += 1
|
||||
continue
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
installed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Warning: Failed to install skill {command_file.stem}: {e}[/yellow]")
|
||||
continue
|
||||
|
||||
if tracker:
|
||||
if installed_count > 0 and skipped_count > 0:
|
||||
tracker.complete("ai-skills", f"{installed_count} new + {skipped_count} existing skills in {skills_dir.relative_to(project_path)}")
|
||||
elif installed_count > 0:
|
||||
tracker.complete("ai-skills", f"{installed_count} skills → {skills_dir.relative_to(project_path)}")
|
||||
elif skipped_count > 0:
|
||||
tracker.complete("ai-skills", f"{skipped_count} skills already present")
|
||||
else:
|
||||
tracker.error("ai-skills", "no skills installed")
|
||||
else:
|
||||
if installed_count > 0:
|
||||
console.print(f"[green]✓[/green] Installed {installed_count} agent skills to {skills_dir.relative_to(project_path)}/")
|
||||
elif skipped_count > 0:
|
||||
console.print(f"[green]✓[/green] {skipped_count} agent skills already present in {skills_dir.relative_to(project_path)}/")
|
||||
else:
|
||||
console.print("[yellow]No skills were installed[/yellow]")
|
||||
|
||||
return installed_count > 0 or skipped_count > 0
|
||||
|
||||
|
||||
@app.command()
|
||||
def init(
|
||||
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
|
||||
ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, bob, or qoder "),
|
||||
ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, agy, bob, qodercli, or generic (requires --ai-commands-dir)"),
|
||||
ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"),
|
||||
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
|
||||
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"),
|
||||
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
|
||||
@@ -989,6 +1224,7 @@ def init(
|
||||
skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"),
|
||||
debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"),
|
||||
github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"),
|
||||
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
|
||||
):
|
||||
"""
|
||||
Initialize a new Specify project from the latest template.
|
||||
@@ -1013,10 +1249,27 @@ def init(
|
||||
specify init --here --ai codebuddy
|
||||
specify init --here
|
||||
specify init --here --force # Skip confirmation when current directory not empty
|
||||
specify init my-project --ai claude --ai-skills # Install agent skills
|
||||
specify init --here --ai gemini --ai-skills
|
||||
specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent
|
||||
"""
|
||||
|
||||
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
|
||||
@@ -1029,6 +1282,11 @@ def init(
|
||||
console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if ai_skills and not ai_assistant:
|
||||
console.print("[red]Error:[/red] --ai-skills requires --ai to be specified")
|
||||
console.print("[yellow]Usage:[/yellow] specify init <project> --ai <agent> --ai-skills")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if here:
|
||||
project_name = Path.cwd().name
|
||||
project_path = Path.cwd()
|
||||
@@ -1092,6 +1350,16 @@ def init(
|
||||
"copilot"
|
||||
)
|
||||
|
||||
# Validate --ai-commands-dir usage
|
||||
if selected_ai == "generic":
|
||||
if not ai_commands_dir:
|
||||
console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic")
|
||||
console.print("[dim]Example: specify init my-project --ai generic --ai-commands-dir .myagent/commands/[/dim]")
|
||||
raise typer.Exit(1)
|
||||
elif ai_commands_dir:
|
||||
console.print(f"[red]Error:[/red] --ai-commands-dir can only be used with --ai generic (not '{selected_ai}')")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not ignore_agent_tools:
|
||||
agent_config = AGENT_CONFIG.get(selected_ai)
|
||||
if agent_config and agent_config["requires_cli"]:
|
||||
@@ -1144,6 +1412,11 @@ def init(
|
||||
("extracted-summary", "Extraction summary"),
|
||||
("chmod", "Ensure scripts executable"),
|
||||
("constitution", "Constitution setup"),
|
||||
]:
|
||||
tracker.add(key, label)
|
||||
if ai_skills:
|
||||
tracker.add("ai-skills", "Install agent skills")
|
||||
for key, label in [
|
||||
("cleanup", "Cleanup"),
|
||||
("git", "Initialize git repository"),
|
||||
("final", "Finalize")
|
||||
@@ -1162,10 +1435,45 @@ def init(
|
||||
|
||||
download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token)
|
||||
|
||||
# For generic agent, rename placeholder directory to user-specified path
|
||||
if selected_ai == "generic" and ai_commands_dir:
|
||||
placeholder_dir = project_path / ".speckit" / "commands"
|
||||
target_dir = project_path / ai_commands_dir
|
||||
if placeholder_dir.is_dir():
|
||||
target_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(placeholder_dir), str(target_dir))
|
||||
# Clean up empty .speckit dir if it's now empty
|
||||
speckit_dir = project_path / ".speckit"
|
||||
if speckit_dir.is_dir() and not any(speckit_dir.iterdir()):
|
||||
speckit_dir.rmdir()
|
||||
|
||||
ensure_executable_scripts(project_path, tracker=tracker)
|
||||
|
||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||
|
||||
if ai_skills:
|
||||
skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker)
|
||||
|
||||
# When --ai-skills is used on a NEW project and skills were
|
||||
# successfully installed, remove the command files that the
|
||||
# template archive just created. Skills replace commands, so
|
||||
# keeping both would be confusing. For --here on an existing
|
||||
# repo we leave pre-existing commands untouched to avoid a
|
||||
# breaking change. We only delete AFTER skills succeed so the
|
||||
# project always has at least one of {commands, skills}.
|
||||
if skills_ok and not here:
|
||||
agent_cfg = AGENT_CONFIG.get(selected_ai, {})
|
||||
agent_folder = agent_cfg.get("folder", "")
|
||||
if agent_folder:
|
||||
cmds_dir = project_path / agent_folder.rstrip("/") / "commands"
|
||||
if cmds_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(cmds_dir)
|
||||
except OSError:
|
||||
# Best-effort cleanup: skills are already installed,
|
||||
# so leaving stale commands is non-fatal.
|
||||
console.print("[yellow]Warning: could not remove extracted commands directory[/yellow]")
|
||||
|
||||
if not no_git:
|
||||
tracker.start("git")
|
||||
if is_git_repo(project_path):
|
||||
@@ -1224,16 +1532,17 @@ def init(
|
||||
# Agent folder security notice
|
||||
agent_config = AGENT_CONFIG.get(selected_ai)
|
||||
if agent_config:
|
||||
agent_folder = agent_config["folder"]
|
||||
security_notice = Panel(
|
||||
f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
|
||||
f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
|
||||
title="[yellow]Agent Folder Security[/yellow]",
|
||||
border_style="yellow",
|
||||
padding=(1, 2)
|
||||
)
|
||||
console.print()
|
||||
console.print(security_notice)
|
||||
agent_folder = ai_commands_dir if selected_ai == "generic" else agent_config["folder"]
|
||||
if agent_folder:
|
||||
security_notice = Panel(
|
||||
f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
|
||||
f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
|
||||
title="[yellow]Agent Folder Security[/yellow]",
|
||||
border_style="yellow",
|
||||
padding=(1, 2)
|
||||
)
|
||||
console.print()
|
||||
console.print(security_notice)
|
||||
|
||||
steps_lines = []
|
||||
if not here:
|
||||
@@ -1270,9 +1579,9 @@ def init(
|
||||
enhancement_lines = [
|
||||
"Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]",
|
||||
"",
|
||||
f"○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)",
|
||||
f"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])",
|
||||
f"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])"
|
||||
"○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)",
|
||||
"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])",
|
||||
"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])"
|
||||
]
|
||||
enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2))
|
||||
console.print()
|
||||
@@ -1291,6 +1600,8 @@ def check():
|
||||
|
||||
agent_results = {}
|
||||
for agent_key, agent_config in AGENT_CONFIG.items():
|
||||
if agent_key == "generic":
|
||||
continue # Generic is not a real agent to check
|
||||
agent_name = agent_config["name"]
|
||||
requires_cli = agent_config["requires_cli"]
|
||||
|
||||
@@ -1305,10 +1616,10 @@ def check():
|
||||
|
||||
# Check VS Code variants (not in agent config)
|
||||
tracker.add("code", "Visual Studio Code")
|
||||
code_ok = check_tool("code", tracker=tracker)
|
||||
check_tool("code", tracker=tracker)
|
||||
|
||||
tracker.add("code-insiders", "Visual Studio Code Insiders")
|
||||
code_insiders_ok = check_tool("code-insiders", tracker=tracker)
|
||||
check_tool("code-insiders", tracker=tracker)
|
||||
|
||||
console.print(tracker.render())
|
||||
|
||||
@@ -1409,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."""
|
||||
@@ -1474,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"),
|
||||
@@ -1562,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)
|
||||
@@ -1574,14 +2056,14 @@ def extension_add(
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
|
||||
console.print(f"\n[green]✓[/green] Extension installed successfully!")
|
||||
console.print("\n[green]✓[/green] Extension installed successfully!")
|
||||
console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})")
|
||||
console.print(f" {manifest.description}")
|
||||
console.print(f"\n[bold cyan]Provided commands:[/bold cyan]")
|
||||
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
|
||||
for cmd in manifest.commands:
|
||||
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")
|
||||
|
||||
console.print(f"\n[yellow]⚠[/yellow] Configuration may be required")
|
||||
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
|
||||
console.print(f" Check: .specify/extensions/{manifest.id}/")
|
||||
|
||||
except ValidationError as e:
|
||||
@@ -1631,11 +2113,11 @@ def extension_remove(
|
||||
|
||||
# Confirm removal
|
||||
if not force:
|
||||
console.print(f"\n[yellow]⚠ This will remove:[/yellow]")
|
||||
console.print("\n[yellow]⚠ This will remove:[/yellow]")
|
||||
console.print(f" • {cmd_count} commands from AI agent")
|
||||
console.print(f" • Extension directory: .specify/extensions/{extension}/")
|
||||
if not keep_config:
|
||||
console.print(f" • Config files (will be backed up)")
|
||||
console.print(" • Config files (will be backed up)")
|
||||
console.print()
|
||||
|
||||
confirm = typer.confirm("Continue?")
|
||||
@@ -1654,7 +2136,7 @@ def extension_remove(
|
||||
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension}/")
|
||||
console.print(f"\nTo reinstall: specify extension add {extension}")
|
||||
else:
|
||||
console.print(f"[red]Error:[/red] Failed to remove extension")
|
||||
console.print("[red]Error:[/red] Failed to remove extension")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@@ -1706,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:
|
||||
@@ -1719,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:
|
||||
@@ -1769,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
|
||||
@@ -1825,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}")
|
||||
@@ -1929,8 +2442,8 @@ def extension_update(
|
||||
# TODO: Implement download and reinstall from URL
|
||||
# For now, just show message
|
||||
console.print(
|
||||
f"[yellow]Note:[/yellow] Automatic update not yet implemented. "
|
||||
f"Please update manually:"
|
||||
"[yellow]Note:[/yellow] Automatic update not yet implemented. "
|
||||
"Please update manually:"
|
||||
)
|
||||
console.print(f" specify extension remove {ext_id} --keep-config")
|
||||
console.print(f" specify extension add {ext_id}")
|
||||
@@ -2030,7 +2543,7 @@ def extension_disable(
|
||||
hook_executor.save_project_config(config)
|
||||
|
||||
console.print(f"[green]✓[/green] Extension '{extension}' disabled")
|
||||
console.print(f"\nCommands will no longer be available. Hooks will not execute.")
|
||||
console.print("\nCommands will no longer be available. Hooks will not execute.")
|
||||
console.print(f"To re-enable: specify extension enable {extension}")
|
||||
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -647,7 +658,7 @@ class CommandRegistrar:
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"qoder": {
|
||||
"qodercli": {
|
||||
"dir": ".qoder/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
@@ -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:
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
||||
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -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]
|
||||
|
||||
694
tests/test_ai_skills.py
Normal file
694
tests/test_ai_skills.py
Normal file
@@ -0,0 +1,694 @@
|
||||
"""
|
||||
Unit tests for AI agent skills installation.
|
||||
|
||||
Tests cover:
|
||||
- Skills directory resolution for different agents (_get_skills_dir)
|
||||
- YAML frontmatter parsing and SKILL.md generation (install_ai_skills)
|
||||
- Cleanup of duplicate command files when --ai-skills is used
|
||||
- Missing templates directory handling
|
||||
- Malformed template error handling
|
||||
- CLI validation: --ai-skills requires --ai
|
||||
"""
|
||||
|
||||
import re
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import specify_cli
|
||||
|
||||
from specify_cli import (
|
||||
_get_skills_dir,
|
||||
install_ai_skills,
|
||||
AGENT_SKILLS_DIR_OVERRIDES,
|
||||
DEFAULT_SKILLS_DIR,
|
||||
SKILL_DESCRIPTIONS,
|
||||
AGENT_CONFIG,
|
||||
app,
|
||||
)
|
||||
|
||||
|
||||
# ===== Fixtures =====
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
"""Create a temporary directory for tests."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
yield Path(tmpdir)
|
||||
shutil.rmtree(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(temp_dir):
|
||||
"""Create a mock project directory."""
|
||||
proj_dir = temp_dir / "test-project"
|
||||
proj_dir.mkdir()
|
||||
return proj_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def templates_dir(project_dir):
|
||||
"""Create mock command templates in the project's agent commands directory.
|
||||
|
||||
This simulates what download_and_extract_template() does: it places
|
||||
command .md files into project_path/<agent_folder>/commands/.
|
||||
install_ai_skills() now reads from here instead of from the repo
|
||||
source tree.
|
||||
"""
|
||||
tpl_root = project_dir / ".claude" / "commands"
|
||||
tpl_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Template with valid YAML frontmatter
|
||||
(tpl_root / "specify.md").write_text(
|
||||
"---\n"
|
||||
"description: Create or update the feature specification.\n"
|
||||
"handoffs:\n"
|
||||
" - label: Build Plan\n"
|
||||
" agent: speckit.plan\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/create-new-feature.sh\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Specify Command\n"
|
||||
"\n"
|
||||
"Run this to create a spec.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Template with minimal frontmatter
|
||||
(tpl_root / "plan.md").write_text(
|
||||
"---\n"
|
||||
"description: Generate implementation plan.\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Plan Command\n"
|
||||
"\n"
|
||||
"Plan body content.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Template with no frontmatter
|
||||
(tpl_root / "tasks.md").write_text(
|
||||
"# Tasks Command\n"
|
||||
"\n"
|
||||
"Body without frontmatter.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Template with empty YAML frontmatter (yaml.safe_load returns None)
|
||||
(tpl_root / "empty_fm.md").write_text(
|
||||
"---\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Empty Frontmatter Command\n"
|
||||
"\n"
|
||||
"Body with empty frontmatter.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
return tpl_root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def commands_dir_claude(project_dir):
|
||||
"""Create a populated .claude/commands directory simulating template extraction."""
|
||||
cmd_dir = project_dir / ".claude" / "commands"
|
||||
cmd_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name in ["speckit.specify.md", "speckit.plan.md", "speckit.tasks.md"]:
|
||||
(cmd_dir / name).write_text(f"# {name}\nContent here\n")
|
||||
return cmd_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def commands_dir_gemini(project_dir):
|
||||
"""Create a populated .gemini/commands directory (TOML format)."""
|
||||
cmd_dir = project_dir / ".gemini" / "commands"
|
||||
cmd_dir.mkdir(parents=True)
|
||||
for name in ["speckit.specify.toml", "speckit.plan.toml", "speckit.tasks.toml"]:
|
||||
(cmd_dir / name).write_text(f'[command]\nname = "{name}"\n')
|
||||
return cmd_dir
|
||||
|
||||
|
||||
# ===== _get_skills_dir Tests =====
|
||||
|
||||
class TestGetSkillsDir:
|
||||
"""Test the _get_skills_dir() helper function."""
|
||||
|
||||
def test_claude_skills_dir(self, project_dir):
|
||||
"""Claude should use .claude/skills/."""
|
||||
result = _get_skills_dir(project_dir, "claude")
|
||||
assert result == project_dir / ".claude" / "skills"
|
||||
|
||||
def test_gemini_skills_dir(self, project_dir):
|
||||
"""Gemini should use .gemini/skills/."""
|
||||
result = _get_skills_dir(project_dir, "gemini")
|
||||
assert result == project_dir / ".gemini" / "skills"
|
||||
|
||||
def test_copilot_skills_dir(self, project_dir):
|
||||
"""Copilot should use .github/skills/."""
|
||||
result = _get_skills_dir(project_dir, "copilot")
|
||||
assert result == project_dir / ".github" / "skills"
|
||||
|
||||
def test_codex_uses_override(self, project_dir):
|
||||
"""Codex should use the AGENT_SKILLS_DIR_OVERRIDES value."""
|
||||
result = _get_skills_dir(project_dir, "codex")
|
||||
assert result == project_dir / ".agents" / "skills"
|
||||
|
||||
def test_cursor_agent_skills_dir(self, project_dir):
|
||||
"""Cursor should use .cursor/skills/."""
|
||||
result = _get_skills_dir(project_dir, "cursor-agent")
|
||||
assert result == project_dir / ".cursor" / "skills"
|
||||
|
||||
def test_unknown_agent_uses_default(self, project_dir):
|
||||
"""Unknown agents should fall back to DEFAULT_SKILLS_DIR."""
|
||||
result = _get_skills_dir(project_dir, "nonexistent-agent")
|
||||
assert result == project_dir / DEFAULT_SKILLS_DIR
|
||||
|
||||
def test_all_configured_agents_resolve(self, project_dir):
|
||||
"""Every agent in AGENT_CONFIG should resolve to a valid path."""
|
||||
for agent_key in AGENT_CONFIG:
|
||||
result = _get_skills_dir(project_dir, agent_key)
|
||||
assert result is not None
|
||||
assert str(result).startswith(str(project_dir))
|
||||
# Should always end with "skills"
|
||||
assert result.name == "skills"
|
||||
|
||||
def test_override_takes_precedence_over_config(self, project_dir):
|
||||
"""AGENT_SKILLS_DIR_OVERRIDES should take precedence over AGENT_CONFIG."""
|
||||
for agent_key in AGENT_SKILLS_DIR_OVERRIDES:
|
||||
result = _get_skills_dir(project_dir, agent_key)
|
||||
expected = project_dir / AGENT_SKILLS_DIR_OVERRIDES[agent_key]
|
||||
assert result == expected
|
||||
|
||||
|
||||
# ===== install_ai_skills Tests =====
|
||||
|
||||
class TestInstallAiSkills:
|
||||
"""Test SKILL.md generation and installation logic."""
|
||||
|
||||
def test_skills_installed_with_correct_structure(self, project_dir, templates_dir):
|
||||
"""Verify SKILL.md files have correct agentskills.io structure."""
|
||||
result = install_ai_skills(project_dir, "claude")
|
||||
|
||||
assert result is True
|
||||
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
assert skills_dir.exists()
|
||||
|
||||
# Check that skill directories were created
|
||||
skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()])
|
||||
assert "speckit-plan" in skill_dirs
|
||||
assert "speckit-specify" in skill_dirs
|
||||
assert "speckit-tasks" in skill_dirs
|
||||
assert "speckit-empty_fm" in skill_dirs
|
||||
|
||||
# Verify SKILL.md content for speckit-specify
|
||||
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
|
||||
# Check agentskills.io frontmatter
|
||||
assert content.startswith("---\n")
|
||||
assert "name: speckit-specify" in content
|
||||
assert "description:" in content
|
||||
assert "compatibility:" in content
|
||||
assert "metadata:" in content
|
||||
assert "author: github-spec-kit" in content
|
||||
assert "source: templates/commands/specify.md" in content
|
||||
|
||||
# Check body content is included
|
||||
assert "# Speckit Specify Skill" in content
|
||||
assert "Run this to create a spec." in content
|
||||
|
||||
def test_generated_skill_has_parseable_yaml(self, project_dir, templates_dir):
|
||||
"""Generated SKILL.md should contain valid, parseable YAML frontmatter."""
|
||||
install_ai_skills(project_dir, "claude")
|
||||
|
||||
skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
|
||||
# Extract and parse frontmatter
|
||||
assert content.startswith("---\n")
|
||||
parts = content.split("---", 2)
|
||||
assert len(parts) >= 3
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert isinstance(parsed, dict)
|
||||
assert "name" in parsed
|
||||
assert parsed["name"] == "speckit-specify"
|
||||
assert "description" in parsed
|
||||
|
||||
def test_empty_yaml_frontmatter(self, project_dir, templates_dir):
|
||||
"""Templates with empty YAML frontmatter (---\\n---) should not crash."""
|
||||
result = install_ai_skills(project_dir, "claude")
|
||||
|
||||
assert result is True
|
||||
|
||||
skill_file = project_dir / ".claude" / "skills" / "speckit-empty_fm" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
assert "name: speckit-empty_fm" in content
|
||||
assert "Body with empty frontmatter." in content
|
||||
|
||||
def test_enhanced_descriptions_used_when_available(self, project_dir, templates_dir):
|
||||
"""SKILL_DESCRIPTIONS take precedence over template frontmatter descriptions."""
|
||||
install_ai_skills(project_dir, "claude")
|
||||
|
||||
skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
|
||||
# Parse the generated YAML to compare the description value
|
||||
# (yaml.safe_dump may wrap long strings across multiple lines)
|
||||
parts = content.split("---", 2)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
|
||||
if "specify" in SKILL_DESCRIPTIONS:
|
||||
assert parsed["description"] == SKILL_DESCRIPTIONS["specify"]
|
||||
|
||||
def test_template_without_frontmatter(self, project_dir, templates_dir):
|
||||
"""Templates without YAML frontmatter should still produce valid skills."""
|
||||
install_ai_skills(project_dir, "claude")
|
||||
|
||||
skill_file = project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
|
||||
# Should still have valid SKILL.md structure
|
||||
assert "name: speckit-tasks" in content
|
||||
assert "Body without frontmatter." in content
|
||||
|
||||
def test_missing_templates_directory(self, project_dir):
|
||||
"""Returns False when no command templates exist anywhere."""
|
||||
# No .claude/commands/ exists, and __file__ fallback won't find anything
|
||||
fake_init = project_dir / "nonexistent" / "src" / "specify_cli" / "__init__.py"
|
||||
fake_init.parent.mkdir(parents=True, exist_ok=True)
|
||||
fake_init.touch()
|
||||
|
||||
with patch.object(specify_cli, "__file__", str(fake_init)):
|
||||
result = install_ai_skills(project_dir, "claude")
|
||||
|
||||
assert result is False
|
||||
|
||||
# Skills directory should not exist
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
assert not skills_dir.exists()
|
||||
|
||||
def test_empty_templates_directory(self, project_dir):
|
||||
"""Returns False when commands directory has no .md files."""
|
||||
# Create empty .claude/commands/
|
||||
empty_cmds = project_dir / ".claude" / "commands"
|
||||
empty_cmds.mkdir(parents=True)
|
||||
|
||||
# Block the __file__ fallback so it can't find real templates
|
||||
fake_init = project_dir / "nowhere" / "src" / "specify_cli" / "__init__.py"
|
||||
fake_init.parent.mkdir(parents=True, exist_ok=True)
|
||||
fake_init.touch()
|
||||
|
||||
with patch.object(specify_cli, "__file__", str(fake_init)):
|
||||
result = install_ai_skills(project_dir, "claude")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_malformed_yaml_frontmatter(self, project_dir):
|
||||
"""Malformed YAML in a template should be handled gracefully, not crash."""
|
||||
# Create .claude/commands/ with a broken template
|
||||
cmds_dir = project_dir / ".claude" / "commands"
|
||||
cmds_dir.mkdir(parents=True)
|
||||
|
||||
(cmds_dir / "broken.md").write_text(
|
||||
"---\n"
|
||||
"description: [unclosed bracket\n"
|
||||
" invalid: yaml: content: here\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Broken\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Should not raise — errors are caught per-file
|
||||
result = install_ai_skills(project_dir, "claude")
|
||||
|
||||
# The broken template should be skipped but not crash the process
|
||||
assert result is False
|
||||
|
||||
def test_additive_does_not_overwrite_other_files(self, project_dir, templates_dir):
|
||||
"""Installing skills should not remove non-speckit files in the skills dir."""
|
||||
# Pre-create a custom skill
|
||||
custom_dir = project_dir / ".claude" / "skills" / "my-custom-skill"
|
||||
custom_dir.mkdir(parents=True)
|
||||
custom_file = custom_dir / "SKILL.md"
|
||||
custom_file.write_text("# My Custom Skill\n")
|
||||
|
||||
install_ai_skills(project_dir, "claude")
|
||||
|
||||
# Custom skill should still exist
|
||||
assert custom_file.exists()
|
||||
assert custom_file.read_text() == "# My Custom Skill\n"
|
||||
|
||||
def test_return_value(self, project_dir, templates_dir):
|
||||
"""install_ai_skills returns True when skills installed, False otherwise."""
|
||||
assert install_ai_skills(project_dir, "claude") is True
|
||||
|
||||
def test_return_false_when_no_templates(self, project_dir):
|
||||
"""install_ai_skills returns False when no templates found."""
|
||||
fake_init = project_dir / "missing" / "src" / "specify_cli" / "__init__.py"
|
||||
fake_init.parent.mkdir(parents=True, exist_ok=True)
|
||||
fake_init.touch()
|
||||
|
||||
with patch.object(specify_cli, "__file__", str(fake_init)):
|
||||
assert install_ai_skills(project_dir, "claude") is False
|
||||
|
||||
def test_non_md_commands_dir_falls_back(self, project_dir):
|
||||
"""When extracted commands are .toml (e.g. gemini), fall back to repo templates."""
|
||||
# Simulate gemini template extraction: .gemini/commands/ with .toml files only
|
||||
cmds_dir = project_dir / ".gemini" / "commands"
|
||||
cmds_dir.mkdir(parents=True)
|
||||
(cmds_dir / "speckit.specify.toml").write_text('[command]\nname = "specify"\n')
|
||||
(cmds_dir / "speckit.plan.toml").write_text('[command]\nname = "plan"\n')
|
||||
|
||||
# The __file__ fallback should find the real repo templates/commands/*.md
|
||||
result = install_ai_skills(project_dir, "gemini")
|
||||
|
||||
assert result is True
|
||||
skills_dir = project_dir / ".gemini" / "skills"
|
||||
assert skills_dir.exists()
|
||||
# Should have installed skills from the fallback .md templates
|
||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
||||
assert len(skill_dirs) >= 1
|
||||
# .toml commands should be untouched
|
||||
assert (cmds_dir / "speckit.specify.toml").exists()
|
||||
|
||||
@pytest.mark.parametrize("agent_key", [k for k in AGENT_CONFIG.keys() if k != "generic"])
|
||||
def test_skills_install_for_all_agents(self, temp_dir, agent_key):
|
||||
"""install_ai_skills should produce skills for every configured agent."""
|
||||
proj = temp_dir / f"proj-{agent_key}"
|
||||
proj.mkdir()
|
||||
|
||||
# Place .md templates in the agent's commands directory
|
||||
agent_folder = AGENT_CONFIG[agent_key]["folder"]
|
||||
cmds_dir = proj / agent_folder.rstrip("/") / "commands"
|
||||
cmds_dir.mkdir(parents=True)
|
||||
(cmds_dir / "specify.md").write_text(
|
||||
"---\ndescription: Test command\n---\n\n# Test\n\nBody.\n"
|
||||
)
|
||||
|
||||
result = install_ai_skills(proj, agent_key)
|
||||
|
||||
assert result is True
|
||||
skills_dir = _get_skills_dir(proj, agent_key)
|
||||
assert skills_dir.exists()
|
||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
||||
assert "speckit-specify" in skill_dirs
|
||||
assert (skills_dir / "speckit-specify" / "SKILL.md").exists()
|
||||
|
||||
|
||||
|
||||
class TestCommandCoexistence:
|
||||
"""Verify install_ai_skills never touches command files.
|
||||
|
||||
Cleanup of freshly-extracted commands for NEW projects is handled
|
||||
in init(), not in install_ai_skills(). These tests confirm that
|
||||
install_ai_skills leaves existing commands intact.
|
||||
"""
|
||||
|
||||
def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude):
|
||||
"""install_ai_skills must NOT remove pre-existing .claude/commands files."""
|
||||
# Verify commands exist before
|
||||
assert len(list(commands_dir_claude.glob("speckit.*"))) == 3
|
||||
|
||||
install_ai_skills(project_dir, "claude")
|
||||
|
||||
# Commands must still be there — install_ai_skills never touches them
|
||||
remaining = list(commands_dir_claude.glob("speckit.*"))
|
||||
assert len(remaining) == 3
|
||||
|
||||
def test_existing_commands_preserved_gemini(self, project_dir, templates_dir, commands_dir_gemini):
|
||||
"""install_ai_skills must NOT remove pre-existing .gemini/commands files."""
|
||||
assert len(list(commands_dir_gemini.glob("speckit.*"))) == 3
|
||||
|
||||
install_ai_skills(project_dir, "gemini")
|
||||
|
||||
remaining = list(commands_dir_gemini.glob("speckit.*"))
|
||||
assert len(remaining) == 3
|
||||
|
||||
def test_commands_dir_not_removed(self, project_dir, templates_dir, commands_dir_claude):
|
||||
"""install_ai_skills must not remove the commands directory."""
|
||||
install_ai_skills(project_dir, "claude")
|
||||
|
||||
assert commands_dir_claude.exists()
|
||||
|
||||
def test_no_commands_dir_no_error(self, project_dir, templates_dir):
|
||||
"""No error when installing skills — commands dir has templates and is preserved."""
|
||||
result = install_ai_skills(project_dir, "claude")
|
||||
|
||||
# Should succeed since templates are in .claude/commands/ via fixture
|
||||
assert result is True
|
||||
|
||||
|
||||
# ===== New-Project Command Skip Tests =====
|
||||
|
||||
class TestNewProjectCommandSkip:
|
||||
"""Test that init() removes extracted commands for new projects only.
|
||||
|
||||
These tests run init() end-to-end via CliRunner with
|
||||
download_and_extract_template patched to create local fixtures.
|
||||
"""
|
||||
|
||||
def _fake_extract(self, agent, project_path, **_kwargs):
|
||||
"""Simulate template extraction: create agent commands dir."""
|
||||
agent_cfg = AGENT_CONFIG.get(agent, {})
|
||||
agent_folder = agent_cfg.get("folder", "")
|
||||
if agent_folder:
|
||||
cmds_dir = project_path / agent_folder.rstrip("/") / "commands"
|
||||
cmds_dir.mkdir(parents=True, exist_ok=True)
|
||||
(cmds_dir / "speckit.specify.md").write_text("# spec")
|
||||
|
||||
def test_new_project_commands_removed_after_skills_succeed(self, tmp_path):
|
||||
"""For new projects, commands should be removed when skills succeed."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "new-proj"
|
||||
|
||||
def fake_download(project_path, *args, **kwargs):
|
||||
self._fake_extract("claude", project_path)
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"])
|
||||
|
||||
# Skills should have been called
|
||||
mock_skills.assert_called_once()
|
||||
|
||||
# Commands dir should have been removed after skills succeeded
|
||||
cmds_dir = target / ".claude" / "commands"
|
||||
assert not cmds_dir.exists()
|
||||
|
||||
def test_commands_preserved_when_skills_fail(self, tmp_path):
|
||||
"""If skills fail, commands should NOT be removed (safety net)."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "fail-proj"
|
||||
|
||||
def fake_download(project_path, *args, **kwargs):
|
||||
self._fake_extract("claude", project_path)
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills", return_value=False), \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"])
|
||||
|
||||
# Commands should still exist since skills failed
|
||||
cmds_dir = target / ".claude" / "commands"
|
||||
assert cmds_dir.exists()
|
||||
assert (cmds_dir / "speckit.specify.md").exists()
|
||||
|
||||
def test_here_mode_commands_preserved(self, tmp_path, monkeypatch):
|
||||
"""For --here on existing repos, commands must NOT be removed."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
# Create a mock existing project with commands already present
|
||||
target = tmp_path / "existing"
|
||||
target.mkdir()
|
||||
agent_folder = AGENT_CONFIG["claude"]["folder"]
|
||||
cmds_dir = target / agent_folder.rstrip("/") / "commands"
|
||||
cmds_dir.mkdir(parents=True)
|
||||
(cmds_dir / "speckit.specify.md").write_text("# spec")
|
||||
|
||||
# --here uses CWD, so chdir into the target
|
||||
monkeypatch.chdir(target)
|
||||
|
||||
def fake_download(project_path, *args, **kwargs):
|
||||
pass # commands already exist, no need to re-create
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills", return_value=True), \
|
||||
patch("specify_cli.is_git_repo", return_value=True), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
|
||||
result = runner.invoke(app, ["init", "--here", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"])
|
||||
|
||||
# Commands must remain for --here
|
||||
assert cmds_dir.exists()
|
||||
assert (cmds_dir / "speckit.specify.md").exists()
|
||||
|
||||
|
||||
# ===== Skip-If-Exists Tests =====
|
||||
|
||||
class TestSkipIfExists:
|
||||
"""Test that install_ai_skills does not overwrite existing SKILL.md files."""
|
||||
|
||||
def test_existing_skill_not_overwritten(self, project_dir, templates_dir):
|
||||
"""Pre-existing SKILL.md should not be replaced on re-run."""
|
||||
# Pre-create a custom SKILL.md for speckit-specify
|
||||
skill_dir = project_dir / ".claude" / "skills" / "speckit-specify"
|
||||
skill_dir.mkdir(parents=True)
|
||||
custom_content = "# My Custom Specify Skill\nUser-modified content\n"
|
||||
(skill_dir / "SKILL.md").write_text(custom_content)
|
||||
|
||||
result = install_ai_skills(project_dir, "claude")
|
||||
|
||||
# The custom SKILL.md should be untouched
|
||||
assert (skill_dir / "SKILL.md").read_text() == custom_content
|
||||
|
||||
# But other skills should still be installed
|
||||
assert result is True
|
||||
assert (project_dir / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
assert (project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md").exists()
|
||||
|
||||
def test_fresh_install_writes_all_skills(self, project_dir, templates_dir):
|
||||
"""On first install (no pre-existing skills), all should be written."""
|
||||
result = install_ai_skills(project_dir, "claude")
|
||||
|
||||
assert result is True
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
||||
# All 4 templates should produce skills (specify, plan, tasks, empty_fm)
|
||||
assert len(skill_dirs) == 4
|
||||
|
||||
|
||||
# ===== SKILL_DESCRIPTIONS Coverage Tests =====
|
||||
|
||||
class TestSkillDescriptions:
|
||||
"""Test SKILL_DESCRIPTIONS constants."""
|
||||
|
||||
def test_all_known_commands_have_descriptions(self):
|
||||
"""All standard spec-kit commands should have enhanced descriptions."""
|
||||
expected_commands = [
|
||||
"specify", "plan", "tasks", "implement", "analyze",
|
||||
"clarify", "constitution", "checklist", "taskstoissues",
|
||||
]
|
||||
for cmd in expected_commands:
|
||||
assert cmd in SKILL_DESCRIPTIONS, f"Missing description for '{cmd}'"
|
||||
assert len(SKILL_DESCRIPTIONS[cmd]) > 20, f"Description for '{cmd}' is too short"
|
||||
|
||||
|
||||
# ===== CLI Validation Tests =====
|
||||
|
||||
class TestCliValidation:
|
||||
"""Test --ai-skills CLI flag validation."""
|
||||
|
||||
def test_ai_skills_without_ai_fails(self):
|
||||
"""--ai-skills without --ai should fail with exit code 1."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", "test-proj", "--ai-skills"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "--ai-skills requires --ai" in result.output
|
||||
|
||||
def test_ai_skills_without_ai_shows_usage(self):
|
||||
"""Error message should include usage hint."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", "test-proj", "--ai-skills"])
|
||||
|
||||
assert "Usage:" in result.output
|
||||
assert "--ai" in result.output
|
||||
|
||||
def test_ai_skills_flag_appears_in_help(self):
|
||||
"""--ai-skills should appear in init --help output."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", "--help"])
|
||||
|
||||
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
|
||||
263
tests/test_cursor_frontmatter.py
Normal file
263
tests/test_cursor_frontmatter.py
Normal 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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user