mirror of
https://github.com/github/spec-kit.git
synced 2026-03-20 20:33:08 +00:00
Compare commits
11 Commits
copilot/up
...
019f23548c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
019f23548c | ||
|
|
7279f1d7f1 | ||
|
|
ece831e1a7 | ||
|
|
8a1328cfbd | ||
|
|
128bb0d790 | ||
|
|
9d0981a3ff | ||
|
|
b55d00beed | ||
|
|
525eae7f7e | ||
|
|
ce7bed4823 | ||
|
|
61b0637a6d | ||
|
|
56deda7be3 |
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Fetch all history for git info
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Run markdownlint-cli2
|
- name: Run markdownlint-cli2
|
||||||
uses: DavidAnson/markdownlint-cli2-action@v19
|
uses: DavidAnson/markdownlint-cli2-action@v19
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -16,10 +16,10 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v7
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
|
|
||||||
@@ -36,10 +36,10 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v7
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
|||||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -7,6 +7,25 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.1.7] - 2026-02-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Multi-Catalog Support (#1707)**: Extension catalog system now supports multiple active catalogs simultaneously via a catalog stack
|
||||||
|
- New `specify extension catalogs` command lists all active catalogs with name, URL, priority, and `install_allowed` status
|
||||||
|
- New `specify extension catalog add` and `specify extension catalog remove` commands for project-scoped catalog management
|
||||||
|
- Default built-in stack includes `catalog.json` (org-approved, installable) and `catalog.community.json` (discovery only) — community extensions are now surfaced in search results out of the box
|
||||||
|
- `specify extension search` aggregates results across all active catalogs, annotating each result with source catalog
|
||||||
|
- `specify extension add` enforces `install_allowed` policy — extensions from discovery-only catalogs cannot be installed directly
|
||||||
|
- Project-level `.specify/extension-catalogs.yml` and user-level `~/.specify/extension-catalogs.yml` config files supported, with project-level taking precedence
|
||||||
|
- `SPECKIT_CATALOG_URL` environment variable still works for backward compatibility (replaces full stack with single catalog)
|
||||||
|
- All catalog URLs require HTTPS (HTTP allowed for localhost development)
|
||||||
|
- New `CatalogEntry` dataclass in `extensions.py` for catalog stack representation
|
||||||
|
- Per-URL hash-based caching for non-default catalogs; legacy cache preserved for default catalog
|
||||||
|
- Higher-priority catalogs win on merge conflicts (same extension id in multiple catalogs)
|
||||||
|
- 13 new tests covering catalog stack resolution, merge conflicts, URL validation, and `install_allowed` enforcement
|
||||||
|
- Updated RFC, Extension User Guide, and Extension API Reference documentation
|
||||||
|
|
||||||
## [0.1.6] - 2026-02-23
|
## [0.1.6] - 2026-02-23
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -243,6 +243,32 @@ manager.check_compatibility(
|
|||||||
) # Raises: CompatibilityError if incompatible
|
) # 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
|
### ExtensionCatalog
|
||||||
|
|
||||||
**Module**: `specify_cli.extensions`
|
**Module**: `specify_cli.extensions`
|
||||||
@@ -253,30 +279,65 @@ from specify_cli.extensions import ExtensionCatalog
|
|||||||
catalog = ExtensionCatalog(project_root)
|
catalog = ExtensionCatalog(project_root)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Class attributes**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ExtensionCatalog.DEFAULT_CATALOG_URL # org-approved catalog URL
|
||||||
|
ExtensionCatalog.COMMUNITY_CATALOG_URL # community catalog URL
|
||||||
|
```
|
||||||
|
|
||||||
**Methods**:
|
**Methods**:
|
||||||
|
|
||||||
```python
|
```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
|
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(
|
results = catalog.search(
|
||||||
query: Optional[str] = None,
|
query: Optional[str] = None,
|
||||||
tag: Optional[str] = None,
|
tag: Optional[str] = None,
|
||||||
author: Optional[str] = None,
|
author: Optional[str] = None,
|
||||||
verified_only: bool = False
|
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]
|
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
|
is_valid = catalog.is_cache_valid() # bool
|
||||||
|
|
||||||
# Clear cache
|
# Clear all catalog caches
|
||||||
catalog.clear_cache()
|
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
|
### HookExecutor
|
||||||
|
|
||||||
**Module**: `specify_cli.extensions`
|
**Module**: `specify_cli.extensions`
|
||||||
@@ -543,6 +604,38 @@ EXECUTE_COMMAND: {command}
|
|||||||
|
|
||||||
**Output**: List of installed extensions with metadata
|
**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
|
### extension add
|
||||||
|
|
||||||
**Usage**: `specify extension add EXTENSION [OPTIONS]`
|
**Usage**: `specify extension add EXTENSION [OPTIONS]`
|
||||||
@@ -551,13 +644,13 @@ EXECUTE_COMMAND: {command}
|
|||||||
|
|
||||||
- `--from URL` - Install from custom URL
|
- `--from URL` - Install from custom URL
|
||||||
- `--dev PATH` - Install from local directory
|
- `--dev PATH` - Install from local directory
|
||||||
- `--version VERSION` - Install specific version
|
|
||||||
- `--no-register` - Skip command registration
|
|
||||||
|
|
||||||
**Arguments**:
|
**Arguments**:
|
||||||
|
|
||||||
- `EXTENSION` - Extension name or URL
|
- `EXTENSION` - Extension name or URL
|
||||||
|
|
||||||
|
**Note**: Extensions from catalogs with `install_allowed: false` cannot be installed via this command.
|
||||||
|
|
||||||
### extension remove
|
### extension remove
|
||||||
|
|
||||||
**Usage**: `specify extension remove EXTENSION [OPTIONS]`
|
**Usage**: `specify extension remove EXTENSION [OPTIONS]`
|
||||||
@@ -575,6 +668,8 @@ EXECUTE_COMMAND: {command}
|
|||||||
|
|
||||||
**Usage**: `specify extension search [QUERY] [OPTIONS]`
|
**Usage**: `specify extension search [QUERY] [OPTIONS]`
|
||||||
|
|
||||||
|
Searches all active catalogs simultaneously. Results include source catalog name and install_allowed status.
|
||||||
|
|
||||||
**Options**:
|
**Options**:
|
||||||
|
|
||||||
- `--tag TAG` - Filter by tag
|
- `--tag TAG` - Filter by tag
|
||||||
@@ -589,6 +684,8 @@ EXECUTE_COMMAND: {command}
|
|||||||
|
|
||||||
**Usage**: `specify extension info EXTENSION`
|
**Usage**: `specify extension info EXTENSION`
|
||||||
|
|
||||||
|
Shows source catalog and install_allowed status.
|
||||||
|
|
||||||
**Arguments**:
|
**Arguments**:
|
||||||
|
|
||||||
- `EXTENSION` - Extension ID
|
- `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
|
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
|
1. **Fork** spec-kit repository
|
||||||
2. **Add entry** to `extensions/catalog.json`
|
2. **Add entry** to `extensions/catalog.community.json`
|
||||||
3. **Create PR**
|
3. **Update** `extensions/README.md` with your extension
|
||||||
4. **After merge**, users can install with:
|
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
|
See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed submission instructions.
|
||||||
specify extension add my-ext # No URL needed!
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -129,26 +129,32 @@ specify extension add --from https://github.com/your-org/spec-kit-your-extension
|
|||||||
|
|
||||||
## Submit to Catalog
|
## 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
|
### 1. Fork the spec-kit Repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Fork on GitHub
|
# Fork on GitHub
|
||||||
# https://github.com/statsperform/spec-kit/fork
|
# https://github.com/github/spec-kit/fork
|
||||||
|
|
||||||
# Clone your fork
|
# Clone your fork
|
||||||
git clone https://github.com/YOUR-USERNAME/spec-kit.git
|
git clone https://github.com/YOUR-USERNAME/spec-kit.git
|
||||||
cd spec-kit
|
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
|
```json
|
||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"updated_at": "2026-01-28T15:54:00Z",
|
"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": {
|
"extensions": {
|
||||||
"your-extension": {
|
"your-extension": {
|
||||||
"name": "Your Extension Name",
|
"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`
|
- Use current timestamp for `created_at` and `updated_at`
|
||||||
- Update the top-level `updated_at` to current time
|
- 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
|
```bash
|
||||||
# Create a branch
|
# Create a branch
|
||||||
git checkout -b add-your-extension
|
git checkout -b add-your-extension
|
||||||
|
|
||||||
# Commit your changes
|
# Commit your changes
|
||||||
git add extensions/catalog.json
|
git add extensions/catalog.community.json extensions/README.md
|
||||||
git commit -m "Add your-extension to catalog
|
git commit -m "Add your-extension to community catalog
|
||||||
|
|
||||||
- Extension ID: your-extension
|
- Extension ID: your-extension
|
||||||
- Version: 1.0.0
|
- Version: 1.0.0
|
||||||
@@ -218,7 +234,7 @@ git commit -m "Add your-extension to catalog
|
|||||||
git push origin add-your-extension
|
git push origin add-your-extension
|
||||||
|
|
||||||
# Create Pull Request on GitHub
|
# Create Pull Request on GitHub
|
||||||
# https://github.com/statsperform/spec-kit/compare
|
# https://github.com/github/spec-kit/compare
|
||||||
```
|
```
|
||||||
|
|
||||||
**Pull Request Template**:
|
**Pull Request Template**:
|
||||||
@@ -243,6 +259,8 @@ Brief description of what your extension does.
|
|||||||
- [x] Extension tested on real project
|
- [x] Extension tested on real project
|
||||||
- [x] All commands working
|
- [x] All commands working
|
||||||
- [x] No security vulnerabilities
|
- [x] No security vulnerabilities
|
||||||
|
- [x] Added to extensions/catalog.community.json
|
||||||
|
- [x] Added to extensions/README.md Available Extensions table
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
Tested on:
|
Tested on:
|
||||||
|
|||||||
@@ -76,13 +76,15 @@ vim .specify/extensions/jira/jira-config.yml
|
|||||||
|
|
||||||
## Finding Extensions
|
## 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
|
### Browse All Extensions
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
specify extension search
|
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
|
### Search by Keyword
|
||||||
|
|
||||||
@@ -400,13 +402,13 @@ In addition to extension-specific environment variables (`SPECKIT_{EXT_ID}_*`),
|
|||||||
|
|
||||||
| Variable | Description | Default |
|
| 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 |
|
| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub API token for downloads | None |
|
||||||
|
|
||||||
#### Example: Using a custom catalog for testing
|
#### Example: Using a custom catalog for testing
|
||||||
|
|
||||||
```bash
|
```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"
|
export SPECKIT_CATALOG_URL="http://localhost:8000/catalog.json"
|
||||||
|
|
||||||
# Or use a staging catalog
|
# 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
|
## 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
|
- **Control available extensions** - Curate which extensions your team can install
|
||||||
- **Host private extensions** - Internal tools that shouldn't be public
|
- **Host private extensions** - Internal tools that shouldn't be public
|
||||||
@@ -497,24 +563,40 @@ Options for hosting your catalog:
|
|||||||
|
|
||||||
#### 3. Configure Your Environment
|
#### 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
|
```bash
|
||||||
# In ~/.bashrc, ~/.zshrc, or CI pipeline
|
# In ~/.bashrc, ~/.zshrc, or CI pipeline
|
||||||
export SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json"
|
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
|
#### 4. Verify Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# List active catalogs
|
||||||
|
specify extension catalogs
|
||||||
|
|
||||||
# Search should now show your catalog's extensions
|
# Search should now show your catalog's extensions
|
||||||
specify extension search
|
specify extension search
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,74 @@
|
|||||||
# Spec Kit Community Extensions
|
# Spec Kit Extensions
|
||||||
|
|
||||||
Community-contributed extensions for [Spec Kit](https://github.com/github/spec-kit).
|
Extension system for [Spec Kit](https://github.com/github/spec-kit) - add new functionality without bloating the core framework.
|
||||||
|
|
||||||
## Available Extensions
|
## 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 |
|
| Extension | Purpose | URL |
|
||||||
|-----------|---------|-----|
|
|-----------|---------|-----|
|
||||||
@@ -11,4 +77,43 @@ Community-contributed extensions for [Spec Kit](https://github.com/github/spec-k
|
|||||||
|
|
||||||
## Adding Your Extension
|
## Adding Your Extension
|
||||||
|
|
||||||
See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for instructions on how to submit your extension to the community catalog.
|
### 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
|
## 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`
|
**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
|
```json
|
||||||
{
|
{
|
||||||
@@ -931,24 +961,110 @@ specify extension info jira
|
|||||||
|
|
||||||
### Custom Catalogs
|
### 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
|
#### Catalog Stack Resolution
|
||||||
# Add custom catalog
|
|
||||||
specify extension add-catalog https://internal.company.com/spec-kit/catalog.json
|
|
||||||
|
|
||||||
# Set as default
|
The active catalog stack is resolved in this order (first match wins):
|
||||||
specify extension set-catalog --default https://internal.company.com/spec-kit/catalog.json
|
|
||||||
|
|
||||||
# List catalogs
|
1. **`SPECKIT_CATALOG_URL` environment variable** — single catalog replacing all defaults (backward compat)
|
||||||
specify extension catalogs
|
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`)
|
#### Catalog CLI Commands
|
||||||
2. User-level catalog (`~/.specify/extension-catalogs.yml`)
|
|
||||||
3. Default GitHub catalog
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.1.6"
|
version = "0.1.7"
|
||||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -351,10 +351,19 @@ create_new_agent_file() {
|
|||||||
# Convert \n sequences to actual newlines
|
# Convert \n sequences to actual newlines
|
||||||
newline=$(printf '\n')
|
newline=$(printf '\n')
|
||||||
sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file"
|
sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file"
|
||||||
|
|
||||||
# Clean up backup files
|
# Clean up backup files
|
||||||
rm -f "$temp_file.bak" "$temp_file.bak2"
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,13 +501,24 @@ update_existing_agent_file() {
|
|||||||
changes_entries_added=true
|
changes_entries_added=true
|
||||||
fi
|
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
|
# Move temp file to target atomically
|
||||||
if ! mv "$temp_file" "$target_file"; then
|
if ! mv "$temp_file" "$target_file"; then
|
||||||
log_error "Failed to update target file"
|
log_error "Failed to update target file"
|
||||||
rm -f "$temp_file"
|
rm -f "$temp_file"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
|
|||||||
@@ -258,6 +258,12 @@ function New-AgentFile {
|
|||||||
# Convert literal \n sequences introduced by Escape to real newlines
|
# Convert literal \n sequences introduced by Escape to real newlines
|
||||||
$content = $content -replace '\\n',[Environment]::NewLine
|
$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
|
$parent = Split-Path -Parent $TargetFile
|
||||||
if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null }
|
if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null }
|
||||||
Set-Content -LiteralPath $TargetFile -Value $content -NoNewline -Encoding utf8
|
Set-Content -LiteralPath $TargetFile -Value $content -NoNewline -Encoding utf8
|
||||||
@@ -334,6 +340,12 @@ function Update-ExistingAgentFile {
|
|||||||
$newTechEntries | ForEach-Object { $output.Add($_) }
|
$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
|
Set-Content -LiteralPath $TargetFile -Value ($output -join [Environment]::NewLine) -Encoding utf8
|
||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1720,6 +1720,13 @@ extension_app = typer.Typer(
|
|||||||
)
|
)
|
||||||
app.add_typer(extension_app, name="extension")
|
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:
|
def get_speckit_version() -> str:
|
||||||
"""Get current spec-kit version."""
|
"""Get current spec-kit version."""
|
||||||
@@ -1785,6 +1792,157 @@ def extension_list(
|
|||||||
console.print(" [cyan]specify extension add <name>[/cyan]")
|
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")
|
@extension_app.command("add")
|
||||||
def extension_add(
|
def extension_add(
|
||||||
extension: str = typer.Argument(help="Extension name or path"),
|
extension: str = typer.Argument(help="Extension name or path"),
|
||||||
@@ -1873,6 +2031,19 @@ def extension_add(
|
|||||||
console.print(" specify extension search")
|
console.print(" specify extension search")
|
||||||
raise typer.Exit(1)
|
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
|
# Download extension ZIP
|
||||||
console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...")
|
console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...")
|
||||||
zip_path = catalog.download_extension(extension)
|
zip_path = catalog.download_extension(extension)
|
||||||
@@ -2017,6 +2188,15 @@ def extension_search(
|
|||||||
tags_str = ", ".join(ext['tags'])
|
tags_str = ", ".join(ext['tags'])
|
||||||
console.print(f" [dim]Tags:[/dim] {tags_str}")
|
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
|
||||||
stats = []
|
stats = []
|
||||||
if ext.get('downloads') is not None:
|
if ext.get('downloads') is not None:
|
||||||
@@ -2030,8 +2210,15 @@ def extension_search(
|
|||||||
if ext.get('repository'):
|
if ext.get('repository'):
|
||||||
console.print(f" [dim]Repository:[/dim] {ext['repository']}")
|
console.print(f" [dim]Repository:[/dim] {ext['repository']}")
|
||||||
|
|
||||||
# Install command
|
# Install command (show warning if not installable)
|
||||||
console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}")
|
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()
|
console.print()
|
||||||
|
|
||||||
except ExtensionError as e:
|
except ExtensionError as e:
|
||||||
@@ -2080,6 +2267,12 @@ def extension_info(
|
|||||||
# Author and License
|
# Author and License
|
||||||
console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}")
|
console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}")
|
||||||
console.print(f"[dim]License:[/dim] {ext_info.get('license', '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()
|
console.print()
|
||||||
|
|
||||||
# Requirements
|
# Requirements
|
||||||
@@ -2136,12 +2329,21 @@ def extension_info(
|
|||||||
|
|
||||||
# Installation status and command
|
# Installation status and command
|
||||||
is_installed = manager.registry.is_installed(ext_info['id'])
|
is_installed = manager.registry.is_installed(ext_info['id'])
|
||||||
|
install_allowed = ext_info.get("_install_allowed", True)
|
||||||
if is_installed:
|
if is_installed:
|
||||||
console.print("[green]✓ Installed[/green]")
|
console.print("[green]✓ Installed[/green]")
|
||||||
console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
|
console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
|
||||||
else:
|
elif install_allowed:
|
||||||
console.print("[yellow]Not installed[/yellow]")
|
console.print("[yellow]Not installed[/yellow]")
|
||||||
console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}")
|
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:
|
except ExtensionError as e:
|
||||||
console.print(f"\n[red]Error:[/red] {e}")
|
console.print(f"\n[red]Error:[/red] {e}")
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ without bloating the core framework.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
import shutil
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, List, Any
|
from typing import Optional, Dict, List, Any
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -36,6 +38,15 @@ class CompatibilityError(ExtensionError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CatalogEntry:
|
||||||
|
"""Represents a single catalog entry in the catalog stack."""
|
||||||
|
url: str
|
||||||
|
name: str
|
||||||
|
priority: int
|
||||||
|
install_allowed: bool
|
||||||
|
|
||||||
|
|
||||||
class ExtensionManifest:
|
class ExtensionManifest:
|
||||||
"""Represents and validates an extension manifest (extension.yml)."""
|
"""Represents and validates an extension manifest (extension.yml)."""
|
||||||
|
|
||||||
@@ -940,6 +951,7 @@ class ExtensionCatalog:
|
|||||||
"""Manages extension catalog fetching, caching, and searching."""
|
"""Manages extension catalog fetching, caching, and searching."""
|
||||||
|
|
||||||
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
|
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
|
CACHE_DURATION = 3600 # 1 hour in seconds
|
||||||
|
|
||||||
def __init__(self, project_root: Path):
|
def __init__(self, project_root: Path):
|
||||||
@@ -954,43 +966,82 @@ class ExtensionCatalog:
|
|||||||
self.cache_file = self.cache_dir / "catalog.json"
|
self.cache_file = self.cache_dir / "catalog.json"
|
||||||
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
|
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
|
||||||
|
|
||||||
def get_catalog_url(self) -> str:
|
def _validate_catalog_url(self, url: str) -> None:
|
||||||
"""Get catalog URL from config or use default.
|
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).
|
||||||
|
|
||||||
Checks in order:
|
Args:
|
||||||
1. SPECKIT_CATALOG_URL environment variable
|
url: URL to validate
|
||||||
2. Default catalog URL
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
URL to fetch catalog from
|
|
||||||
|
|
||||||
Raises:
|
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
|
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"):
|
if env_value := os.environ.get("SPECKIT_CATALOG_URL"):
|
||||||
catalog_url = env_value.strip()
|
catalog_url = env_value.strip()
|
||||||
parsed = urlparse(catalog_url)
|
self._validate_catalog_url(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)
|
|
||||||
if catalog_url != self.DEFAULT_CATALOG_URL:
|
if catalog_url != self.DEFAULT_CATALOG_URL:
|
||||||
if not getattr(self, "_non_default_catalog_warning_shown", False):
|
if not getattr(self, "_non_default_catalog_warning_shown", False):
|
||||||
print(
|
print(
|
||||||
@@ -999,11 +1050,161 @@ class ExtensionCatalog:
|
|||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
self._non_default_catalog_warning_shown = True
|
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
|
# 3. User-level config
|
||||||
return self.DEFAULT_CATALOG_URL
|
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:
|
def is_cache_valid(self) -> bool:
|
||||||
"""Check if cached catalog is still valid.
|
"""Check if cached catalog is still valid.
|
||||||
@@ -1080,7 +1281,7 @@ class ExtensionCatalog:
|
|||||||
author: Optional[str] = None,
|
author: Optional[str] = None,
|
||||||
verified_only: bool = False,
|
verified_only: bool = False,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Search catalog for extensions.
|
"""Search catalog for extensions across all active catalogs.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search query (searches name, description, tags)
|
query: Search query (searches name, description, tags)
|
||||||
@@ -1089,14 +1290,16 @@ class ExtensionCatalog:
|
|||||||
verified_only: If True, show only verified extensions
|
verified_only: If True, show only verified extensions
|
||||||
|
|
||||||
Returns:
|
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()
|
all_extensions = self._get_merged_extensions()
|
||||||
extensions = catalog.get("extensions", {})
|
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
for ext_id, ext_data in extensions.items():
|
for ext_data in all_extensions:
|
||||||
|
ext_id = ext_data["id"]
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
if verified_only and not ext_data.get("verified", False):
|
if verified_only and not ext_data.get("verified", False):
|
||||||
continue
|
continue
|
||||||
@@ -1122,25 +1325,26 @@ class ExtensionCatalog:
|
|||||||
if query_lower not in searchable_text:
|
if query_lower not in searchable_text:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
results.append({"id": ext_id, **ext_data})
|
results.append(ext_data)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def get_extension_info(self, extension_id: str) -> Optional[Dict[str, Any]]:
|
def get_extension_info(self, extension_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get detailed information about a specific extension.
|
"""Get detailed information about a specific extension.
|
||||||
|
|
||||||
|
Searches all active catalogs in priority order.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
extension_id: ID of the extension
|
extension_id: ID of the extension
|
||||||
|
|
||||||
Returns:
|
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()
|
all_extensions = self._get_merged_extensions()
|
||||||
extensions = catalog.get("extensions", {})
|
for ext_data in all_extensions:
|
||||||
|
if ext_data["id"] == extension_id:
|
||||||
if extension_id in extensions:
|
return ext_data
|
||||||
return {"id": extension_id, **extensions[extension_id]}
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def download_extension(self, extension_id: str, target_dir: Optional[Path] = None) -> Path:
|
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}")
|
raise ExtensionError(f"Failed to save extension ZIP: {e}")
|
||||||
|
|
||||||
def clear_cache(self):
|
def clear_cache(self):
|
||||||
"""Clear the catalog cache."""
|
"""Clear the catalog cache (both legacy and URL-hash-based files)."""
|
||||||
if self.cache_file.exists():
|
if self.cache_file.exists():
|
||||||
self.cache_file.unlink()
|
self.cache_file.unlink()
|
||||||
if self.cache_metadata_file.exists():
|
if self.cache_metadata_file.exists():
|
||||||
self.cache_metadata_file.unlink()
|
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:
|
class ConfigManager:
|
||||||
|
|||||||
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 registry operations
|
||||||
- Extension manager installation/removal
|
- Extension manager installation/removal
|
||||||
- Command registration
|
- Command registration
|
||||||
|
- Catalog stack (multi-catalog support)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -16,6 +17,7 @@ from pathlib import Path
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from specify_cli.extensions import (
|
from specify_cli.extensions import (
|
||||||
|
CatalogEntry,
|
||||||
ExtensionManifest,
|
ExtensionManifest,
|
||||||
ExtensionRegistry,
|
ExtensionRegistry,
|
||||||
ExtensionManager,
|
ExtensionManager,
|
||||||
@@ -734,10 +736,29 @@ class TestExtensionCatalog:
|
|||||||
|
|
||||||
def test_search_all_extensions(self, temp_dir):
|
def test_search_all_extensions(self, temp_dir):
|
||||||
"""Test searching all extensions without filters."""
|
"""Test searching all extensions without filters."""
|
||||||
|
import yaml as yaml_module
|
||||||
|
|
||||||
project_dir = temp_dir / "project"
|
project_dir = temp_dir / "project"
|
||||||
project_dir.mkdir()
|
project_dir.mkdir()
|
||||||
(project_dir / ".specify").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)
|
catalog = ExtensionCatalog(project_dir)
|
||||||
|
|
||||||
# Create mock catalog
|
# Create mock catalog
|
||||||
@@ -987,3 +1008,373 @@ class TestExtensionCatalog:
|
|||||||
|
|
||||||
assert not catalog.cache_file.exists()
|
assert not catalog.cache_file.exists()
|
||||||
assert not catalog.cache_metadata_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