From f2ad27420a120e6273f2ac2385eb32a3cf7624ce Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Tue, 3 Feb 2026 16:03:42 +0100 Subject: [PATCH 1/8] Add modular extension system for Spec Kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a complete extension system that allows third-party developers to extend Spec Kit functionality through plugins. ## Core Features - Extension discovery and loading from local and global directories - YAML-based extension manifest (extension.yml) with metadata and capabilities - Command extensions: custom slash commands with markdown templates - Hook system: pre/post hooks for generate, task, and sync operations - Extension catalog for discovering and installing community extensions - SPECKIT_CATALOG_URL environment variable for catalog URL override ## Installation Methods - Catalog install: `specify extension add ` - URL install: `specify extension add --from ` - Dev install: `specify extension add --dev ` ## Implementation - ExtensionManager class for lifecycle management (load, enable, disable) - Support for extension dependencies and version constraints - Configuration layering (global → project → extension) - Hook conditions for conditional execution ## Documentation - RFC with design rationale and architecture decisions - API reference for extension developers - Development guide with examples - User guide for installing and managing extensions - Publishing guide for the extension catalog ## Included - Extension template for bootstrapping new extensions - Comprehensive test suite - Example catalog.json structure Co-Authored-By: Claude Opus 4.5 --- .gitignore | 10 + CHANGELOG.md | 114 ++ extensions/EXTENSION-API-REFERENCE.md | 714 ++++++++ extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 653 ++++++++ extensions/EXTENSION-PUBLISHING-GUIDE.md | 530 ++++++ extensions/EXTENSION-USER-GUIDE.md | 676 ++++++++ extensions/RFC-EXTENSION-SYSTEM.md | 1794 +++++++++++++++++++++ extensions/catalog.json | 45 + extensions/template/.gitignore | 39 + extensions/template/CHANGELOG.md | 39 + extensions/template/EXAMPLE-README.md | 158 ++ extensions/template/LICENSE | 21 + extensions/template/README.md | 79 + extensions/template/commands/example.md | 210 +++ extensions/template/config-template.yml | 75 + extensions/template/extension.yml | 97 ++ pyproject.toml | 31 +- src/specify_cli/__init__.py | 620 +++++++ src/specify_cli/extensions.py | 1714 ++++++++++++++++++++ tests/__init__.py | 1 + tests/test_extensions.py | 984 +++++++++++ 21 files changed, 8603 insertions(+), 1 deletion(-) create mode 100644 extensions/EXTENSION-API-REFERENCE.md create mode 100644 extensions/EXTENSION-DEVELOPMENT-GUIDE.md create mode 100644 extensions/EXTENSION-PUBLISHING-GUIDE.md create mode 100644 extensions/EXTENSION-USER-GUIDE.md create mode 100644 extensions/RFC-EXTENSION-SYSTEM.md create mode 100644 extensions/catalog.json create mode 100644 extensions/template/.gitignore create mode 100644 extensions/template/CHANGELOG.md create mode 100644 extensions/template/EXAMPLE-README.md create mode 100644 extensions/template/LICENSE create mode 100644 extensions/template/README.md create mode 100644 extensions/template/commands/example.md create mode 100644 extensions/template/config-template.yml create mode 100644 extensions/template/extension.yml create mode 100644 src/specify_cli/extensions.py create mode 100644 tests/__init__.py create mode 100644 tests/test_extensions.py diff --git a/.gitignore b/.gitignore index 1ed573622d..d5d176a193 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,13 @@ env/ .genreleases/ *.zip sdd-*/ + +# Extension system +.specify/extensions/.cache/ +.specify/extensions/.backup/ +.specify/extensions/*/local-config.yml + +# Test coverage +.coverage +htmlcov/ +.pytest_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e2ac3697f..174b429cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,120 @@ All notable changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.0] - 2026-01-28 + +### Added + +- **Extension System**: Introduced modular extension architecture for Spec Kit + - Extensions are self-contained packages that add commands and functionality without bloating core + - Extension manifest schema (`extension.yml`) with validation + - Extension registry (`.specify/extensions/.registry`) for tracking installed extensions + - Extension manager module (`src/specify_cli/extensions.py`) for installation/removal + - New CLI commands: + - `specify extension list` - List installed extensions + - `specify extension add` - Install extension from local directory or URL + - `specify extension remove` - Uninstall extension + - `specify extension search` - Search extension catalog + - `specify extension info` - Show detailed extension information + - Semantic versioning compatibility checks + - Support for extension configuration files + - Command registration system for AI agents (Claude support initially) + - Added dependencies: `pyyaml>=6.0`, `packaging>=23.0` + +- **Extension Catalog**: Extension discovery and distribution system + - Central catalog (`extensions/catalog.json`) for published extensions + - Extension catalog manager (`ExtensionCatalog` class) with: + - Catalog fetching from GitHub + - 1-hour local caching for performance + - Search by query, tag, author, or verification status + - Extension info retrieval + - Catalog cache stored in `.specify/extensions/.cache/` + - Search and info commands with rich console output + - Added 9 catalog-specific unit tests (100% pass rate) + +- **Jira Extension**: First official extension for Jira integration + - Extension ID: `jira` + - Version: 1.0.0 + - Commands: + - `/speckit.jira.specstoissues` - Create Jira hierarchy from spec and tasks + - `/speckit.jira.discover-fields` - Discover Jira custom fields + - `/speckit.jira.sync-status` - Sync task completion status + - Comprehensive documentation (README, usage guide, examples) + - MIT licensed + +- **Hook System**: Extension lifecycle hooks for automation + - `HookExecutor` class for managing extension hooks + - Hooks registered in `.specify/extensions.yml` + - Hook registration during extension installation + - Hook unregistration during extension removal + - Support for optional and mandatory hooks + - Hook execution messages for AI agent integration + - Condition support for conditional hook execution (placeholder) + +- **Extension Management**: Advanced extension management commands + - `specify extension update` - Check and update extensions to latest version + - `specify extension enable` - Enable a disabled extension + - `specify extension disable` - Disable extension without removing it + - Version comparison with catalog + - Update notifications + - Preserve configuration during updates + +- **Multi-Agent Support**: Extensions now work with all supported AI agents (Phase 6) + - Automatic detection and registration for all agents in project + - Support for 16+ AI agents (Claude, Gemini, Copilot, Cursor, Qwen, and more) + - Agent-specific command formats (Markdown and TOML) + - Automatic argument placeholder conversion ($ARGUMENTS → {{args}}) + - Commands registered for all detected agents during installation + - Multi-agent command unregistration on extension removal + - `CommandRegistrar.register_commands_for_agent()` method + - `CommandRegistrar.register_commands_for_all_agents()` method + +- **Configuration Layers**: Full configuration cascade system (Phase 6) + - **Layer 1**: Defaults from extension manifest (`extension.yml`) + - **Layer 2**: Project config (`.specify/extensions/{ext-id}/{ext-id}-config.yml`) + - **Layer 3**: Local config (`.specify/extensions/{ext-id}/local-config.yml`, gitignored) + - **Layer 4**: Environment variables (`SPECKIT_{EXT_ID}_{KEY}` pattern) + - Recursive config merging with proper precedence + - `ConfigManager` class for programmatic config access + - `get_config()`, `get_value()`, `has_value()` methods + - Support for nested configuration paths with dot-notation + +- **Hook Condition Evaluation**: Smart hook execution based on runtime conditions (Phase 6) + - Config conditions: `config.key.path is set`, `config.key == 'value'`, `config.key != 'value'` + - Environment conditions: `env.VAR is set`, `env.VAR == 'value'`, `env.VAR != 'value'` + - Automatic filtering of hooks based on condition evaluation + - Safe fallback behavior on evaluation errors + - Case-insensitive pattern matching + +- **Hook Integration**: Agent-level hook checking and execution (Phase 6) + - `check_hooks_for_event()` method for AI agents to query hooks after core commands + - Condition-aware hook filtering before execution + - `enable_hooks()` and `disable_hooks()` methods per extension + - Formatted hook messages for agent display + - `execute_hook()` method for hook execution information + +- **Documentation Suite**: Comprehensive documentation for users and developers + - **EXTENSION-USER-GUIDE.md**: Complete user guide with installation, usage, configuration, and troubleshooting + - **EXTENSION-API-REFERENCE.md**: Technical API reference with manifest schema, Python API, and CLI commands + - **EXTENSION-PUBLISHING-GUIDE.md**: Publishing guide for extension authors + - **RFC-EXTENSION-SYSTEM.md**: Extension architecture design document + +- **Extension Template**: Starter template in `extensions/template/` for creating new extensions + - Fully commented `extension.yml` manifest template + - Example command file with detailed explanations + - Configuration template with all options + - Complete project structure (README, LICENSE, CHANGELOG, .gitignore) + - EXAMPLE-README.md showing final documentation format + +- **Unit Tests**: Comprehensive test suite with 39 tests covering all extension system components + - Test coverage: 83% of extension module code + - Test dependencies: `pytest>=7.0`, `pytest-cov>=4.0` + - Configured pytest in `pyproject.toml` + +### Changed + +- Version bumped to 0.1.0 (minor release for new feature) + ## [0.0.22] - 2025-11-07 - Support for VS Code/Copilot agents, and moving away from prompts to proper agents with hand-offs. diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md new file mode 100644 index 0000000000..3e7cc48157 --- /dev/null +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -0,0 +1,714 @@ +# Extension API Reference + +Technical reference for Spec Kit extension system APIs and manifest schema. + +## Table of Contents + +1. [Extension Manifest](#extension-manifest) +2. [Python API](#python-api) +3. [Command File Format](#command-file-format) +4. [Configuration Schema](#configuration-schema) +5. [Hook System](#hook-system) +6. [CLI Commands](#cli-commands) + +--- + +## Extension Manifest + +### Schema Version 1.0 + +File: `extension.yml` + +```yaml +schema_version: "1.0" # Required + +extension: + id: string # Required, pattern: ^[a-z0-9-]+$ + name: string # Required, human-readable name + version: string # Required, semantic version (X.Y.Z) + description: string # Required, brief description (<200 chars) + author: string # Required + repository: string # Required, valid URL + license: string # Required (e.g., "MIT", "Apache-2.0") + homepage: string # Optional, valid URL + +requires: + speckit_version: string # Required, version specifier (>=X.Y.Z) + tools: # Optional, array of tool requirements + - name: string # Tool name + version: string # Optional, version specifier + required: boolean # Optional, default: false + +provides: + commands: # Required, at least one command + - name: string # Required, pattern: ^speckit\.[a-z0-9-]+\.[a-z0-9-]+$ + file: string # Required, relative path to command file + description: string # Required + aliases: [string] # Optional, array of alternate names + + config: # Optional, array of config files + - name: string # Config file name + template: string # Template file path + description: string + required: boolean # Default: false + +hooks: # Optional, event hooks + event_name: # e.g., "after_tasks", "after_implement" + command: string # Command to execute + optional: boolean # Default: true + prompt: string # Prompt text for optional hooks + description: string # Hook description + condition: string # Optional, condition expression + +tags: # Optional, array of tags (2-10 recommended) + - string + +defaults: # Optional, default configuration values + key: value # Any YAML structure +``` + +### Field Specifications + +#### `extension.id` + +- **Type**: string +- **Pattern**: `^[a-z0-9-]+$` +- **Description**: Unique extension identifier +- **Examples**: `jira`, `linear`, `azure-devops` +- **Invalid**: `Jira`, `my_extension`, `extension.id` + +#### `extension.version` + +- **Type**: string +- **Format**: Semantic versioning (X.Y.Z) +- **Description**: Extension version +- **Examples**: `1.0.0`, `0.9.5`, `2.1.3` +- **Invalid**: `v1.0`, `1.0`, `1.0.0-beta` + +#### `requires.speckit_version` + +- **Type**: string +- **Format**: Version specifier +- **Description**: Required spec-kit version range +- **Examples**: + - `>=0.1.0` - Any version 0.1.0 or higher + - `>=0.1.0,<2.0.0` - Version 0.1.x or 1.x + - `==0.1.0` - Exactly 0.1.0 +- **Invalid**: `0.1.0`, `>= 0.1.0` (space), `latest` + +#### `provides.commands[].name` + +- **Type**: string +- **Pattern**: `^speckit\.[a-z0-9-]+\.[a-z0-9-]+$` +- **Description**: Namespaced command name +- **Format**: `speckit.{extension-id}.{command-name}` +- **Examples**: `speckit.jira.specstoissues`, `speckit.linear.sync` +- **Invalid**: `jira.specstoissues`, `speckit.command`, `speckit.jira.CreateIssues` + +#### `hooks` + +- **Type**: object +- **Keys**: Event names (e.g., `after_tasks`, `after_implement`, `before_commit`) +- **Description**: Hooks that execute at lifecycle events +- **Events**: Defined by core spec-kit commands + +--- + +## Python API + +### ExtensionManifest + +**Module**: `specify_cli.extensions` + +```python +from specify_cli.extensions import ExtensionManifest + +manifest = ExtensionManifest(Path("extension.yml")) +``` + +**Properties**: + +```python +manifest.id # str: Extension ID +manifest.name # str: Extension name +manifest.version # str: Version +manifest.description # str: Description +manifest.requires_speckit_version # str: Required spec-kit version +manifest.commands # List[Dict]: Command definitions +manifest.hooks # Dict: Hook definitions +``` + +**Methods**: + +```python +manifest.get_hash() # str: SHA256 hash of manifest file +``` + +**Exceptions**: + +```python +ValidationError # Invalid manifest structure +CompatibilityError # Incompatible with current spec-kit version +``` + +### ExtensionRegistry + +**Module**: `specify_cli.extensions` + +```python +from specify_cli.extensions import ExtensionRegistry + +registry = ExtensionRegistry(extensions_dir) +``` + +**Methods**: + +```python +# Add extension to registry +registry.add(extension_id: str, metadata: dict) + +# Remove extension from registry +registry.remove(extension_id: str) + +# Get extension metadata +metadata = registry.get(extension_id: str) # Optional[dict] + +# List all extensions +extensions = registry.list() # Dict[str, dict] + +# Check if installed +is_installed = registry.is_installed(extension_id: str) # bool +``` + +**Registry Format**: + +```json +{ + "schema_version": "1.0", + "extensions": { + "jira": { + "version": "1.0.0", + "source": "catalog", + "manifest_hash": "sha256...", + "enabled": true, + "registered_commands": ["speckit.jira.specstoissues", ...], + "installed_at": "2026-01-28T..." + } + } +} +``` + +### ExtensionManager + +**Module**: `specify_cli.extensions` + +```python +from specify_cli.extensions import ExtensionManager + +manager = ExtensionManager(project_root) +``` + +**Methods**: + +```python +# Install from directory +manifest = manager.install_from_directory( + source_dir: Path, + speckit_version: str, + register_commands: bool = True +) # Returns: ExtensionManifest + +# Install from ZIP +manifest = manager.install_from_zip( + zip_path: Path, + speckit_version: str +) # Returns: ExtensionManifest + +# Remove extension +success = manager.remove( + extension_id: str, + keep_config: bool = False +) # Returns: bool + +# List installed extensions +extensions = manager.list_installed() # List[Dict] + +# Get extension manifest +manifest = manager.get_extension(extension_id: str) # Optional[ExtensionManifest] + +# Check compatibility +manager.check_compatibility( + manifest: ExtensionManifest, + speckit_version: str +) # Raises: CompatibilityError if incompatible +``` + +### ExtensionCatalog + +**Module**: `specify_cli.extensions` + +```python +from specify_cli.extensions import ExtensionCatalog + +catalog = ExtensionCatalog(project_root) +``` + +**Methods**: + +```python +# Fetch catalog +catalog_data = catalog.fetch_catalog(force_refresh: bool = False) # Dict + +# Search extensions +results = catalog.search( + query: Optional[str] = None, + tag: Optional[str] = None, + author: Optional[str] = None, + verified_only: bool = False +) # Returns: List[Dict] + +# Get extension info +ext_info = catalog.get_extension_info(extension_id: str) # Optional[Dict] + +# Check cache validity +is_valid = catalog.is_cache_valid() # bool + +# Clear cache +catalog.clear_cache() +``` + +### HookExecutor + +**Module**: `specify_cli.extensions` + +```python +from specify_cli.extensions import HookExecutor + +hook_executor = HookExecutor(project_root) +``` + +**Methods**: + +```python +# Get project config +config = hook_executor.get_project_config() # Dict + +# Save project config +hook_executor.save_project_config(config: Dict) + +# Register hooks +hook_executor.register_hooks(manifest: ExtensionManifest) + +# Unregister hooks +hook_executor.unregister_hooks(extension_id: str) + +# Get hooks for event +hooks = hook_executor.get_hooks_for_event(event_name: str) # List[Dict] + +# Check if hook should execute +should_run = hook_executor.should_execute_hook(hook: Dict) # bool + +# Format hook message +message = hook_executor.format_hook_message( + event_name: str, + hooks: List[Dict] +) # str +``` + +### CommandRegistrar + +**Module**: `specify_cli.extensions` + +```python +from specify_cli.extensions import CommandRegistrar + +registrar = CommandRegistrar() +``` + +**Methods**: + +```python +# Register commands for Claude Code +registered = registrar.register_commands_for_claude( + manifest: ExtensionManifest, + extension_dir: Path, + project_root: Path +) # Returns: List[str] (command names) + +# Parse frontmatter +frontmatter, body = registrar.parse_frontmatter(content: str) + +# Render frontmatter +yaml_text = registrar.render_frontmatter(frontmatter: Dict) # str +``` + +--- + +## Command File Format + +### Universal Command Format + +**File**: `commands/{command-name}.md` + +```markdown +--- +description: "Command description" +tools: + - 'mcp-server/tool_name' + - 'other-mcp-server/other_tool' +--- + +# Command Title + +Command documentation in Markdown. + +## Prerequisites + +1. Requirement 1 +2. Requirement 2 + +## User Input + +$ARGUMENTS + +## Steps + +### Step 1: Description + +Instruction text... + +\`\`\`bash +# Shell commands +\`\`\` + +### Step 2: Another Step + +More instructions... + +## Configuration Reference + +Information about configuration options. + +## Notes + +Additional notes and tips. +``` + +### Frontmatter Fields + +```yaml +description: string # Required, brief command description +tools: [string] # Optional, MCP tools required +``` + +### Special Variables + +- `$ARGUMENTS` - Placeholder for user-provided arguments +- Extension context automatically injected: + + ```markdown + + + ``` + +--- + +## Configuration Schema + +### Extension Config File + +**File**: `.specify/extensions/{extension-id}/{extension-id}-config.yml` + +Extensions define their own config schema. Common patterns: + +```yaml +# Connection settings +connection: + url: string + api_key: string + +# Project settings +project: + key: string + workspace: string + +# Feature flags +features: + enabled: boolean + auto_sync: boolean + +# Defaults +defaults: + labels: [string] + assignee: string + +# Custom fields +field_mappings: + internal_name: "external_field_id" +``` + +### Config Layers + +1. **Extension Defaults** (from `extension.yml` `defaults` section) +2. **Project Config** (`{extension-id}-config.yml`) +3. **Local Override** (`{extension-id}-config.local.yml`, gitignored) +4. **Environment Variables** (`SPECKIT_{EXTENSION}_*`) + +### Environment Variable Pattern + +Format: `SPECKIT_{EXTENSION}_{KEY}` + +Examples: + +- `SPECKIT_JIRA_PROJECT_KEY` +- `SPECKIT_LINEAR_API_KEY` +- `SPECKIT_GITHUB_TOKEN` + +--- + +## Hook System + +### Hook Definition + +**In extension.yml**: + +```yaml +hooks: + after_tasks: + command: "speckit.jira.specstoissues" + optional: true + prompt: "Create Jira issues from tasks?" + description: "Automatically create Jira hierarchy" + condition: null +``` + +### Hook Events + +Standard events (defined by core): + +- `after_tasks` - After task generation +- `after_implement` - After implementation +- `before_commit` - Before git commit +- `after_commit` - After git commit + +### Hook Configuration + +**In `.specify/extensions.yml`**: + +```yaml +hooks: + after_tasks: + - extension: jira + command: speckit.jira.specstoissues + enabled: true + optional: true + prompt: "Create Jira issues from tasks?" + description: "..." + condition: null +``` + +### Hook Message Format + +```markdown +## Extension Hooks + +**Optional Hook**: {extension} +Command: `/{command}` +Description: {description} + +Prompt: {prompt} +To execute: `/{command}` +``` + +Or for mandatory hooks: + +```markdown +**Automatic Hook**: {extension} +Executing: `/{command}` +EXECUTE_COMMAND: {command} +``` + +--- + +## CLI Commands + +### extension list + +**Usage**: `specify extension list [OPTIONS]` + +**Options**: + +- `--available` - Show available extensions from catalog +- `--all` - Show both installed and available + +**Output**: List of installed extensions with metadata + +### extension add + +**Usage**: `specify extension add EXTENSION [OPTIONS]` + +**Options**: + +- `--from URL` - Install from custom URL +- `--dev PATH` - Install from local directory +- `--version VERSION` - Install specific version +- `--no-register` - Skip command registration + +**Arguments**: + +- `EXTENSION` - Extension name or URL + +### extension remove + +**Usage**: `specify extension remove EXTENSION [OPTIONS]` + +**Options**: + +- `--keep-config` - Preserve config files +- `--force` - Skip confirmation + +**Arguments**: + +- `EXTENSION` - Extension ID + +### extension search + +**Usage**: `specify extension search [QUERY] [OPTIONS]` + +**Options**: + +- `--tag TAG` - Filter by tag +- `--author AUTHOR` - Filter by author +- `--verified` - Show only verified extensions + +**Arguments**: + +- `QUERY` - Optional search query + +### extension info + +**Usage**: `specify extension info EXTENSION` + +**Arguments**: + +- `EXTENSION` - Extension ID + +### extension update + +**Usage**: `specify extension update [EXTENSION]` + +**Arguments**: + +- `EXTENSION` - Optional, extension ID (default: all) + +### extension enable + +**Usage**: `specify extension enable EXTENSION` + +**Arguments**: + +- `EXTENSION` - Extension ID + +### extension disable + +**Usage**: `specify extension disable EXTENSION` + +**Arguments**: + +- `EXTENSION` - Extension ID + +--- + +## Exceptions + +### ValidationError + +Raised when extension manifest validation fails. + +```python +from specify_cli.extensions import ValidationError + +try: + manifest = ExtensionManifest(path) +except ValidationError as e: + print(f"Invalid manifest: {e}") +``` + +### CompatibilityError + +Raised when extension is incompatible with current spec-kit version. + +```python +from specify_cli.extensions import CompatibilityError + +try: + manager.check_compatibility(manifest, "0.1.0") +except CompatibilityError as e: + print(f"Incompatible: {e}") +``` + +### ExtensionError + +Base exception for all extension-related errors. + +```python +from specify_cli.extensions import ExtensionError + +try: + manager.install_from_directory(path, "0.1.0") +except ExtensionError as e: + print(f"Extension error: {e}") +``` + +--- + +## Version Functions + +### version_satisfies + +Check if a version satisfies a specifier. + +```python +from specify_cli.extensions import version_satisfies + +# True if 1.2.3 satisfies >=1.0.0,<2.0.0 +satisfied = version_satisfies("1.2.3", ">=1.0.0,<2.0.0") # bool +``` + +--- + +## File System Layout + +``` +.specify/ +├── extensions/ +│ ├── .registry # Extension registry (JSON) +│ ├── .cache/ # Catalog cache +│ │ ├── catalog.json +│ │ └── catalog-metadata.json +│ ├── .backup/ # Config backups +│ │ └── {ext}-{config}.yml +│ ├── {extension-id}/ # Extension directory +│ │ ├── extension.yml # Manifest +│ │ ├── {ext}-config.yml # User config +│ │ ├── {ext}-config.local.yml # Local overrides (gitignored) +│ │ ├── {ext}-config.template.yml # Template +│ │ ├── commands/ # Command files +│ │ │ └── *.md +│ │ ├── scripts/ # Helper scripts +│ │ │ └── *.sh +│ │ ├── docs/ # Documentation +│ │ └── README.md +│ └── extensions.yml # Project extension config +└── scripts/ # (existing spec-kit) + +.claude/ +└── commands/ + └── speckit.{ext}.{cmd}.md # Registered commands +``` + +--- + +*Last Updated: 2026-01-28* +*API Version: 1.0* +*Spec Kit Version: 0.1.0* diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md new file mode 100644 index 0000000000..3f165404b7 --- /dev/null +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -0,0 +1,653 @@ +# Extension Development Guide + +A guide for creating Spec Kit extensions. + +--- + +## Quick Start + +### 1. Create Extension Directory + +```bash +mkdir my-extension +cd my-extension +``` + +### 2. Create `extension.yml` Manifest + +```yaml +schema_version: "1.0" + +extension: + id: "my-ext" # Lowercase, alphanumeric + hyphens only + name: "My Extension" + version: "1.0.0" # Semantic versioning + description: "My custom extension" + author: "Your Name" + repository: "https://github.com/you/spec-kit-my-ext" + license: "MIT" + +requires: + speckit_version: ">=0.1.0" # Minimum spec-kit version + tools: # Optional: External tools required + - name: "my-tool" + required: true + version: ">=1.0.0" + commands: # Optional: Core commands needed + - "speckit.tasks" + +provides: + commands: + - name: "speckit.my-ext.hello" # Must follow pattern: speckit.{ext-id}.{cmd} + file: "commands/hello.md" + description: "Say hello" + aliases: ["speckit.hello"] # Optional aliases + + config: # Optional: Config files + - name: "my-ext-config.yml" + template: "my-ext-config.template.yml" + description: "Extension configuration" + required: false + +hooks: # Optional: Integration hooks + after_tasks: + command: "speckit.my-ext.hello" + optional: true + prompt: "Run hello command?" + +tags: # Optional: For catalog search + - "example" + - "utility" +``` + +### 3. Create Commands Directory + +```bash +mkdir commands +``` + +### 4. Create Command File + +**File**: `commands/hello.md` + +```markdown +--- +description: "Say hello command" +tools: # Optional: AI tools this command uses + - 'some-tool/function' +scripts: # Optional: Helper scripts + sh: ../../scripts/bash/helper.sh + ps: ../../scripts/powershell/helper.ps1 +--- + +# Hello Command + +This command says hello! + +## User Input + +$ARGUMENTS + +## Steps + +1. Greet the user +2. Show extension is working + +```bash +echo "Hello from my extension!" +echo "Arguments: $ARGUMENTS" +``` + +## Extension Configuration + +Load extension config from `.specify/extensions/my-ext/my-ext-config.yml`. + +``` + +### 5. Test Locally + +```bash +cd /path/to/spec-kit-project +specify extension add --dev /path/to/my-extension +``` + +### 6. Verify Installation + +```bash +specify extension list + +# Should show: +# ✓ My Extension (v1.0.0) +# My custom extension +# Commands: 1 | Hooks: 1 | Status: Enabled +``` + +### 7. Test Command + +If using Claude: + +```bash +claude +> /speckit.my-ext.hello world +``` + +The command will be available in `.claude/commands/speckit.my-ext.hello.md`. + +--- + +## Manifest Schema Reference + +### Required Fields + +#### `schema_version` + +Extension manifest schema version. Currently: `"1.0"` + +#### `extension` + +Extension metadata block. + +**Required sub-fields**: + +- `id`: Extension identifier (lowercase, alphanumeric, hyphens) +- `name`: Human-readable name +- `version`: Semantic version (e.g., "1.0.0") +- `description`: Short description + +**Optional sub-fields**: + +- `author`: Extension author +- `repository`: Source code URL +- `license`: SPDX license identifier +- `homepage`: Extension homepage URL + +#### `requires` + +Compatibility requirements. + +**Required sub-fields**: + +- `speckit_version`: Semantic version specifier (e.g., ">=0.1.0,<2.0.0") + +**Optional sub-fields**: + +- `tools`: External tools required (array of tool objects) +- `commands`: Core spec-kit commands needed (array of command names) +- `scripts`: Core scripts required (array of script names) + +#### `provides` + +What the extension provides. + +**Required sub-fields**: + +- `commands`: Array of command objects (must have at least one) + +**Command object**: + +- `name`: Command name (must match `speckit.{ext-id}.{command}`) +- `file`: Path to command file (relative to extension root) +- `description`: Command description (optional) +- `aliases`: Alternative command names (optional, array) + +### Optional Fields + +#### `hooks` + +Integration hooks for automatic execution. + +Available hook points: + +- `after_tasks`: After `/speckit.tasks` completes +- `after_implement`: After `/speckit.implement` completes (future) + +Hook object: + +- `command`: Command to execute (must be in `provides.commands`) +- `optional`: If true, prompt user before executing +- `prompt`: Prompt text for optional hooks +- `description`: Hook description +- `condition`: Execution condition (future) + +#### `tags` + +Array of tags for catalog discovery. + +#### `defaults` + +Default extension configuration values. + +#### `config_schema` + +JSON Schema for validating extension configuration. + +--- + +## Command File Format + +### Frontmatter (YAML) + +```yaml +--- +description: "Command description" # Required +tools: # Optional + - 'tool-name/function' +scripts: # Optional + sh: ../../scripts/bash/helper.sh + ps: ../../scripts/powershell/helper.ps1 +--- +``` + +### Body (Markdown) + +Use standard Markdown with special placeholders: + +- `$ARGUMENTS`: User-provided arguments +- `{SCRIPT}`: Replaced with script path during registration + +**Example**: + +```markdown +## Steps + +1. Parse arguments +2. Execute logic + +```bash +args="$ARGUMENTS" +echo "Running with args: $args" +``` + +``` + +### Script Path Rewriting + +Extension commands use relative paths that get rewritten during registration: + +**In extension**: +```yaml +scripts: + sh: ../../scripts/bash/helper.sh +``` + +**After registration**: + +```yaml +scripts: + sh: .specify/scripts/bash/helper.sh +``` + +This allows scripts to reference core spec-kit scripts. + +--- + +## Configuration Files + +### Config Template + +**File**: `my-ext-config.template.yml` + +```yaml +# My Extension Configuration +# Copy this to my-ext-config.yml and customize + +# Example configuration +api: + endpoint: "https://api.example.com" + timeout: 30 + +features: + feature_a: true + feature_b: false + +credentials: + # DO NOT commit credentials! + # Use environment variables instead + api_key: "${MY_EXT_API_KEY}" +``` + +### Config Loading + +In your command, load config with layered precedence: + +1. Extension defaults (`extension.yml` → `defaults`) +2. Project config (`.specify/extensions/my-ext/my-ext-config.yml`) +3. Local overrides (`.specify/extensions/my-ext/my-ext-config.local.yml` - gitignored) +4. Environment variables (`SPECKIT_MY_EXT_*`) + +**Example loading script**: + +```bash +#!/usr/bin/env bash +EXT_DIR=".specify/extensions/my-ext" + +# Load and merge config +config=$(yq eval '.' "$EXT_DIR/my-ext-config.yml" -o=json) + +# Apply env overrides +if [ -n "${SPECKIT_MY_EXT_API_KEY:-}" ]; then + config=$(echo "$config" | jq ".api.api_key = \"$SPECKIT_MY_EXT_API_KEY\"") +fi + +echo "$config" +``` + +--- + +## Validation Rules + +### Extension ID + +- **Pattern**: `^[a-z0-9-]+$` +- **Valid**: `my-ext`, `tool-123`, `awesome-plugin` +- **Invalid**: `MyExt` (uppercase), `my_ext` (underscore), `my ext` (space) + +### Extension Version + +- **Format**: Semantic versioning (MAJOR.MINOR.PATCH) +- **Valid**: `1.0.0`, `0.1.0`, `2.5.3` +- **Invalid**: `1.0`, `v1.0.0`, `1.0.0-beta` + +### Command Name + +- **Pattern**: `^speckit\.[a-z0-9-]+\.[a-z0-9-]+$` +- **Valid**: `speckit.my-ext.hello`, `speckit.tool.cmd` +- **Invalid**: `my-ext.hello` (missing prefix), `speckit.hello` (no extension namespace) + +### Command File Path + +- **Must be** relative to extension root +- **Valid**: `commands/hello.md`, `commands/subdir/cmd.md` +- **Invalid**: `/absolute/path.md`, `../outside.md` + +--- + +## Testing Extensions + +### Manual Testing + +1. **Create test extension** +2. **Install locally**: + + ```bash + specify extension add --dev /path/to/extension + ``` + +3. **Verify installation**: + + ```bash + specify extension list + ``` + +4. **Test commands** with your AI agent +5. **Check command registration**: + + ```bash + ls .claude/commands/speckit.my-ext.* + ``` + +6. **Remove extension**: + + ```bash + specify extension remove my-ext + ``` + +### Automated Testing + +Create tests for your extension: + +```python +# tests/test_my_extension.py +import pytest +from pathlib import Path +from specify_cli.extensions import ExtensionManifest + +def test_manifest_valid(): + """Test extension manifest is valid.""" + manifest = ExtensionManifest(Path("extension.yml")) + assert manifest.id == "my-ext" + assert len(manifest.commands) >= 1 + +def test_command_files_exist(): + """Test all command files exist.""" + manifest = ExtensionManifest(Path("extension.yml")) + for cmd in manifest.commands: + cmd_file = Path(cmd["file"]) + assert cmd_file.exists(), f"Command file not found: {cmd_file}" +``` + +--- + +## Distribution + +### Option 1: GitHub Repository + +1. **Create repository**: `spec-kit-my-ext` +2. **Add files**: + + ``` + spec-kit-my-ext/ + ├── extension.yml + ├── commands/ + ├── scripts/ + ├── docs/ + ├── README.md + ├── LICENSE + └── CHANGELOG.md + ``` + +3. **Create release**: Tag with version (e.g., `v1.0.0`) +4. **Install from repo**: + + ```bash + git clone https://github.com/you/spec-kit-my-ext + specify extension add --dev spec-kit-my-ext/ + ``` + +### Option 2: ZIP Archive (Future) + +Create ZIP archive and host on GitHub Releases: + +```bash +zip -r spec-kit-my-ext-1.0.0.zip extension.yml commands/ scripts/ docs/ +``` + +Users install with: + +```bash +specify extension add --from https://github.com/.../spec-kit-my-ext-1.0.0.zip +``` + +### Option 3: Extension Catalog (Future) + +Submit to official catalog: + +1. **Fork** spec-kit repository +2. **Add entry** to `extensions/catalog.json` +3. **Create PR** +4. **After merge**, users can install with: + + ```bash + specify extension add my-ext # No URL needed! + ``` + +--- + +## Best Practices + +### Naming Conventions + +- **Extension ID**: Use descriptive, hyphenated names (`jira-integration`, not `ji`) +- **Commands**: Use verb-noun pattern (`create-issue`, `sync-status`) +- **Config files**: Match extension ID (`jira-config.yml`) + +### Documentation + +- **README.md**: Overview, installation, usage +- **CHANGELOG.md**: Version history +- **docs/**: Detailed guides +- **Command descriptions**: Clear, concise + +### Versioning + +- **Follow SemVer**: `MAJOR.MINOR.PATCH` +- **MAJOR**: Breaking changes +- **MINOR**: New features +- **PATCH**: Bug fixes + +### Security + +- **Never commit secrets**: Use environment variables +- **Validate input**: Sanitize user arguments +- **Document permissions**: What files/APIs are accessed + +### Compatibility + +- **Specify version range**: Don't require exact version +- **Test with multiple versions**: Ensure compatibility +- **Graceful degradation**: Handle missing features + +--- + +## Example Extensions + +### Minimal Extension + +Smallest possible extension: + +```yaml +# extension.yml +schema_version: "1.0" +extension: + id: "minimal" + name: "Minimal Extension" + version: "1.0.0" + description: "Minimal example" +requires: + speckit_version: ">=0.1.0" +provides: + commands: + - name: "speckit.minimal.hello" + file: "commands/hello.md" +``` + +```markdown + +--- +description: "Hello command" +--- + +# Hello World + +```bash +echo "Hello, $ARGUMENTS!" +``` + +``` + +### Extension with Config + +Extension using configuration: + +```yaml +# extension.yml +# ... metadata ... +provides: + config: + - name: "tool-config.yml" + template: "tool-config.template.yml" + required: true +``` + +```yaml +# tool-config.template.yml +api_endpoint: "https://api.example.com" +timeout: 30 +``` + +```markdown + +# Use Config + +Load config: +```bash +config_file=".specify/extensions/tool/tool-config.yml" +endpoint=$(yq eval '.api_endpoint' "$config_file") +echo "Using endpoint: $endpoint" +``` + +``` + +### Extension with Hooks + +Extension that runs automatically: + +```yaml +# extension.yml +hooks: + after_tasks: + command: "speckit.auto.analyze" + optional: false # Always run + description: "Analyze tasks after generation" +``` + +--- + +## Troubleshooting + +### Extension won't install + +**Error**: `Invalid extension ID` + +- **Fix**: Use lowercase, alphanumeric + hyphens only + +**Error**: `Extension requires spec-kit >=0.2.0` + +- **Fix**: Update spec-kit with `uv tool install specify-cli --force` + +**Error**: `Command file not found` + +- **Fix**: Ensure command files exist at paths specified in manifest + +### Commands not registered + +**Symptom**: Commands don't appear in AI agent + +**Check**: + +1. `.claude/commands/` directory exists +2. Extension installed successfully +3. Commands registered in registry: + + ```bash + cat .specify/extensions/.registry + ``` + +**Fix**: Reinstall extension to trigger registration + +### Config not loading + +**Check**: + +1. Config file exists: `.specify/extensions/{ext-id}/{ext-id}-config.yml` +2. YAML syntax is valid: `yq eval '.' config.yml` +3. Environment variables set correctly + +--- + +## Getting Help + +- **Issues**: Report bugs at GitHub repository +- **Discussions**: Ask questions in GitHub Discussions +- **Examples**: See `spec-kit-jira` for full-featured example (Phase B) + +--- + +## Next Steps + +1. **Create your extension** following this guide +2. **Test locally** with `--dev` flag +3. **Share with community** (GitHub, catalog) +4. **Iterate** based on feedback + +Happy extending! 🚀 diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md new file mode 100644 index 0000000000..74d7e227eb --- /dev/null +++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md @@ -0,0 +1,530 @@ +# Extension Publishing Guide + +This guide explains how to publish your extension to the Spec Kit extension catalog, making it discoverable by `specify extension search`. + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Prepare Your Extension](#prepare-your-extension) +3. [Submit to Catalog](#submit-to-catalog) +4. [Verification Process](#verification-process) +5. [Release Workflow](#release-workflow) +6. [Best Practices](#best-practices) + +--- + +## Prerequisites + +Before publishing an extension, ensure you have: + +1. **Valid Extension**: A working extension with a valid `extension.yml` manifest +2. **Git Repository**: Extension hosted on GitHub (or other public git hosting) +3. **Documentation**: README.md with installation and usage instructions +4. **License**: Open source license file (MIT, Apache 2.0, etc.) +5. **Versioning**: Semantic versioning (e.g., 1.0.0) +6. **Testing**: Extension tested on real projects + +--- + +## Prepare Your Extension + +### 1. Extension Structure + +Ensure your extension follows the standard structure: + +``` +your-extension/ +├── extension.yml # Required: Extension manifest +├── README.md # Required: Documentation +├── LICENSE # Required: License file +├── CHANGELOG.md # Recommended: Version history +├── .gitignore # Recommended: Git ignore rules +│ +├── commands/ # Extension commands +│ ├── command1.md +│ └── command2.md +│ +├── config-template.yml # Config template (if needed) +│ +└── docs/ # Additional documentation + ├── usage.md + └── examples/ +``` + +### 2. extension.yml Validation + +Verify your manifest is valid: + +```yaml +schema_version: "1.0" + +extension: + id: "your-extension" # Unique lowercase-hyphenated ID + name: "Your Extension Name" # Human-readable name + version: "1.0.0" # Semantic version + description: "Brief description (one sentence)" + author: "Your Name or Organization" + repository: "https://github.com/your-org/spec-kit-your-extension" + license: "MIT" + homepage: "https://github.com/your-org/spec-kit-your-extension" + +requires: + speckit_version: ">=0.1.0" # Required spec-kit version + +provides: + commands: # List all commands + - name: "speckit.your-extension.command" + file: "commands/command.md" + description: "Command description" + +tags: # 2-5 relevant tags + - "category" + - "tool-name" +``` + +**Validation Checklist**: + +- ✅ `id` is lowercase with hyphens only (no underscores, spaces, or special characters) +- ✅ `version` follows semantic versioning (X.Y.Z) +- ✅ `description` is concise (under 100 characters) +- ✅ `repository` URL is valid and public +- ✅ All command files exist in the extension directory +- ✅ Tags are lowercase and descriptive + +### 3. Create GitHub Release + +Create a GitHub release for your extension version: + +```bash +# Tag the release +git tag v1.0.0 +git push origin v1.0.0 + +# Create release on GitHub +# Go to: https://github.com/your-org/spec-kit-your-extension/releases/new +# - Tag: v1.0.0 +# - Title: v1.0.0 - Release Name +# - Description: Changelog/release notes +``` + +The release archive URL will be: + +``` +https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip +``` + +### 4. Test Installation + +Test that users can install from your release: + +```bash +# Test dev installation +specify extension add --dev /path/to/your-extension + +# Test from GitHub archive +specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip +``` + +--- + +## Submit to Catalog + +### 1. Fork the spec-kit Repository + +```bash +# Fork on GitHub +# https://github.com/statsperform/spec-kit/fork + +# Clone your fork +git clone https://github.com/YOUR-USERNAME/spec-kit.git +cd spec-kit +``` + +### 2. Add Extension to Catalog + +Edit `extensions/catalog.json` and add your extension: + +```json +{ + "schema_version": "1.0", + "updated_at": "2026-01-28T15:54:00Z", + "catalog_url": "https://raw.githubusercontent.com/statsperform/spec-kit/main/extensions/catalog.json", + "extensions": { + "your-extension": { + "name": "Your Extension Name", + "id": "your-extension", + "description": "Brief description of your extension", + "author": "Your Name", + "version": "1.0.0", + "download_url": "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/your-org/spec-kit-your-extension", + "homepage": "https://github.com/your-org/spec-kit-your-extension", + "documentation": "https://github.com/your-org/spec-kit-your-extension/blob/main/docs/", + "changelog": "https://github.com/your-org/spec-kit-your-extension/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "required-mcp-tool", + "version": ">=1.0.0", + "required": true + } + ] + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "category", + "tool-name", + "feature" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-01-28T00:00:00Z", + "updated_at": "2026-01-28T00:00:00Z" + } + } +} +``` + +**Important**: + +- Set `verified: false` (maintainers will verify) +- Set `downloads: 0` and `stars: 0` (auto-updated later) +- Use current timestamp for `created_at` and `updated_at` +- Update the top-level `updated_at` to current time + +### 3. Submit Pull Request + +```bash +# Create a branch +git checkout -b add-your-extension + +# Commit your changes +git add extensions/catalog.json +git commit -m "Add your-extension to catalog + +- Extension ID: your-extension +- Version: 1.0.0 +- Author: Your Name +- Description: Brief description +" + +# Push to your fork +git push origin add-your-extension + +# Create Pull Request on GitHub +# https://github.com/statsperform/spec-kit/compare +``` + +**Pull Request Template**: + +```markdown +## Extension Submission + +**Extension Name**: Your Extension Name +**Extension ID**: your-extension +**Version**: 1.0.0 +**Author**: Your Name +**Repository**: https://github.com/your-org/spec-kit-your-extension + +### Description +Brief description of what your extension does. + +### Checklist +- [x] Valid extension.yml manifest +- [x] README.md with installation and usage docs +- [x] LICENSE file included +- [x] GitHub release created (v1.0.0) +- [x] Extension tested on real project +- [x] All commands working +- [x] No security vulnerabilities + +### Testing +Tested on: +- macOS 13.0+ with spec-kit 0.1.0 +- Project: [Your test project] + +### Additional Notes +Any additional context or notes for reviewers. +``` + +--- + +## Verification Process + +### What Happens After Submission + +1. **Automated Checks** (if available): + - Manifest validation + - Download URL accessibility + - Repository existence + - License file presence + +2. **Manual Review**: + - Code quality review + - Security audit + - Functionality testing + - Documentation review + +3. **Verification**: + - If approved, `verified: true` is set + - Extension appears in `specify extension search --verified` + +### Verification Criteria + +To be verified, your extension must: + +✅ **Functionality**: + +- Works as described in documentation +- All commands execute without errors +- No breaking changes to user workflows + +✅ **Security**: + +- No known vulnerabilities +- No malicious code +- Safe handling of user data +- Proper validation of inputs + +✅ **Code Quality**: + +- Clean, readable code +- Follows extension best practices +- Proper error handling +- Helpful error messages + +✅ **Documentation**: + +- Clear installation instructions +- Usage examples +- Troubleshooting section +- Accurate description + +✅ **Maintenance**: + +- Active repository +- Responsive to issues +- Regular updates +- Semantic versioning followed + +### Typical Review Timeline + +- **Automated checks**: Immediate (if implemented) +- **Manual review**: 3-7 business days +- **Verification**: After successful review + +--- + +## Release Workflow + +### Publishing New Versions + +When releasing a new version: + +1. **Update version** in `extension.yml`: + + ```yaml + extension: + version: "1.1.0" # Updated version + ``` + +2. **Update CHANGELOG.md**: + + ```markdown + ## [1.1.0] - 2026-02-15 + + ### Added + - New feature X + + ### Fixed + - Bug fix Y + ``` + +3. **Create GitHub release**: + + ```bash + git tag v1.1.0 + git push origin v1.1.0 + # Create release on GitHub + ``` + +4. **Update catalog**: + + ```bash + # Fork spec-kit repo (or update existing fork) + cd spec-kit + + # Update extensions/catalog.json + jq '.extensions["your-extension"].version = "1.1.0"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json + jq '.extensions["your-extension"].download_url = "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.1.0.zip"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json + jq '.extensions["your-extension"].updated_at = "2026-02-15T00:00:00Z"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json + jq '.updated_at = "2026-02-15T00:00:00Z"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json + + # Submit PR + git checkout -b update-your-extension-v1.1.0 + git add extensions/catalog.json + git commit -m "Update your-extension to v1.1.0" + git push origin update-your-extension-v1.1.0 + ``` + +5. **Submit update PR** with changelog in description + +--- + +## Best Practices + +### Extension Design + +1. **Single Responsibility**: Each extension should focus on one tool/integration +2. **Clear Naming**: Use descriptive, unambiguous names +3. **Minimal Dependencies**: Avoid unnecessary dependencies +4. **Backward Compatibility**: Follow semantic versioning strictly + +### Documentation + +1. **README.md Structure**: + - Overview and features + - Installation instructions + - Configuration guide + - Usage examples + - Troubleshooting + - Contributing guidelines + +2. **Command Documentation**: + - Clear description + - Prerequisites listed + - Step-by-step instructions + - Error handling guidance + - Examples + +3. **Configuration**: + - Provide template file + - Document all options + - Include examples + - Explain defaults + +### Security + +1. **Input Validation**: Validate all user inputs +2. **No Hardcoded Secrets**: Never include credentials +3. **Safe Dependencies**: Only use trusted dependencies +4. **Audit Regularly**: Check for vulnerabilities + +### Maintenance + +1. **Respond to Issues**: Address issues within 1-2 weeks +2. **Regular Updates**: Keep dependencies updated +3. **Changelog**: Maintain detailed changelog +4. **Deprecation**: Give advance notice for breaking changes + +### Community + +1. **License**: Use permissive open-source license (MIT, Apache 2.0) +2. **Contributing**: Welcome contributions +3. **Code of Conduct**: Be respectful and inclusive +4. **Support**: Provide ways to get help (issues, discussions, email) + +--- + +## FAQ + +### Q: Can I publish private/proprietary extensions? + +A: The main catalog is for public extensions only. For private extensions: + +- Host your own catalog.json file +- Users add your catalog: `specify extension add-catalog https://your-domain.com/catalog.json` +- Not yet implemented - coming in Phase 4 + +### Q: How long does verification take? + +A: Typically 3-7 business days for initial review. Updates to verified extensions are usually faster. + +### Q: What if my extension is rejected? + +A: You'll receive feedback on what needs to be fixed. Make the changes and resubmit. + +### Q: Can I update my extension anytime? + +A: Yes, submit a PR to update the catalog with your new version. Verified status may be re-evaluated for major changes. + +### Q: Do I need to be verified to be in the catalog? + +A: No, unverified extensions are still searchable. Verification just adds trust and visibility. + +### Q: Can extensions have paid features? + +A: Extensions should be free and open-source. Commercial support/services are allowed, but core functionality must be free. + +--- + +## Support + +- **Catalog Issues**: +- **Extension Template**: (coming soon) +- **Development Guide**: See EXTENSION-DEVELOPMENT-GUIDE.md +- **Community**: Discussions and Q&A + +--- + +## Appendix: Catalog Schema + +### Complete Catalog Entry Schema + +```json +{ + "name": "string (required)", + "id": "string (required, unique)", + "description": "string (required, <200 chars)", + "author": "string (required)", + "version": "string (required, semver)", + "download_url": "string (required, valid URL)", + "repository": "string (required, valid URL)", + "homepage": "string (optional, valid URL)", + "documentation": "string (optional, valid URL)", + "changelog": "string (optional, valid URL)", + "license": "string (required)", + "requires": { + "speckit_version": "string (required, version specifier)", + "tools": [ + { + "name": "string (required)", + "version": "string (optional, version specifier)", + "required": "boolean (default: false)" + } + ] + }, + "provides": { + "commands": "integer (optional)", + "hooks": "integer (optional)" + }, + "tags": ["array of strings (2-10 tags)"], + "verified": "boolean (default: false)", + "downloads": "integer (auto-updated)", + "stars": "integer (auto-updated)", + "created_at": "string (ISO 8601 datetime)", + "updated_at": "string (ISO 8601 datetime)" +} +``` + +### Valid Tags + +Recommended tag categories: + +- **Integration**: jira, linear, github, gitlab, azure-devops +- **Category**: issue-tracking, vcs, ci-cd, documentation, testing +- **Platform**: atlassian, microsoft, google +- **Feature**: automation, reporting, deployment, monitoring + +Use 2-5 tags that best describe your extension. + +--- + +*Last Updated: 2026-01-28* +*Catalog Format Version: 1.0* diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md new file mode 100644 index 0000000000..80d2ebe0a2 --- /dev/null +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -0,0 +1,676 @@ +# Extension User Guide + +Complete guide for using Spec Kit extensions to enhance your workflow. + +## Table of Contents + +1. [Introduction](#introduction) +2. [Getting Started](#getting-started) +3. [Finding Extensions](#finding-extensions) +4. [Installing Extensions](#installing-extensions) +5. [Using Extensions](#using-extensions) +6. [Managing Extensions](#managing-extensions) +7. [Configuration](#configuration) +8. [Troubleshooting](#troubleshooting) +9. [Best Practices](#best-practices) + +--- + +## Introduction + +### What are Extensions? + +Extensions are modular packages that add new commands and functionality to Spec Kit without bloating the core framework. They allow you to: + +- **Integrate** with external tools (Jira, Linear, GitHub, etc.) +- **Automate** repetitive tasks with hooks +- **Customize** workflows for your team +- **Share** solutions across projects + +### Why Use Extensions? + +- **Clean Core**: Keeps spec-kit lightweight and focused +- **Optional Features**: Only install what you need +- **Community Driven**: Anyone can create and share extensions +- **Version Controlled**: Extensions are versioned independently + +--- + +## Getting Started + +### Prerequisites + +- Spec Kit version 0.1.0 or higher +- A spec-kit project (directory with `.specify/` folder) + +### Check Your Version + +```bash +specify --version +# Should show 0.1.0 or higher +``` + +### First Extension + +Let's install the Jira extension as an example: + +```bash +# 1. Search for the extension +specify extension search jira + +# 2. Get detailed information +specify extension info jira + +# 3. Install it +specify extension add jira + +# 4. Configure it +vim .specify/extensions/jira/jira-config.yml + +# 5. Use it +# (Commands are now available in Claude Code) +/speckit.jira.specstoissues +``` + +--- + +## Finding Extensions + +### Browse All Extensions + +```bash +specify extension search +``` + +Shows all available extensions in the catalog. + +### Search by Keyword + +```bash +# Search for "jira" +specify extension search jira + +# Search for "issue tracking" +specify extension search issue +``` + +### Filter by Tag + +```bash +# Find all issue-tracking extensions +specify extension search --tag issue-tracking + +# Find all Atlassian tools +specify extension search --tag atlassian +``` + +### Filter by Author + +```bash +# Extensions by Stats Perform +specify extension search --author "Stats Perform" +``` + +### Show Verified Only + +```bash +# Only show verified extensions +specify extension search --verified +``` + +### Get Extension Details + +```bash +# Detailed information +specify extension info jira +``` + +Shows: + +- Description +- Requirements +- Commands provided +- Hooks available +- Links (documentation, repository, changelog) +- Installation status + +--- + +## Installing Extensions + +### Install from Catalog + +```bash +# By name (from catalog) +specify extension add jira +``` + +This will: + +1. Download the extension from GitHub +2. Validate the manifest +3. Check compatibility with your spec-kit version +4. Install to `.specify/extensions/jira/` +5. Register commands with your AI agent +6. Create config template + +### Install from URL + +```bash +# From GitHub release +specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/tags/v1.0.0.zip +``` + +### Install from Local Directory (Development) + +```bash +# For testing or development +specify extension add --dev /path/to/extension +``` + +### Installation Output + +``` +✓ Extension installed successfully! + +Jira Integration (v1.0.0) + Create Jira Epics, Stories, and Issues from spec-kit artifacts + +Provided commands: + • speckit.jira.specstoissues - Create Jira hierarchy from spec and tasks + • speckit.jira.discover-fields - Discover Jira custom fields for configuration + • speckit.jira.sync-status - Sync task completion status to Jira + +⚠ Configuration may be required + Check: .specify/extensions/jira/ +``` + +--- + +## Using Extensions + +### Using Extension Commands + +Extensions add commands that appear in your AI agent (Claude Code): + +``` +# In Claude Code +> /speckit.jira.specstoissues + +# Or use short alias (if provided) +> /speckit.specstoissues +``` + +### Extension Configuration + +Most extensions require configuration: + +```bash +# 1. Find the config file +ls .specify/extensions/jira/ + +# 2. Copy template to config +cp .specify/extensions/jira/jira-config.template.yml \ + .specify/extensions/jira/jira-config.yml + +# 3. Edit configuration +vim .specify/extensions/jira/jira-config.yml + +# 4. Use the extension +# (Commands will now work with your config) +``` + +### Extension Hooks + +Some extensions provide hooks that execute after core commands: + +**Example**: Jira extension hooks into `/speckit.tasks` + +``` +# Run core command +> /speckit.tasks + +# Output includes: +## Extension Hooks + +**Optional Hook**: jira +Command: `/speckit.jira.specstoissues` +Description: Automatically create Jira hierarchy after task generation + +Prompt: Create Jira issues from tasks? +To execute: `/speckit.jira.specstoissues` +``` + +You can then choose to run the hook or skip it. + +--- + +## Managing Extensions + +### List Installed Extensions + +```bash +specify extension list +``` + +Output: + +``` +Installed Extensions: + + ✓ Jira Integration (v1.0.0) + Create Jira Epics, Stories, and Issues from spec-kit artifacts + Commands: 3 | Hooks: 1 | Status: Enabled +``` + +### Update Extensions + +```bash +# Check for updates (all extensions) +specify extension update + +# Update specific extension +specify extension update jira +``` + +Output: + +``` +🔄 Checking for updates... + +Updates available: + + • jira: 1.0.0 → 1.1.0 + +Update these extensions? [y/N]: +``` + +### Disable Extension Temporarily + +```bash +# Disable without removing +specify extension disable jira + +✓ Extension 'jira' disabled + +Commands will no longer be available. Hooks will not execute. +To re-enable: specify extension enable jira +``` + +### Re-enable Extension + +```bash +specify extension enable jira + +✓ Extension 'jira' enabled +``` + +### Remove Extension + +```bash +# Remove extension (with confirmation) +specify extension remove jira + +# Keep configuration when removing +specify extension remove jira --keep-config + +# Force removal (no confirmation) +specify extension remove jira --force +``` + +--- + +## Configuration + +### Configuration Files + +Extensions can have multiple configuration files: + +``` +.specify/extensions/jira/ +├── jira-config.yml # Main config (version controlled) +├── jira-config.local.yml # Local overrides (gitignored) +└── jira-config.template.yml # Template (reference) +``` + +### Configuration Layers + +Configuration is merged in this order (highest priority last): + +1. **Extension defaults** (from `extension.yml`) +2. **Project config** (`jira-config.yml`) +3. **Local overrides** (`jira-config.local.yml`) +4. **Environment variables** (`SPECKIT_JIRA_*`) + +### Example: Jira Configuration + +**Project config** (`.specify/extensions/jira/jira-config.yml`): + +```yaml +project: + key: "MSATS" + +defaults: + epic: + labels: ["spec-driven"] +``` + +**Local override** (`.specify/extensions/jira/jira-config.local.yml`): + +```yaml +project: + key: "MYTEST" # Override for local development +``` + +**Environment variable**: + +```bash +export SPECKIT_JIRA_PROJECT_KEY="DEVTEST" +``` + +Final resolved config uses `DEVTEST` from environment variable. + +### Project-Wide Extension Settings + +File: `.specify/extensions.yml` + +```yaml +# Extensions installed in this project +installed: + - jira + - linear + +# Global settings +settings: + auto_execute_hooks: true + +# Hook configuration +hooks: + after_tasks: + - extension: jira + command: speckit.jira.specstoissues + enabled: true + optional: true + prompt: "Create Jira issues from tasks?" +``` + +### Core Environment Variables + +In addition to extension-specific environment variables (`SPECKIT_{EXT_ID}_*`), spec-kit supports core environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `SPECKIT_CATALOG_URL` | Override the extension catalog URL | GitHub-hosted catalog | +| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub API token for downloads | None | + +**Example: Using a custom catalog for testing** + +```bash +# Point to a local or alternative catalog +export SPECKIT_CATALOG_URL="http://localhost:8000/catalog.json" + +# Or use a staging catalog +export SPECKIT_CATALOG_URL="https://example.com/staging/catalog.json" +``` + +--- + +## Troubleshooting + +### Extension Not Found + +**Error**: `Extension 'jira' not found in catalog + +**Solutions**: + +1. Check spelling: `specify extension search jira` +2. Refresh catalog: `specify extension search --help` +3. Check internet connection +4. Extension may not be published yet + +### Configuration Not Found + +**Error**: `Jira configuration not found` + +**Solutions**: + +1. Check if extension is installed: `specify extension list` +2. Create config from template: + + ```bash + cp .specify/extensions/jira/jira-config.template.yml \ + .specify/extensions/jira/jira-config.yml + ``` + +3. Reinstall extension: `specify extension remove jira && specify extension add jira` + +### Command Not Available + +**Issue**: Extension command not appearing in AI agent + +**Solutions**: + +1. Check extension is enabled: `specify extension list` +2. Restart AI agent (Claude Code) +3. Check command file exists: + + ```bash + ls .claude/commands/speckit.jira.*.md + ``` + +4. Reinstall extension + +### Incompatible Version + +**Error**: `Extension requires spec-kit >=0.2.0, but you have 0.1.0` + +**Solutions**: + +1. Upgrade spec-kit: + + ```bash + uv tool upgrade specify-cli + ``` + +2. Install older version of extension: + + ```bash + specify extension add --from https://github.com/org/ext/archive/v1.0.0.zip + ``` + +### MCP Tool Not Available + +**Error**: `Tool 'jira-mcp-server/epic_create' not found` + +**Solutions**: + +1. Check MCP server is installed +2. Check AI agent MCP configuration +3. Restart AI agent +4. Check extension requirements: `specify extension info jira` + +### Permission Denied + +**Error**: `Permission denied` when accessing Jira + +**Solutions**: + +1. Check Jira credentials in MCP server config +2. Verify project permissions in Jira +3. Test MCP server connection independently + +--- + +## Best Practices + +### 1. Version Control + +**Do commit**: + +- `.specify/extensions.yml` (project extension config) +- `.specify/extensions/*/jira-config.yml` (project config) + +**Don't commit**: + +- `.specify/extensions/.cache/` (catalog cache) +- `.specify/extensions/.backup/` (config backups) +- `.specify/extensions/*/*.local.yml` (local overrides) +- `.specify/extensions/.registry` (installation state) + +Add to `.gitignore`: + +``` +.specify/extensions/.cache/ +.specify/extensions/.backup/ +.specify/extensions/*/*.local.yml +.specify/extensions/.registry +``` + +### 2. Team Workflows + +**For teams**: + +1. Agree on which extensions to use +2. Commit extension configuration +3. Document extension usage in README +4. Keep extensions updated together + +**Example README section**: + +```markdown +## Extensions + +This project uses: +- **jira** (v1.0.0) - Jira integration + - Config: `.specify/extensions/jira/jira-config.yml` + - Requires: jira-mcp-server + +To install: `specify extension add jira` +``` + +### 3. Local Development + +Use local config for development: + +```yaml +# .specify/extensions/jira/jira-config.local.yml +project: + key: "DEVTEST" # Your test project + +defaults: + task: + custom_fields: + customfield_10002: 1 # Lower story points for testing +``` + +### 4. Environment-Specific Config + +Use environment variables for CI/CD: + +```bash +# .github/workflows/deploy.yml +env: + SPECKIT_JIRA_PROJECT_KEY: ${{ secrets.JIRA_PROJECT }} + +- name: Create Jira Issues + run: specify extension add jira && ... +``` + +### 5. Extension Updates + +**Check for updates regularly**: + +```bash +# Weekly or before major releases +specify extension update +``` + +**Pin versions for stability**: + +```yaml +# .specify/extensions.yml +installed: + - id: jira + version: "1.0.0" # Pin to specific version +``` + +### 6. Minimal Extensions + +Only install extensions you actively use: + +- Reduces complexity +- Faster command loading +- Less configuration + +### 7. Documentation + +Document extension usage in your project: + +```markdown +# PROJECT.md + +## Working with Jira + +After creating tasks, sync to Jira: +1. Run `/speckit.tasks` to generate tasks +2. Run `/speckit.jira.specstoissues` to create Jira issues +3. Run `/speckit.jira.sync-status` to update status +``` + +--- + +## FAQ + +### Q: Can I use multiple extensions at once? + +**A**: Yes! Extensions are designed to work together. Install as many as you need. + +### Q: Do extensions slow down spec-kit? + +**A**: No. Extensions are loaded on-demand and only when their commands are used. + +### Q: Can I create private extensions? + +**A**: Yes. Install with `--dev` or `--from` and keep private. Public catalog submission is optional. + +### Q: How do I know if an extension is safe? + +**A**: Look for the ✓ Verified badge. Verified extensions are reviewed by maintainers. Always review extension code before installing. + +### Q: Can extensions modify spec-kit core? + +**A**: No. Extensions can only add commands and hooks. They cannot modify core functionality. + +### Q: What happens if two extensions have the same command name? + +**A**: Extensions use namespaced commands (`speckit.{extension}.{command}`), so conflicts are very rare. The extension system will warn you if conflicts occur. + +### Q: Can I contribute to existing extensions? + +**A**: Yes! Most extensions are open source. Check the repository link in `specify extension info {extension}`. + +### Q: How do I report extension bugs? + +**A**: Go to the extension's repository (shown in `specify extension info`) and create an issue. + +### Q: Can extensions work offline? + +**A**: Once installed, extensions work offline. However, some extensions may require internet for their functionality (e.g., Jira requires Jira API access). + +### Q: How do I backup my extension configuration? + +**A**: Extension configs are in `.specify/extensions/{extension}/`. Back up this directory or commit configs to git. + +--- + +## Support + +- **Extension Issues**: Report to extension repository (see `specify extension info`) +- **Spec Kit Issues**: +- **Extension Catalog**: +- **Documentation**: See EXTENSION-DEVELOPMENT-GUIDE.md and EXTENSION-PUBLISHING-GUIDE.md + +--- + +*Last Updated: 2026-01-28* +*Spec Kit Version: 0.1.0* diff --git a/extensions/RFC-EXTENSION-SYSTEM.md b/extensions/RFC-EXTENSION-SYSTEM.md new file mode 100644 index 0000000000..db0abff880 --- /dev/null +++ b/extensions/RFC-EXTENSION-SYSTEM.md @@ -0,0 +1,1794 @@ +# RFC: Spec Kit Extension System + +**Status**: Draft +**Author**: Stats Perform Engineering +**Created**: 2026-01-28 +**Updated**: 2026-01-28 + +--- + +## Table of Contents + +1. [Summary](#summary) +2. [Motivation](#motivation) +3. [Design Principles](#design-principles) +4. [Architecture Overview](#architecture-overview) +5. [Extension Manifest Specification](#extension-manifest-specification) +6. [Extension Lifecycle](#extension-lifecycle) +7. [Command Registration](#command-registration) +8. [Configuration Management](#configuration-management) +9. [Hook System](#hook-system) +10. [Extension Discovery & Catalog](#extension-discovery--catalog) +11. [CLI Commands](#cli-commands) +12. [Compatibility & Versioning](#compatibility--versioning) +13. [Security Considerations](#security-considerations) +14. [Migration Strategy](#migration-strategy) +15. [Implementation Phases](#implementation-phases) +16. [Open Questions](#open-questions) +17. [Appendices](#appendices) + +--- + +## Summary + +Introduce an extension system to Spec Kit that allows modular integration with external tools (Jira, Linear, Azure DevOps, etc.) without bloating the core framework. Extensions are self-contained packages installed into `.specify/extensions/` with declarative manifests, versioned independently, and discoverable through a central catalog. + +--- + +## Motivation + +### Current Problems + +1. **Monolithic Growth**: Adding Jira integration to core spec-kit creates: + - Large configuration files affecting all users + - Dependencies on Jira MCP server for everyone + - Merge conflicts as features accumulate + +2. **Limited Flexibility**: Different organizations use different tools: + - GitHub Issues vs Jira vs Linear vs Azure DevOps + - Custom internal tools + - No way to support all without bloat + +3. **Maintenance Burden**: Every integration adds: + - Documentation complexity + - Testing matrix expansion + - Breaking change surface area + +4. **Community Friction**: External contributors can't easily add integrations without core repo PR approval and release cycles. + +### Goals + +1. **Modularity**: Core spec-kit remains lean, extensions are opt-in +2. **Extensibility**: Clear API for building new integrations +3. **Independence**: Extensions version/release separately from core +4. **Discoverability**: Central catalog for finding extensions +5. **Safety**: Validation, compatibility checks, sandboxing + +--- + +## Design Principles + +### 1. Convention Over Configuration + +- Standard directory structure (`.specify/extensions/{name}/`) +- Declarative manifest (`extension.yml`) +- Predictable command naming (`speckit.{extension}.{command}`) + +### 2. Fail-Safe Defaults + +- Missing extensions gracefully degrade (skip hooks) +- Invalid extensions warn but don't break core functionality +- Extension failures isolated from core operations + +### 3. Backward Compatibility + +- Core commands remain unchanged +- Extensions additive only (no core modifications) +- Old projects work without extensions + +### 4. Developer Experience + +- Simple installation: `specify extension add jira` +- Clear error messages for compatibility issues +- Local development mode for testing extensions + +### 5. Security First + +- Extensions run in same context as AI agent (trust boundary) +- Manifest validation prevents malicious code +- Verify signatures for official extensions (future) + +--- + +## Architecture Overview + +### Directory Structure + +``` +project/ +├── .specify/ +│ ├── scripts/ # Core scripts (unchanged) +│ ├── templates/ # Core templates (unchanged) +│ ├── memory/ # Session memory +│ ├── extensions/ # Extensions directory (NEW) +│ │ ├── .registry # Installed extensions metadata (NEW) +│ │ ├── jira/ # Jira extension +│ │ │ ├── extension.yml # Manifest +│ │ │ ├── jira-config.yml # Extension config +│ │ │ ├── commands/ # Command files +│ │ │ ├── scripts/ # Helper scripts +│ │ │ └── docs/ # Documentation +│ │ └── linear/ # Linear extension (example) +│ └── extensions.yml # Project extension configuration (NEW) +└── .gitignore # Ignore local extension configs +``` + +### Component Diagram + +``` +┌─────────────────────────────────────────────────────────┐ +│ Spec Kit Core │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ CLI (specify) │ │ +│ │ - init, check │ │ +│ │ - extension add/remove/list/update ← NEW │ │ +│ └──────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Extension Manager ← NEW │ │ +│ │ - Discovery, Installation, Validation │ │ +│ │ - Command Registration, Hook Execution │ │ +│ └──────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Core Commands │ │ +│ │ - /speckit.specify │ │ +│ │ - /speckit.tasks │ │ +│ │ - /speckit.implement │ │ +│ └─────────┬────────────────────────────────────────┘ │ +└────────────┼────────────────────────────────────────────┘ + │ Hook Points (after_tasks, after_implement) + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Extensions │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Jira Extension │ │ +│ │ - /speckit.jira.specstoissues │ │ +│ │ - /speckit.jira.discover-fields │ │ +│ └──────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Linear Extension │ │ +│ │ - /speckit.linear.sync │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ Calls external tools + ↓ +┌─────────────────────────────────────────────────────────┐ +│ External Tools │ +│ - Jira MCP Server │ +│ - Linear API │ +│ - GitHub API │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Extension Manifest Specification + +### Schema: `extension.yml` + +```yaml +# Extension Manifest Schema v1.0 +# All extensions MUST include this file at root + +# Schema version for compatibility +schema_version: "1.0" + +# Extension metadata (REQUIRED) +extension: + id: "jira" # Unique identifier (lowercase, alphanumeric, hyphens) + name: "Jira Integration" # Human-readable name + version: "1.0.0" # Semantic version + description: "Create Jira Epics, Stories, and Issues from spec-kit artifacts" + author: "Stats Perform" # Author/organization + repository: "https://github.com/statsperform/spec-kit-jira" + license: "MIT" # SPDX license identifier + homepage: "https://github.com/statsperform/spec-kit-jira/blob/main/README.md" + +# Compatibility requirements (REQUIRED) +requires: + # Spec-kit version (semantic version range) + speckit_version: ">=0.1.0,<2.0.0" + + # External tools required by extension + tools: + - name: "jira-mcp-server" + required: true + version: ">=1.0.0" # Optional: version constraint + description: "Jira MCP server for API access" + install_url: "https://github.com/your-org/jira-mcp-server" + check_command: "jira --version" # Optional: CLI command to verify + + # Core spec-kit commands this extension depends on + commands: + - "speckit.tasks" # Extension needs tasks command + + # Core scripts required + scripts: + - "check-prerequisites.sh" + +# What this extension provides (REQUIRED) +provides: + # Commands added to AI agent + commands: + - name: "speckit.jira.specstoissues" + file: "commands/specstoissues.md" + description: "Create Jira hierarchy from spec and tasks" + aliases: ["speckit.specstoissues"] # Alternate names + + - name: "speckit.jira.discover-fields" + file: "commands/discover-fields.md" + description: "Discover Jira custom fields for configuration" + + - name: "speckit.jira.sync-status" + file: "commands/sync-status.md" + description: "Sync task completion status to Jira" + + # Configuration files + config: + - name: "jira-config.yml" + template: "jira-config.template.yml" + description: "Jira integration configuration" + required: true # User must configure before use + + # Helper scripts + scripts: + - name: "parse-jira-config.sh" + file: "scripts/parse-jira-config.sh" + description: "Parse jira-config.yml to JSON" + executable: true # Make executable on install + +# Extension configuration defaults (OPTIONAL) +defaults: + project: + key: null # No default, user must configure + hierarchy: + issue_type: "subtask" + update_behavior: + mode: "update" + sync_completion: true + +# Configuration schema for validation (OPTIONAL) +config_schema: + type: "object" + required: ["project"] + properties: + project: + type: "object" + required: ["key"] + properties: + key: + type: "string" + pattern: "^[A-Z]{2,10}$" + description: "Jira project key (e.g., MSATS)" + +# Integration hooks (OPTIONAL) +hooks: + # Hook fired after /speckit.tasks completes + after_tasks: + command: "speckit.jira.specstoissues" + optional: true + prompt: "Create Jira issues from tasks?" + description: "Automatically create Jira hierarchy after task generation" + + # Hook fired after /speckit.implement completes + after_implement: + command: "speckit.jira.sync-status" + optional: true + prompt: "Sync completion status to Jira?" + +# Tags for discovery (OPTIONAL) +tags: + - "issue-tracking" + - "jira" + - "atlassian" + - "project-management" + +# Changelog URL (OPTIONAL) +changelog: "https://github.com/statsperform/spec-kit-jira/blob/main/CHANGELOG.md" + +# Support information (OPTIONAL) +support: + documentation: "https://github.com/statsperform/spec-kit-jira/blob/main/docs/" + issues: "https://github.com/statsperform/spec-kit-jira/issues" + discussions: "https://github.com/statsperform/spec-kit-jira/discussions" + email: "support@statsperform.com" +``` + +### Validation Rules + +1. **MUST have** `schema_version`, `extension`, `requires`, `provides` +2. **MUST follow** semantic versioning for `version` +3. **MUST have** unique `id` (no conflicts with other extensions) +4. **MUST declare** all external tool dependencies +5. **SHOULD include** `config_schema` if extension uses config +6. **SHOULD include** `support` information +7. Command `file` paths **MUST be** relative to extension root +8. Hook `command` names **MUST match** a command in `provides.commands` + +--- + +## Extension Lifecycle + +### 1. Discovery + +```bash +specify extension search jira +# Searches catalog for extensions matching "jira" +``` + +**Process:** + +1. Fetch extension catalog from GitHub +2. Filter by search term (name, tags, description) +3. Display results with metadata + +### 2. Installation + +```bash +specify extension add jira +``` + +**Process:** + +1. **Resolve**: Look up extension in catalog +2. **Download**: Fetch extension package (ZIP from GitHub release) +3. **Validate**: Check manifest schema, compatibility +4. **Extract**: Unpack to `.specify/extensions/jira/` +5. **Configure**: Copy config templates +6. **Register**: Add commands to AI agent config +7. **Record**: Update `.specify/extensions/.registry` + +**Registry Format** (`.specify/extensions/.registry`): + +```json +{ + "schema_version": "1.0", + "extensions": { + "jira": { + "version": "1.0.0", + "installed_at": "2026-01-28T14:30:00Z", + "source": "catalog", + "manifest_hash": "sha256:abc123...", + "enabled": true + } + } +} +``` + +### 3. Configuration + +```bash +# User edits extension config +vim .specify/extensions/jira/jira-config.yml +``` + +**Config discovery order:** + +1. Extension defaults (`extension.yml` → `defaults`) +2. Project config (`jira-config.yml`) +3. Local overrides (`jira-config.local.yml` - gitignored) +4. Environment variables (`SPECKIT_JIRA_*`) + +### 4. Usage + +```bash +claude +> /speckit.jira.specstoissues +``` + +**Command resolution:** + +1. AI agent finds command in `.claude/commands/speckit.jira.specstoissues.md` +2. Command file references extension scripts/config +3. Extension executes with full context + +### 5. Update + +```bash +specify extension update jira +``` + +**Process:** + +1. Check catalog for newer version +2. Download new version +3. Validate compatibility +4. Back up current config +5. Extract new version (preserve config) +6. Re-register commands +7. Update registry + +### 6. Removal + +```bash +specify extension remove jira +``` + +**Process:** + +1. Confirm with user (show what will be removed) +2. Unregister commands from AI agent +3. Remove from `.specify/extensions/jira/` +4. Update registry +5. Optionally preserve config for reinstall + +--- + +## Command Registration + +### Per-Agent Registration + +Extensions provide **universal command format** (Markdown-based), and CLI converts to agent-specific format during registration. + +#### Universal Command Format + +**Location**: Extension's `commands/specstoissues.md` + +```markdown +--- +# Universal metadata (parsed by all agents) +description: "Create Jira hierarchy from spec and tasks" +tools: + - 'jira-mcp-server/epic_create' + - 'jira-mcp-server/story_create' +scripts: + sh: ../../scripts/bash/check-prerequisites.sh --json + ps: ../../scripts/powershell/check-prerequisites.ps1 -Json +--- + +# Command implementation +## User Input +$ARGUMENTS + +## Steps +1. Load jira-config.yml +2. Parse spec.md and tasks.md +3. Create Jira items +``` + +#### Claude Code Registration + +**Output**: `.claude/commands/speckit.jira.specstoissues.md` + +```markdown +--- +description: "Create Jira hierarchy from spec and tasks" +tools: + - 'jira-mcp-server/epic_create' + - 'jira-mcp-server/story_create' +scripts: + sh: .specify/scripts/bash/check-prerequisites.sh --json + ps: .specify/scripts/powershell/check-prerequisites.ps1 -Json +--- + +# Command implementation (copied from extension) +## User Input +$ARGUMENTS + +## Steps +1. Load jira-config.yml from .specify/extensions/jira/ +2. Parse spec.md and tasks.md +3. Create Jira items +``` + +**Transformation:** + +- Copy frontmatter with adjustments +- Rewrite script paths (relative to repo root) +- Add extension context (config location) + +#### Gemini CLI Registration + +**Output**: `.gemini/commands/speckit.jira.specstoissues.toml` + +```toml +[command] +name = "speckit.jira.specstoissues" +description = "Create Jira hierarchy from spec and tasks" + +[command.tools] +tools = [ + "jira-mcp-server/epic_create", + "jira-mcp-server/story_create" +] + +[command.script] +sh = ".specify/scripts/bash/check-prerequisites.sh --json" +ps = ".specify/scripts/powershell/check-prerequisites.ps1 -Json" + +[command.template] +content = """ +# Command implementation +## User Input +{{args}} + +## Steps +1. Load jira-config.yml from .specify/extensions/jira/ +2. Parse spec.md and tasks.md +3. Create Jira items +""" +``` + +**Transformation:** + +- Convert Markdown frontmatter to TOML +- Convert `$ARGUMENTS` to `{{args}}` +- Rewrite script paths + +### Registration Code + +**Location**: `src/specify_cli/extensions.py` + +```python +def register_extension_commands( + project_path: Path, + ai_assistant: str, + manifest: dict +) -> None: + """Register extension commands with AI agent.""" + + agent_config = AGENT_CONFIG.get(ai_assistant) + if not agent_config: + console.print(f"[yellow]Unknown agent: {ai_assistant}[/yellow]") + return + + ext_id = manifest['extension']['id'] + ext_dir = project_path / ".specify" / "extensions" / ext_id + agent_commands_dir = project_path / agent_config['folder'].rstrip('/') / "commands" + agent_commands_dir.mkdir(parents=True, exist_ok=True) + + for cmd_info in manifest['provides']['commands']: + cmd_name = cmd_info['name'] + source_file = ext_dir / cmd_info['file'] + + if not source_file.exists(): + console.print(f"[red]Command file not found:[/red] {cmd_info['file']}") + continue + + # Convert to agent-specific format + if ai_assistant == "claude": + dest_file = agent_commands_dir / f"{cmd_name}.md" + convert_to_claude(source_file, dest_file, ext_dir) + elif ai_assistant == "gemini": + dest_file = agent_commands_dir / f"{cmd_name}.toml" + convert_to_gemini(source_file, dest_file, ext_dir) + elif ai_assistant == "copilot": + dest_file = agent_commands_dir / f"{cmd_name}.md" + convert_to_copilot(source_file, dest_file, ext_dir) + # ... other agents + + console.print(f" ✓ Registered: {cmd_name}") + +def convert_to_claude( + source: Path, + dest: Path, + ext_dir: Path +) -> None: + """Convert universal command to Claude format.""" + + # Parse universal command + content = source.read_text() + frontmatter, body = parse_frontmatter(content) + + # Adjust script paths (relative to repo root) + if 'scripts' in frontmatter: + for key in frontmatter['scripts']: + frontmatter['scripts'][key] = adjust_path_for_repo_root( + frontmatter['scripts'][key] + ) + + # Inject extension context + body = inject_extension_context(body, ext_dir) + + # Write Claude command + dest.write_text(render_frontmatter(frontmatter) + "\n" + body) +``` + +--- + +## Configuration Management + +### Configuration File Hierarchy + +```yaml +# .specify/extensions/jira/jira-config.yml (Project config) +project: + key: "MSATS" + +hierarchy: + issue_type: "subtask" + +defaults: + epic: + labels: ["spec-driven", "typescript"] +``` + +```yaml +# .specify/extensions/jira/jira-config.local.yml (Local overrides - gitignored) +project: + key: "MYTEST" # Override for local testing +``` + +```bash +# Environment variables (highest precedence) +export SPECKIT_JIRA_PROJECT_KEY="DEVTEST" +``` + +### Config Loading Function + +**Location**: Extension command (e.g., `commands/specstoissues.md`) + +```markdown +## Load Configuration + +1. Run helper script to load and merge config: + +```bash +config_json=$(bash .specify/extensions/jira/scripts/parse-jira-config.sh) +echo "$config_json" +``` + +1. Parse JSON and use in subsequent steps + +``` + +**Script**: `.specify/extensions/jira/scripts/parse-jira-config.sh` + +```bash +#!/usr/bin/env bash +set -euo pipefail + +EXT_DIR=".specify/extensions/jira" +CONFIG_FILE="$EXT_DIR/jira-config.yml" +LOCAL_CONFIG="$EXT_DIR/jira-config.local.yml" + +# Start with defaults from extension.yml +defaults=$(yq eval '.defaults' "$EXT_DIR/extension.yml" -o=json) + +# Merge project config +if [ -f "$CONFIG_FILE" ]; then + project_config=$(yq eval '.' "$CONFIG_FILE" -o=json) + defaults=$(echo "$defaults $project_config" | jq -s '.[0] * .[1]') +fi + +# Merge local config +if [ -f "$LOCAL_CONFIG" ]; then + local_config=$(yq eval '.' "$LOCAL_CONFIG" -o=json) + defaults=$(echo "$defaults $local_config" | jq -s '.[0] * .[1]') +fi + +# Apply environment variable overrides +if [ -n "${SPECKIT_JIRA_PROJECT_KEY:-}" ]; then + defaults=$(echo "$defaults" | jq ".project.key = \"$SPECKIT_JIRA_PROJECT_KEY\"") +fi + +# Output merged config as JSON +echo "$defaults" +``` + +### Config Validation + +**In command file**: + +```markdown +## Validate Configuration + +1. Load config (from previous step) +2. Validate against schema from extension.yml: + +```python +import jsonschema + +schema = load_yaml(".specify/extensions/jira/extension.yml")['config_schema'] +config = json.loads(config_json) + +try: + jsonschema.validate(config, schema) +except jsonschema.ValidationError as e: + print(f"❌ Invalid jira-config.yml: {e.message}") + print(f" Path: {'.'.join(str(p) for p in e.path)}") + exit(1) +``` + +1. Proceed with validated config + +``` + +--- + +## Hook System + +### Hook Definition + +**In extension.yml:** + +```yaml +hooks: + after_tasks: + command: "speckit.jira.specstoissues" + optional: true + prompt: "Create Jira issues from tasks?" + description: "Automatically create Jira hierarchy" + condition: "config.project.key is set" +``` + +### Hook Registration + +**During extension installation**, record hooks in project config: + +**File**: `.specify/extensions.yml` (project-level extension config) + +```yaml +# Extensions installed in this project +installed: + - jira + - linear + +# Global extension settings +settings: + auto_execute_hooks: true # Prompt for optional hooks after commands + +# Hook configuration +hooks: + after_tasks: + - extension: jira + command: speckit.jira.specstoissues + enabled: true + optional: true + prompt: "Create Jira issues from tasks?" + + after_implement: + - extension: jira + command: speckit.jira.sync-status + enabled: true + optional: true + prompt: "Sync completion status to Jira?" +``` + +### Hook Execution + +**In core command** (e.g., `templates/commands/tasks.md`): + +Add at end of command: + +```markdown +## Extension Hooks + +After task generation completes, check for registered hooks: + +```bash +# Check if extensions.yml exists and has after_tasks hooks +if [ -f ".specify/extensions.yml" ]; then + # Parse hooks for after_tasks + hooks=$(yq eval '.hooks.after_tasks[] | select(.enabled == true)' .specify/extensions.yml -o=json) + + if [ -n "$hooks" ]; then + echo "" + echo "📦 Extension hooks available:" + + # Iterate hooks + echo "$hooks" | jq -c '.' | while read -r hook; do + extension=$(echo "$hook" | jq -r '.extension') + command=$(echo "$hook" | jq -r '.command') + optional=$(echo "$hook" | jq -r '.optional') + prompt_text=$(echo "$hook" | jq -r '.prompt') + + if [ "$optional" = "true" ]; then + # Prompt user + echo "" + read -p "$prompt_text (y/n) " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "▶ Executing: $command" + # Let AI agent execute the command + # (AI agent will see this and execute) + echo "EXECUTE_COMMAND: $command" + fi + else + # Auto-execute mandatory hooks + echo "▶ Executing: $command (required)" + echo "EXECUTE_COMMAND: $command" + fi + done + fi +fi +``` + +``` + +**AI Agent Handling:** + +The AI agent sees `EXECUTE_COMMAND: speckit.jira.specstoissues` in output and automatically invokes that command. + +**Alternative**: Direct call in agent context (if agent supports it): + +```python +# In AI agent's command execution engine +def execute_command_with_hooks(command_name: str, args: str): + # Execute main command + result = execute_command(command_name, args) + + # Check for hooks + hooks = load_hooks_for_phase(f"after_{command_name}") + for hook in hooks: + if hook.optional: + if confirm(hook.prompt): + execute_command(hook.command, args) + else: + execute_command(hook.command, args) + + return result +``` + +### Hook Conditions + +Extensions can specify **conditions** for hooks: + +```yaml +hooks: + after_tasks: + command: "speckit.jira.specstoissues" + optional: true + condition: "config.project.key is set and config.enabled == true" +``` + +**Condition evaluation** (in hook executor): + +```python +def should_execute_hook(hook: dict, config: dict) -> bool: + """Evaluate hook condition.""" + condition = hook.get('condition') + if not condition: + return True # No condition = always eligible + + # Simple expression evaluator + # "config.project.key is set" → check if config['project']['key'] exists + # "config.enabled == true" → check if config['enabled'] is True + + return eval_condition(condition, config) +``` + +--- + +## Extension Discovery & Catalog + +### Central Catalog + +**URL**: `https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json` + +**Format**: + +```json +{ + "schema_version": "1.0", + "updated_at": "2026-01-28T14:30:00Z", + "extensions": { + "jira": { + "name": "Jira Integration", + "id": "jira", + "description": "Create Jira Epics, Stories, and Issues from spec-kit artifacts", + "author": "Stats Perform", + "version": "1.0.0", + "download_url": "https://github.com/statsperform/spec-kit-jira/releases/download/v1.0.0/spec-kit-jira-1.0.0.zip", + "repository": "https://github.com/statsperform/spec-kit-jira", + "homepage": "https://github.com/statsperform/spec-kit-jira/blob/main/README.md", + "documentation": "https://github.com/statsperform/spec-kit-jira/blob/main/docs/", + "changelog": "https://github.com/statsperform/spec-kit-jira/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0,<2.0.0", + "tools": [ + { + "name": "jira-mcp-server", + "version": ">=1.0.0" + } + ] + }, + "tags": ["issue-tracking", "jira", "atlassian", "project-management"], + "verified": true, + "downloads": 1250, + "stars": 45 + }, + "linear": { + "name": "Linear Integration", + "id": "linear", + "description": "Sync spec-kit tasks with Linear issues", + "author": "Community", + "version": "0.9.0", + "download_url": "https://github.com/example/spec-kit-linear/releases/download/v0.9.0/spec-kit-linear-0.9.0.zip", + "repository": "https://github.com/example/spec-kit-linear", + "requires": { + "speckit_version": ">=0.1.0" + }, + "tags": ["issue-tracking", "linear"], + "verified": false + } + } +} +``` + +### Catalog Discovery Commands + +```bash +# List all available extensions +specify extension search + +# Search by keyword +specify extension search jira + +# Search by tag +specify extension search --tag issue-tracking + +# Show extension details +specify extension info jira +``` + +### Custom Catalogs + +Organizations can host private catalogs: + +```bash +# Add custom catalog +specify extension add-catalog https://internal.company.com/spec-kit/catalog.json + +# Set as default +specify extension set-catalog --default https://internal.company.com/spec-kit/catalog.json + +# List catalogs +specify extension catalogs +``` + +**Catalog priority**: + +1. Project-specific catalog (`.specify/extension-catalogs.yml`) +2. User-level catalog (`~/.specify/extension-catalogs.yml`) +3. Default GitHub catalog + +--- + +## CLI Commands + +### `specify extension` Subcommands + +#### `specify extension list` + +List installed extensions in current project. + +```bash +$ specify extension list + +Installed Extensions: + ✓ jira (v1.0.0) - Jira Integration + Commands: 3 | Hooks: 2 | Status: Enabled + + ✓ linear (v0.9.0) - Linear Integration + Commands: 1 | Hooks: 1 | Status: Enabled +``` + +**Options:** + +- `--available`: Show available (not installed) extensions from catalog +- `--all`: Show both installed and available + +#### `specify extension search [QUERY]` + +Search extension catalog. + +```bash +$ specify extension search jira + +Found 1 extension: + +┌─────────────────────────────────────────────────────────┐ +│ jira (v1.0.0) ✓ Verified │ +│ Jira Integration │ +│ │ +│ Create Jira Epics, Stories, and Issues from spec-kit │ +│ artifacts │ +│ │ +│ Author: Stats Perform │ +│ Tags: issue-tracking, jira, atlassian │ +│ Downloads: 1,250 │ +│ │ +│ Repository: github.com/statsperform/spec-kit-jira │ +│ Documentation: github.com/.../docs │ +└─────────────────────────────────────────────────────────┘ + +Install: specify extension add jira +``` + +**Options:** + +- `--tag TAG`: Filter by tag +- `--author AUTHOR`: Filter by author +- `--verified`: Show only verified extensions + +#### `specify extension info NAME` + +Show detailed information about an extension. + +```bash +$ specify extension info jira + +Jira Integration (jira) v1.0.0 + +Description: + Create Jira Epics, Stories, and Issues from spec-kit artifacts + +Author: Stats Perform +License: MIT +Repository: https://github.com/statsperform/spec-kit-jira +Documentation: https://github.com/statsperform/spec-kit-jira/blob/main/docs/ + +Requirements: + • Spec Kit: >=0.1.0,<2.0.0 + • Tools: jira-mcp-server (>=1.0.0) + +Provides: + Commands: + • speckit.jira.specstoissues - Create Jira hierarchy from spec and tasks + • speckit.jira.discover-fields - Discover Jira custom fields + • speckit.jira.sync-status - Sync task completion status + + Hooks: + • after_tasks - Prompt to create Jira issues + • after_implement - Prompt to sync status + +Tags: issue-tracking, jira, atlassian, project-management + +Downloads: 1,250 | Stars: 45 | Verified: ✓ + +Install: specify extension add jira +``` + +#### `specify extension add NAME` + +Install an extension. + +```bash +$ specify extension add jira + +Installing extension: Jira Integration + +✓ Downloaded spec-kit-jira-1.0.0.zip (245 KB) +✓ Validated manifest +✓ Checked compatibility (spec-kit 0.1.0 ≥ 0.1.0) +✓ Extracted to .specify/extensions/jira/ +✓ Registered 3 commands with claude +✓ Installed config template (jira-config.yml) + +⚠ Configuration required: + Edit .specify/extensions/jira/jira-config.yml to set your Jira project key + +Extension installed successfully! + +Next steps: + 1. Configure: vim .specify/extensions/jira/jira-config.yml + 2. Discover fields: /speckit.jira.discover-fields + 3. Use commands: /speckit.jira.specstoissues +``` + +**Options:** + +- `--from URL`: Install from custom URL or Git repo +- `--version VERSION`: Install specific version +- `--dev PATH`: Install from local path (development mode) +- `--no-register`: Skip command registration (manual setup) + +#### `specify extension remove NAME` + +Uninstall an extension. + +```bash +$ specify extension remove jira + +⚠ This will remove: + • 3 commands from AI agent + • Extension directory: .specify/extensions/jira/ + • Config file: jira-config.yml (will be backed up) + +Continue? (yes/no): yes + +✓ Unregistered commands +✓ Backed up config to .specify/extensions/.backup/jira-config.yml +✓ Removed extension directory +✓ Updated registry + +Extension removed successfully. + +To reinstall: specify extension add jira +``` + +**Options:** + +- `--keep-config`: Don't remove config file +- `--force`: Skip confirmation + +#### `specify extension update [NAME]` + +Update extension(s) to latest version. + +```bash +$ specify extension update jira + +Checking for updates... + +jira: 1.0.0 → 1.1.0 available + +Changes in v1.1.0: + • Added support for custom workflows + • Fixed issue with parallel tasks + • Improved error messages + +Update? (yes/no): yes + +✓ Downloaded spec-kit-jira-1.1.0.zip +✓ Validated manifest +✓ Backed up current version +✓ Extracted new version +✓ Preserved config file +✓ Re-registered commands + +Extension updated successfully! + +Changelog: https://github.com/statsperform/spec-kit-jira/blob/main/CHANGELOG.md#v110 +``` + +**Options:** + +- `--all`: Update all extensions +- `--check`: Check for updates without installing +- `--force`: Force update even if already latest + +#### `specify extension enable/disable NAME` + +Enable or disable an extension without removing it. + +```bash +$ specify extension disable jira + +✓ Disabled extension: jira + • Commands unregistered (but files preserved) + • Hooks will not execute + +To re-enable: specify extension enable jira +``` + +--- + +## Compatibility & Versioning + +### Semantic Versioning + +Extensions follow [SemVer 2.0.0](https://semver.org/): + +- **MAJOR**: Breaking changes (command API changes, config schema changes) +- **MINOR**: New features (new commands, new config options) +- **PATCH**: Bug fixes (no API changes) + +### Compatibility Checks + +**At installation:** + +```python +def check_compatibility(extension_manifest: dict) -> bool: + """Check if extension is compatible with current environment.""" + + requires = extension_manifest['requires'] + + # 1. Check spec-kit version + current_speckit = get_speckit_version() # e.g., "0.1.5" + required_speckit = requires['speckit_version'] # e.g., ">=0.1.0,<2.0.0" + + if not version_satisfies(current_speckit, required_speckit): + raise IncompatibleVersionError( + f"Extension requires spec-kit {required_speckit}, " + f"but {current_speckit} is installed. " + f"Upgrade spec-kit with: uv tool install specify-cli --force" + ) + + # 2. Check required tools + for tool in requires.get('tools', []): + tool_name = tool['name'] + tool_version = tool.get('version') + + if tool.get('required', True): + if not check_tool(tool_name): + raise MissingToolError( + f"Extension requires tool: {tool_name}\n" + f"Install from: {tool.get('install_url', 'N/A')}" + ) + + if tool_version: + installed = get_tool_version(tool_name, tool.get('check_command')) + if not version_satisfies(installed, tool_version): + raise IncompatibleToolVersionError( + f"Extension requires {tool_name} {tool_version}, " + f"but {installed} is installed" + ) + + # 3. Check required commands + for cmd in requires.get('commands', []): + if not command_exists(cmd): + raise MissingCommandError( + f"Extension requires core command: {cmd}\n" + f"Update spec-kit to latest version" + ) + + return True +``` + +### Deprecation Policy + +**Extension manifest can mark features as deprecated:** + +```yaml +provides: + commands: + - name: "speckit.jira.old-command" + file: "commands/old-command.md" + deprecated: true + deprecated_message: "Use speckit.jira.new-command instead" + removal_version: "2.0.0" +``` + +**At runtime, show warning:** + +``` +⚠️ Warning: /speckit.jira.old-command is deprecated + Use /speckit.jira.new-command instead + This command will be removed in v2.0.0 +``` + +--- + +## Security Considerations + +### Trust Model + +Extensions run with **same privileges as AI agent**: + +- Can execute shell commands +- Can read/write files in project +- Can make network requests + +**Trust boundary**: User must trust extension author. + +### Verification + +**Verified Extensions** (in catalog): + +- Published by known organizations (GitHub, Stats Perform, etc.) +- Code reviewed by spec-kit maintainers +- Marked with ✓ badge in catalog + +**Community Extensions**: + +- Not verified, use at own risk +- Show warning during installation: + + ``` + ⚠️ This extension is not verified. + Review code before installing: https://github.com/... + + Continue? (yes/no): + ``` + +### Sandboxing (Future) + +**Phase 2** (not in initial release): + +- Extensions declare required permissions in manifest +- CLI enforces permission boundaries +- Example permissions: `filesystem:read`, `network:external`, `env:read` + +```yaml +# Future extension.yml +permissions: + - "filesystem:read:.specify/extensions/jira/" # Can only read own config + - "filesystem:write:.specify/memory/" # Can write to memory + - "network:external:*.atlassian.net" # Can call Jira API + - "env:read:SPECKIT_JIRA_*" # Can read own env vars +``` + +### Package Integrity + +**Future**: Sign extension packages with GPG/Sigstore + +```yaml +# catalog.json +"jira": { + "download_url": "...", + "checksum": "sha256:abc123...", + "signature": "https://github.com/.../spec-kit-jira-1.0.0.sig", + "signing_key": "https://github.com/statsperform.gpg" +} +``` + +CLI verifies signature before extraction. + +--- + +## Migration Strategy + +### Backward Compatibility + +**Goal**: Existing spec-kit projects work without changes. + +**Strategy**: + +1. **Core commands unchanged**: `/speckit.tasks`, `/speckit.implement`, etc. remain in core + +2. **Optional extensions**: Users opt-in to extensions + +3. **Gradual migration**: Existing `taskstoissues` stays in core, Jira extension is alternative + +4. **Deprecation timeline**: + - **v0.2.0**: Introduce extension system, keep core `taskstoissues` + - **v0.3.0**: Mark core `taskstoissues` as "legacy" (still works) + - **v1.0.0**: Consider removing core `taskstoissues` in favor of extension + +### Migration Path for Users + +**Scenario 1**: User has no `taskstoissues` usage + +- No migration needed, extensions are opt-in + +**Scenario 2**: User uses core `taskstoissues` (GitHub Issues) + +- Works as before +- Optional: Migrate to `github-projects` extension for more features + +**Scenario 3**: User wants Jira (new requirement) + +- `specify extension add jira` +- Configure and use + +**Scenario 4**: User has custom scripts calling `taskstoissues` + +- Scripts still work (core command preserved) +- Migration guide shows how to call extension commands instead + +### Extension Migration Guide + +**For extension authors** (if core command becomes extension): + +```bash +# Old (core command) +/speckit.taskstoissues + +# New (extension command) +specify extension add github-projects +/speckit.github.taskstoissues +``` + +**Compatibility shim** (if needed): + +```yaml +# extension.yml +provides: + commands: + - name: "speckit.github.taskstoissues" + file: "commands/taskstoissues.md" + aliases: ["speckit.taskstoissues"] # Backward compatibility +``` + +AI agent registers both names, so old scripts work. + +--- + +## Implementation Phases + +### Phase 1: Core Extension System (Week 1-2) + +**Goal**: Basic extension infrastructure + +**Deliverables**: + +- [ ] Extension manifest schema (`extension.yml`) +- [ ] Extension directory structure +- [ ] CLI commands: + - [ ] `specify extension list` + - [ ] `specify extension add` (from URL) + - [ ] `specify extension remove` +- [ ] Extension registry (`.specify/extensions/.registry`) +- [ ] Command registration (Claude only initially) +- [ ] Basic validation (manifest schema, compatibility) +- [ ] Documentation (extension development guide) + +**Testing**: + +- [ ] Unit tests for manifest parsing +- [ ] Integration test: Install dummy extension +- [ ] Integration test: Register commands with Claude + +### Phase 2: Jira Extension (Week 3) + +**Goal**: First production extension + +**Deliverables**: + +- [ ] Create `spec-kit-jira` repository +- [ ] Port Jira functionality to extension +- [ ] Create `jira-config.yml` template +- [ ] Commands: + - [ ] `specstoissues.md` + - [ ] `discover-fields.md` + - [ ] `sync-status.md` +- [ ] Helper scripts +- [ ] Documentation (README, configuration guide, examples) +- [ ] Release v1.0.0 + +**Testing**: + +- [ ] Test on `eng-msa-ts` project +- [ ] Verify spec→Epic, phase→Story, task→Issue mapping +- [ ] Test configuration loading and validation +- [ ] Test custom field application + +### Phase 3: Extension Catalog (Week 4) + +**Goal**: Discovery and distribution + +**Deliverables**: + +- [ ] Central catalog (`extensions/catalog.json` in spec-kit repo) +- [ ] Catalog fetch and parsing +- [ ] CLI commands: + - [ ] `specify extension search` + - [ ] `specify extension info` +- [ ] Catalog publishing process (GitHub Action) +- [ ] Documentation (how to publish extensions) + +**Testing**: + +- [ ] Test catalog fetch +- [ ] Test extension search/filtering +- [ ] Test catalog caching + +### Phase 4: Advanced Features (Week 5-6) + +**Goal**: Hooks, updates, multi-agent support + +**Deliverables**: + +- [ ] Hook system (`hooks` in extension.yml) +- [ ] Hook registration and execution +- [ ] Project extensions config (`.specify/extensions.yml`) +- [ ] CLI commands: + - [ ] `specify extension update` + - [ ] `specify extension enable/disable` +- [ ] Command registration for multiple agents (Gemini, Copilot) +- [ ] Extension update notifications +- [ ] Configuration layer resolution (project, local, env) + +**Testing**: + +- [ ] Test hooks in core commands +- [ ] Test extension updates (preserve config) +- [ ] Test multi-agent registration + +### Phase 5: Polish & Documentation (Week 7) + +**Goal**: Production ready + +**Deliverables**: + +- [ ] Comprehensive documentation: + - [ ] User guide (installing/using extensions) + - [ ] Extension development guide + - [ ] Extension API reference + - [ ] Migration guide (core → extension) +- [ ] Error messages and validation improvements +- [ ] CLI help text updates +- [ ] Example extension template (cookiecutter) +- [ ] Blog post / announcement +- [ ] Video tutorial + +**Testing**: + +- [ ] End-to-end testing on multiple projects +- [ ] Community beta testing +- [ ] Performance testing (large projects) + +--- + +## Open Questions + +### 1. Extension Namespace + +**Question**: Should extension commands use namespace prefix? + +**Options**: + +- A) Prefixed: `/speckit.jira.specstoissues` (explicit, avoids conflicts) +- B) Short alias: `/jira.specstoissues` (shorter, less verbose) +- C) Both: Register both names, prefer prefixed in docs + +**Recommendation**: C (both), prefixed is canonical + +--- + +### 2. Config File Location + +**Question**: Where should extension configs live? + +**Options**: + +- A) Extension directory: `.specify/extensions/jira/jira-config.yml` (encapsulated) +- B) Root level: `.specify/jira-config.yml` (more visible) +- C) Unified: `.specify/extensions.yml` (all extension configs in one file) + +**Recommendation**: A (extension directory), cleaner separation + +--- + +### 3. Command File Format + +**Question**: Should extensions use universal format or agent-specific? + +**Options**: + +- A) Universal Markdown: Extensions write once, CLI converts per-agent +- B) Agent-specific: Extensions provide separate files for each agent +- C) Hybrid: Universal default, agent-specific overrides + +**Recommendation**: A (universal), reduces duplication + +--- + +### 4. Hook Execution Model + +**Question**: How should hooks execute? + +**Options**: + +- A) AI agent interprets: Core commands output `EXECUTE_COMMAND: name` +- B) CLI executes: Core commands call `specify extension hook after_tasks` +- C) Agent built-in: Extension system built into AI agent (Claude SDK) + +**Recommendation**: A initially (simpler), move to C long-term + +--- + +### 5. Extension Distribution + +**Question**: How should extensions be packaged? + +**Options**: + +- A) ZIP archives: Downloaded from GitHub releases +- B) Git repos: Cloned directly (`git clone`) +- C) Python packages: Installable via `uv tool install` + +**Recommendation**: A (ZIP), simpler for non-Python extensions in future + +--- + +### 6. Multi-Version Support + +**Question**: Can multiple versions of same extension coexist? + +**Options**: + +- A) Single version: Only one version installed at a time +- B) Multi-version: Side-by-side versions (`.specify/extensions/jira@1.0/`, `.specify/extensions/jira@2.0/`) +- C) Per-branch: Different branches use different versions + +**Recommendation**: A initially (simpler), consider B in future if needed + +--- + +## Appendices + +### Appendix A: Example Extension Structure + +**Complete structure of `spec-kit-jira` extension:** + +``` +spec-kit-jira/ +├── README.md # Overview, features, installation +├── LICENSE # MIT license +├── CHANGELOG.md # Version history +├── .gitignore # Ignore local configs +│ +├── extension.yml # Extension manifest (required) +├── jira-config.template.yml # Config template +│ +├── commands/ # Command files +│ ├── specstoissues.md # Main command +│ ├── discover-fields.md # Helper: Discover custom fields +│ └── sync-status.md # Helper: Sync completion status +│ +├── scripts/ # Helper scripts +│ ├── parse-jira-config.sh # Config loader (bash) +│ ├── parse-jira-config.ps1 # Config loader (PowerShell) +│ └── validate-jira-connection.sh # Connection test +│ +├── docs/ # Documentation +│ ├── installation.md # Installation guide +│ ├── configuration.md # Configuration reference +│ ├── usage.md # Usage examples +│ ├── troubleshooting.md # Common issues +│ └── examples/ +│ ├── eng-msa-ts-config.yml # Real-world config example +│ └── simple-project.yml # Minimal config example +│ +├── tests/ # Tests (optional) +│ ├── test-extension.sh # Extension validation +│ └── test-commands.sh # Command execution tests +│ +└── .github/ # GitHub integration + └── workflows/ + └── release.yml # Automated releases +``` + +### Appendix B: Extension Development Guide (Outline) + +**Documentation for creating new extensions:** + +1. **Getting Started** + - Prerequisites (tools needed) + - Extension template (cookiecutter) + - Directory structure + +2. **Extension Manifest** + - Schema reference + - Required vs optional fields + - Versioning guidelines + +3. **Command Development** + - Universal command format + - Frontmatter specification + - Template variables + - Script references + +4. **Configuration** + - Config file structure + - Schema validation + - Layered config resolution + - Environment variable overrides + +5. **Hooks** + - Available hook points + - Hook registration + - Conditional execution + - Best practices + +6. **Testing** + - Local development setup + - Testing with `--dev` flag + - Validation checklist + - Integration testing + +7. **Publishing** + - Packaging (ZIP format) + - GitHub releases + - Catalog submission + - Versioning strategy + +8. **Examples** + - Minimal extension + - Extension with hooks + - Extension with configuration + - Extension with multiple commands + +### Appendix C: Compatibility Matrix + +**Planned support matrix:** + +| Extension Feature | Spec Kit Version | AI Agent Support | +|-------------------|------------------|------------------| +| Basic commands | 0.2.0+ | Claude, Gemini, Copilot | +| Hooks (after_tasks) | 0.3.0+ | Claude, Gemini | +| Config validation | 0.2.0+ | All | +| Multiple catalogs | 0.4.0+ | All | +| Permissions (sandboxing) | 1.0.0+ | TBD | + +### Appendix D: Extension Catalog Schema + +**Full schema for `catalog.json`:** + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["schema_version", "updated_at", "extensions"], + "properties": { + "schema_version": { + "type": "string", + "pattern": "^\\d+\\.\\d+$" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "extensions": { + "type": "object", + "patternProperties": { + "^[a-z0-9-]+$": { + "type": "object", + "required": ["name", "id", "version", "download_url", "repository"], + "properties": { + "name": { "type": "string" }, + "id": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "description": { "type": "string" }, + "author": { "type": "string" }, + "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" }, + "download_url": { "type": "string", "format": "uri" }, + "repository": { "type": "string", "format": "uri" }, + "homepage": { "type": "string", "format": "uri" }, + "documentation": { "type": "string", "format": "uri" }, + "changelog": { "type": "string", "format": "uri" }, + "license": { "type": "string" }, + "requires": { + "type": "object", + "properties": { + "speckit_version": { "type": "string" }, + "tools": { + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" } + } + } + } + } + }, + "tags": { + "type": "array", + "items": { "type": "string" } + }, + "verified": { "type": "boolean" }, + "downloads": { "type": "integer" }, + "stars": { "type": "integer" }, + "checksum": { "type": "string" } + } + } + } + } + } +} +``` + +--- + +## Summary & Next Steps + +This RFC proposes a comprehensive extension system for Spec Kit that: + +1. **Keeps core lean** while enabling unlimited integrations +2. **Supports multiple agents** (Claude, Gemini, Copilot, etc.) +3. **Provides clear extension API** for community contributions +4. **Enables independent versioning** of extensions and core +5. **Includes safety mechanisms** (validation, compatibility checks) + +### Immediate Next Steps + +1. **Review this RFC** with stakeholders +2. **Gather feedback** on open questions +3. **Refine design** based on feedback +4. **Proceed to Phase A**: Implement core extension system +5. **Then Phase B**: Build Jira extension as proof-of-concept + +--- + +## Questions for Discussion + +1. Does the extension architecture meet your needs for Jira integration? +2. Are there additional hook points we should consider? +3. Should we support extension dependencies (extension A requires extension B)? +4. How should we handle extension deprecation/removal from catalog? +5. What level of sandboxing/permissions do we need in v1.0? diff --git a/extensions/catalog.json b/extensions/catalog.json new file mode 100644 index 0000000000..a7e84342c0 --- /dev/null +++ b/extensions/catalog.json @@ -0,0 +1,45 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-01-28T15:54:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", + "extensions": { + "jira": { + "name": "Jira Integration", + "id": "jira", + "description": "Create Jira Epics, Stories, and Issues from spec-kit artifacts", + "author": "Michal Bachorik", + "version": "2.0.0", + "download_url": "https://github.com/mbachorik/spec-kit-jira/archive/refs/tags/v2.0.0.zip", + "repository": "https://github.com/mbachorik/spec-kit-jira", + "homepage": "https://github.com/mbachorik/spec-kit-jira", + "documentation": "https://github.com/mbachorik/spec-kit-jira/blob/main/docs/", + "changelog": "https://github.com/mbachorik/spec-kit-jira/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "atlassian/atlassian-mcp-server", + "version": ">=1.0.0", + "required": true + } + ] + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "issue-tracking", + "jira", + "atlassian", + "project-management" + ], + "verified": true, + "downloads": 0, + "stars": 0, + "created_at": "2026-01-28T15:54:00Z", + "updated_at": "2026-01-28T15:54:00Z" + } + } +} \ No newline at end of file diff --git a/extensions/template/.gitignore b/extensions/template/.gitignore new file mode 100644 index 0000000000..1f7b132a76 --- /dev/null +++ b/extensions/template/.gitignore @@ -0,0 +1,39 @@ +# Local configuration overrides +*-config.local.yml + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Build artifacts +dist/ +build/ +*.egg-info/ + +# Temporary files +*.tmp +.cache/ diff --git a/extensions/template/CHANGELOG.md b/extensions/template/CHANGELOG.md new file mode 100644 index 0000000000..2f2ac13a5a --- /dev/null +++ b/extensions/template/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to this extension will be documented in this file. + +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). + +## [Unreleased] + +### Planned + +- Feature ideas for future versions +- Enhancements +- Bug fixes + +## [1.0.0] - YYYY-MM-DD + +### Added + +- Initial release of extension +- Command: `/speckit.my-extension.example` - Example command functionality +- Configuration system with template +- Documentation and examples + +### Features + +- Feature 1 description +- Feature 2 description +- Feature 3 description + +### Requirements + +- Spec Kit: >=0.1.0 +- External dependencies (if any) + +--- + +[Unreleased]: https://github.com/your-org/spec-kit-my-extension/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/your-org/spec-kit-my-extension/releases/tag/v1.0.0 diff --git a/extensions/template/EXAMPLE-README.md b/extensions/template/EXAMPLE-README.md new file mode 100644 index 0000000000..485285ae65 --- /dev/null +++ b/extensions/template/EXAMPLE-README.md @@ -0,0 +1,158 @@ +# EXAMPLE: Extension README + +This is an example of what your extension README should look like after customization. +**Delete this file and replace README.md with content similar to this.** + +--- + +# My Extension + + + +Brief description of what your extension does and why it's useful. + +## Features + + + +- Feature 1: Description +- Feature 2: Description +- Feature 3: Description + +## Installation + +```bash +# Install from catalog +specify extension add my-extension + +# Or install from local development directory +specify extension add --dev /path/to/my-extension +``` + +## Configuration + +1. Create configuration file: + + ```bash + cp .specify/extensions/my-extension/config-template.yml \ + .specify/extensions/my-extension/my-extension-config.yml + ``` + +2. Edit configuration: + + ```bash + vim .specify/extensions/my-extension/my-extension-config.yml + ``` + +3. Set required values: + + ```yaml + connection: + url: "https://api.example.com" + api_key: "your-api-key" + + project: + id: "your-project-id" + ``` + +## Usage + + + +### Command: example + +Description of what this command does. + +```bash +# In Claude Code +> /speckit.my-extension.example +``` + +**Prerequisites**: + +- Prerequisite 1 +- Prerequisite 2 + +**Output**: + +- What this command produces +- Where results are saved + +## Configuration Reference + + + +### Connection Settings + +| Setting | Type | Required | Description | +|---------|------|----------|-------------| +| `connection.url` | string | Yes | API endpoint URL | +| `connection.api_key` | string | Yes | API authentication key | + +### Project Settings + +| Setting | Type | Required | Description | +|---------|------|----------|-------------| +| `project.id` | string | Yes | Project identifier | +| `project.workspace` | string | No | Workspace or organization | + +## Environment Variables + +Override configuration with environment variables: + +```bash +# Override connection settings +export SPECKIT_MY_EXTENSION_CONNECTION_URL="https://custom-api.com" +export SPECKIT_MY_EXTENSION_CONNECTION_API_KEY="custom-key" +``` + +## Examples + + + +### Example 1: Basic Workflow + +```bash +# Step 1: Create specification +> /speckit.spec + +# Step 2: Generate tasks +> /speckit.tasks + +# Step 3: Use extension +> /speckit.my-extension.example +``` + +## Troubleshooting + + + +### Issue: Configuration not found + +**Solution**: Create config from template (see Configuration section) + +### Issue: Command not available + +**Solutions**: + +1. Check extension is installed: `specify extension list` +2. Restart AI agent +3. Reinstall extension + +## License + +MIT License - see LICENSE file + +## Support + +- **Issues**: +- **Spec Kit Docs**: + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for version history. + +--- + +*Extension Version: 1.0.0* +*Spec Kit: >=0.1.0* diff --git a/extensions/template/LICENSE b/extensions/template/LICENSE new file mode 100644 index 0000000000..8cb3215e44 --- /dev/null +++ b/extensions/template/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 [Your Name or Organization] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/template/README.md b/extensions/template/README.md new file mode 100644 index 0000000000..1f88d9ce40 --- /dev/null +++ b/extensions/template/README.md @@ -0,0 +1,79 @@ +# Extension Template + +Starter template for creating a Spec Kit extension. + +## Quick Start + +1. **Copy this template**: + + ```bash + cp -r extensions/template my-extension + cd my-extension + ``` + +2. **Customize `extension.yml`**: + - Change extension ID, name, description + - Update author and repository + - Define your commands + +3. **Create commands**: + - Add command files in `commands/` directory + - Use Markdown format with YAML frontmatter + +4. **Create config template**: + - Define configuration options + - Document all settings + +5. **Write documentation**: + - Update README.md with usage instructions + - Add examples + +6. **Test locally**: + + ```bash + cd /path/to/spec-kit-project + specify extension add --dev /path/to/my-extension + ``` + +7. **Publish** (optional): + - Create GitHub repository + - Create release + - Submit to catalog (see EXTENSION-PUBLISHING-GUIDE.md) + +## Files in This Template + +- `extension.yml` - Extension manifest (CUSTOMIZE THIS) +- `config-template.yml` - Configuration template (CUSTOMIZE THIS) +- `commands/example.md` - Example command (REPLACE THIS) +- `README.md` - Extension documentation (REPLACE THIS) +- `LICENSE` - MIT License (REVIEW THIS) +- `CHANGELOG.md` - Version history (UPDATE THIS) +- `.gitignore` - Git ignore rules + +## Customization Checklist + +- [ ] Update `extension.yml` with your extension details +- [ ] Change extension ID to your extension name +- [ ] Update author information +- [ ] Define your commands +- [ ] Create command files in `commands/` +- [ ] Update config template +- [ ] Write README with usage instructions +- [ ] Add examples +- [ ] Update LICENSE if needed +- [ ] Test extension locally +- [ ] Create git repository +- [ ] Create first release + +## Need Help? + +- **Development Guide**: See EXTENSION-DEVELOPMENT-GUIDE.md +- **API Reference**: See EXTENSION-API-REFERENCE.md +- **Publishing Guide**: See EXTENSION-PUBLISHING-GUIDE.md +- **User Guide**: See EXTENSION-USER-GUIDE.md + +## Template Version + +- Version: 1.0.0 +- Last Updated: 2026-01-28 +- Compatible with Spec Kit: >=0.1.0 diff --git a/extensions/template/commands/example.md b/extensions/template/commands/example.md new file mode 100644 index 0000000000..c2b6b30c57 --- /dev/null +++ b/extensions/template/commands/example.md @@ -0,0 +1,210 @@ +--- +description: "Example command that demonstrates extension functionality" +# CUSTOMIZE: List MCP tools this command uses +tools: + - 'example-mcp-server/example_tool' +--- + +# Example Command + + + +This is an example command that demonstrates how to create commands for Spec Kit extensions. + +## Purpose + +Describe what this command does and when to use it. + +## Prerequisites + +List requirements before using this command: + +1. Prerequisite 1 (e.g., "MCP server configured") +2. Prerequisite 2 (e.g., "Configuration file exists") +3. Prerequisite 3 (e.g., "Valid API credentials") + +## User Input + +$ARGUMENTS + +## Steps + +### Step 1: Load Configuration + + + +Load extension configuration from the project: + +``bash +config_file=".specify/extensions/my-extension/my-extension-config.yml" + +if [ ! -f "$config_file" ]; then + echo "❌ Error: Configuration not found at $config_file" + echo "Run 'specify extension add my-extension' to install and configure" + exit 1 +fi + +# Read configuration values + +setting_value=$(yq eval '.settings.key' "$config_file") + +# Apply environment variable overrides + +setting_value="${SPECKIT_MY_EXTENSION_KEY:-$setting_value}" + +# Validate configuration + +if [ -z "$setting_value" ]; then + echo "❌ Error: Configuration value not set" + echo "Edit $config_file and set 'settings.key'" + exit 1 +fi + +echo "📋 Configuration loaded: $setting_value" +`` + +### Step 2: Perform Main Action + + + +Describe what this step does: + +``markdown +Use MCP tools to perform the main action: + +- Tool: example-mcp-server example_tool +- Parameters: { "key": "$setting_value" } + +This calls the MCP server tool to execute the operation. +`` + +### Step 3: Process Results + + + +Process the results and provide output: + +`` bash +echo "" +echo "✅ Command completed successfully!" +echo "" +echo "Results:" +echo " • Item 1: Value" +echo " • Item 2: Value" +echo "" +`` + +### Step 4: Save Output (Optional) + +Save results to a file if needed: + +``bash +output_file=".specify/my-extension-output.json" + +cat > "$output_file" < + +This command uses the following configuration from `my-extension-config.yml`: + +- **settings.key**: Description of what this setting does + - Type: string + - Required: Yes + - Example: `"example-value"` + +- **settings.another_key**: Description of another setting + - Type: boolean + - Required: No + - Default: `false` + - Example: `true` + +## Environment Variables + + + +Configuration can be overridden with environment variables: + +- `SPECKIT_MY_EXTENSION_KEY` - Overrides `settings.key` +- `SPECKIT_MY_EXTENSION_ANOTHER_KEY` - Overrides `settings.another_key` + +Example: +``bash +export SPECKIT_MY_EXTENSION_KEY="override-value" +`` + +## Troubleshooting + + + +### "Configuration not found" + +**Solution**: Install the extension and create configuration: +``bash +specify extension add my-extension +cp .specify/extensions/my-extension/config-template.yml \ + .specify/extensions/my-extension/my-extension-config.yml +`` + +### "MCP tool not available" + +**Solution**: Ensure MCP server is configured in your AI agent settings. + +### "Permission denied" + +**Solution**: Check credentials and permissions in the external service. + +## Notes + + + +- This command requires an active connection to the external service +- Results are cached for performance +- Re-run the command to refresh data + +## Examples + + + +### Example 1: Basic Usage + +``bash + +# Run with default configuration +> +> /speckit.my-extension.example +`` + +### Example 2: With Environment Override + +``bash + +# Override configuration with environment variable + +export SPECKIT_MY_EXTENSION_KEY="custom-value" +> /speckit.my-extension.example +`` + +### Example 3: After Core Command + +``bash + +# Use as part of a workflow +> +> /speckit.tasks +> /speckit.my-extension.example +`` + +--- + +*For more information, see the extension README or run `specify extension info my-extension`* diff --git a/extensions/template/config-template.yml b/extensions/template/config-template.yml new file mode 100644 index 0000000000..d424a3507e --- /dev/null +++ b/extensions/template/config-template.yml @@ -0,0 +1,75 @@ +# Extension Configuration Template +# Copy this to my-extension-config.yml and customize for your project + +# CUSTOMIZE: Add your configuration sections below + +# Example: Connection settings +connection: + # URL to external service + url: "" # REQUIRED: e.g., "https://api.example.com" + + # API key or token + api_key: "" # REQUIRED: Your API key + +# Example: Project settings +project: + # Project identifier + id: "" # REQUIRED: e.g., "my-project" + + # Workspace or organization + workspace: "" # OPTIONAL: e.g., "my-org" + +# Example: Feature flags +features: + # Enable/disable main functionality + enabled: true + + # Automatic synchronization + auto_sync: false + + # Verbose logging + verbose: false + +# Example: Default values +defaults: + # Labels to apply + labels: [] # e.g., ["automated", "spec-kit"] + + # Priority level + priority: "medium" # Options: "low", "medium", "high" + + # Assignee + assignee: "" # OPTIONAL: Default assignee + +# Example: Field mappings +# Map internal names to external field IDs +field_mappings: + # Example mappings + # internal_field: "external_field_id" + # status: "customfield_10001" + +# Example: Advanced settings +advanced: + # Timeout in seconds + timeout: 30 + + # Retry attempts + retry_count: 3 + + # Cache duration in seconds + cache_duration: 3600 + +# Environment Variable Overrides: +# You can override any setting with environment variables using this pattern: +# SPECKIT_MY_EXTENSION_{SECTION}_{KEY} +# +# Examples: +# - SPECKIT_MY_EXTENSION_CONNECTION_API_KEY: Override connection.api_key +# - SPECKIT_MY_EXTENSION_PROJECT_ID: Override project.id +# - SPECKIT_MY_EXTENSION_FEATURES_ENABLED: Override features.enabled +# +# Note: Use uppercase and replace dots with underscores + +# Local Overrides: +# For local development, create my-extension-config.local.yml (gitignored) +# to override settings without affecting the team configuration diff --git a/extensions/template/extension.yml b/extensions/template/extension.yml new file mode 100644 index 0000000000..2f51ae7fd5 --- /dev/null +++ b/extensions/template/extension.yml @@ -0,0 +1,97 @@ +schema_version: "1.0" + +extension: + # CUSTOMIZE: Change 'my-extension' to your extension ID (lowercase, hyphen-separated) + id: "my-extension" + + # CUSTOMIZE: Human-readable name for your extension + name: "My Extension" + + # CUSTOMIZE: Update version when releasing (semantic versioning: X.Y.Z) + version: "1.0.0" + + # CUSTOMIZE: Brief description (under 200 characters) + description: "Brief description of what your extension does" + + # CUSTOMIZE: Your name or organization name + author: "Your Name" + + # CUSTOMIZE: GitHub repository URL (create before publishing) + repository: "https://github.com/your-org/spec-kit-my-extension" + + # REVIEW: License (MIT is recommended for open source) + license: "MIT" + + # CUSTOMIZE: Extension homepage (can be same as repository) + homepage: "https://github.com/your-org/spec-kit-my-extension" + +# Requirements for this extension +requires: + # CUSTOMIZE: Minimum spec-kit version required + # Use >=X.Y.Z for minimum version + # Use >=X.Y.Z,=0.1.0" + + # CUSTOMIZE: Add MCP tools or other dependencies + # Remove if no external tools required + tools: + - name: "example-mcp-server" + version: ">=1.0.0" + required: true + +# Commands provided by this extension +provides: + commands: + # CUSTOMIZE: Define your commands + # Pattern: speckit.{extension-id}.{command-name} + - name: "speckit.my-extension.example" + file: "commands/example.md" + description: "Example command that demonstrates functionality" + # Optional: Add aliases for shorter command names + aliases: ["speckit.example"] + + # ADD MORE COMMANDS: Copy this block for each command + # - name: "speckit.my-extension.another-command" + # file: "commands/another-command.md" + # description: "Another command" + + # CUSTOMIZE: Define configuration files + config: + - name: "my-extension-config.yml" + template: "config-template.yml" + description: "Extension configuration" + required: true # Set to false if config is optional + +# CUSTOMIZE: Define hooks (optional) +# Remove if no hooks needed +hooks: + # Hook that runs after /speckit.tasks + after_tasks: + command: "speckit.my-extension.example" + optional: true # User will be prompted + prompt: "Run example command?" + description: "Demonstrates hook functionality" + condition: null # Future: conditional execution + + # ADD MORE HOOKS: Copy this block for other events + # after_implement: + # command: "speckit.my-extension.another" + # optional: false # Auto-execute without prompting + # description: "Runs automatically after implementation" + +# CUSTOMIZE: Add relevant tags (2-5 recommended) +# Used for discovery in catalog +tags: + - "example" + - "template" + # ADD MORE: "category", "tool-name", etc. + +# CUSTOMIZE: Default configuration values (optional) +# These are merged with user config +defaults: + # Example default values + feature: + enabled: true + auto_sync: false + + # ADD MORE: Any default settings for your extension diff --git a/pyproject.toml b/pyproject.toml index fb972adc7c..de6fe5fe9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.0.22" +version = "0.1.0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ @@ -10,6 +10,8 @@ dependencies = [ "platformdirs", "readchar", "truststore>=0.10.4", + "pyyaml>=6.0", + "packaging>=23.0", ] [project.scripts] @@ -22,3 +24,30 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/specify_cli"] +[project.optional-dependencies] +test = [ + "pytest>=7.0", + "pytest-cov>=4.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--strict-markers", + "--tb=short", +] + +[tool.coverage.run] +source = ["src"] +omit = ["*/tests/*", "*/__pycache__/*"] + +[tool.coverage.report] +precision = 2 +show_missing = true +skip_covered = false + + diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1dedb31949..397a099762 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1361,6 +1361,626 @@ def version(): console.print(panel) console.print() + +# ===== Extension Commands ===== + +extension_app = typer.Typer( + name="extension", + help="Manage spec-kit extensions", + add_completion=False, +) +app.add_typer(extension_app, name="extension") + + +def get_speckit_version() -> str: + """Get current spec-kit version.""" + import importlib.metadata + try: + return importlib.metadata.version("specify-cli") + except Exception: + # Fallback: try reading from pyproject.toml + try: + import tomllib + pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" + if pyproject_path.exists(): + with open(pyproject_path, "rb") as f: + data = tomllib.load(f) + return data.get("project", {}).get("version", "unknown") + except Exception: + pass + return "unknown" + + +@extension_app.command("list") +def extension_list( + available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"), + all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"), +): + """List installed extensions.""" + from .extensions import ExtensionManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + 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) + + manager = ExtensionManager(project_root) + installed = manager.list_installed() + + if not installed and not (available or all_extensions): + console.print("[yellow]No extensions installed.[/yellow]") + console.print("\nInstall an extension with:") + console.print(" specify extension add ") + return + + if installed: + console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n") + + for ext in installed: + status_icon = "✓" if ext["enabled"] else "✗" + status_color = "green" if ext["enabled"] else "red" + + console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})") + console.print(f" {ext['description']}") + console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}") + console.print() + + if available or all_extensions: + console.print("\nInstall an extension:") + console.print(" [cyan]specify extension add [/cyan]") + + +@extension_app.command("add") +def extension_add( + extension: str = typer.Argument(help="Extension name or path"), + dev: bool = typer.Option(False, "--dev", help="Install from local directory"), + from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"), +): + """Install an extension.""" + from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + 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) + + manager = ExtensionManager(project_root) + speckit_version = get_speckit_version() + + try: + with console.status(f"[cyan]Installing extension: {extension}[/cyan]"): + if dev: + # Install from local directory + source_path = Path(extension).expanduser().resolve() + if not source_path.exists(): + console.print(f"[red]Error:[/red] Directory not found: {source_path}") + raise typer.Exit(1) + + if not (source_path / "extension.yml").exists(): + console.print(f"[red]Error:[/red] No extension.yml found in {source_path}") + raise typer.Exit(1) + + manifest = manager.install_from_directory(source_path, speckit_version) + + elif from_url: + # Install from URL (ZIP file) + import urllib.request + import urllib.error + + console.print(f"Downloading from {from_url}...") + + # Download ZIP to temp location + download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads" + download_dir.mkdir(parents=True, exist_ok=True) + zip_path = download_dir / f"{extension}-url-download.zip" + + try: + with urllib.request.urlopen(from_url, timeout=60) as response: + zip_data = response.read() + zip_path.write_bytes(zip_data) + + # Install from downloaded ZIP + manifest = manager.install_from_zip(zip_path, speckit_version) + except urllib.error.URLError as e: + console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}") + raise typer.Exit(1) + finally: + # Clean up downloaded ZIP + if zip_path.exists(): + zip_path.unlink() + + else: + # Install from catalog + catalog = ExtensionCatalog(project_root) + + # Check if extension exists in catalog + ext_info = catalog.get_extension_info(extension) + if not ext_info: + console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog") + console.print("\nSearch available extensions:") + console.print(" specify extension search") + raise typer.Exit(1) + + # Download extension ZIP + console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...") + zip_path = catalog.download_extension(extension) + + try: + # Install from downloaded ZIP + manifest = manager.install_from_zip(zip_path, speckit_version) + finally: + # Clean up downloaded ZIP + if zip_path.exists(): + zip_path.unlink() + + console.print(f"\n[green]✓[/green] Extension installed successfully!") + console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})") + console.print(f" {manifest.description}") + console.print(f"\n[bold cyan]Provided commands:[/bold cyan]") + for cmd in manifest.commands: + console.print(f" • {cmd['name']} - {cmd.get('description', '')}") + + console.print(f"\n[yellow]⚠[/yellow] Configuration may be required") + console.print(f" Check: .specify/extensions/{manifest.id}/") + + except ValidationError as e: + console.print(f"\n[red]Validation Error:[/red] {e}") + raise typer.Exit(1) + except CompatibilityError as e: + console.print(f"\n[red]Compatibility Error:[/red] {e}") + raise typer.Exit(1) + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {e}") + raise typer.Exit(1) + + +@extension_app.command("remove") +def extension_remove( + extension: str = typer.Argument(help="Extension ID to remove"), + keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"), + force: bool = typer.Option(False, "--force", help="Skip confirmation"), +): + """Uninstall an extension.""" + from .extensions import ExtensionManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + 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) + + manager = ExtensionManager(project_root) + + # Check if extension is installed + if not manager.registry.is_installed(extension): + console.print(f"[red]Error:[/red] Extension '{extension}' is not installed") + raise typer.Exit(1) + + # Get extension info + ext_manifest = manager.get_extension(extension) + if ext_manifest: + ext_name = ext_manifest.name + cmd_count = len(ext_manifest.commands) + else: + ext_name = extension + cmd_count = 0 + + # Confirm removal + if not force: + console.print(f"\n[yellow]⚠ This will remove:[/yellow]") + console.print(f" • {cmd_count} commands from AI agent") + console.print(f" • Extension directory: .specify/extensions/{extension}/") + if not keep_config: + console.print(f" • Config files (will be backed up)") + console.print() + + confirm = typer.confirm("Continue?") + if not confirm: + console.print("Cancelled") + raise typer.Exit(0) + + # Remove extension + success = manager.remove(extension, keep_config=keep_config) + + if success: + console.print(f"\n[green]✓[/green] Extension '{ext_name}' removed successfully") + if keep_config: + console.print(f"\nConfig files preserved in .specify/extensions/{extension}/") + else: + console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension}/") + console.print(f"\nTo reinstall: specify extension add {extension}") + else: + console.print(f"[red]Error:[/red] Failed to remove extension") + raise typer.Exit(1) + + +@extension_app.command("search") +def extension_search( + query: str = typer.Argument(None, help="Search query (optional)"), + tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"), + author: Optional[str] = typer.Option(None, "--author", help="Filter by author"), + verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"), +): + """Search for available extensions in catalog.""" + from .extensions import ExtensionCatalog, ExtensionError + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + 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: + console.print("🔍 Searching extension catalog...") + results = catalog.search(query=query, tag=tag, author=author, verified_only=verified) + + if not results: + console.print("\n[yellow]No extensions found matching criteria[/yellow]") + if query or tag or author or verified: + console.print("\nTry:") + console.print(" • Broader search terms") + console.print(" • Remove filters") + console.print(" • specify extension search (show all)") + raise typer.Exit(0) + + console.print(f"\n[green]Found {len(results)} extension(s):[/green]\n") + + for ext in results: + # Extension header + verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else "" + console.print(f"[bold]{ext['name']}[/bold] (v{ext['version']}){verified_badge}") + console.print(f" {ext['description']}") + + # Metadata + console.print(f"\n [dim]Author:[/dim] {ext.get('author', 'Unknown')}") + if ext.get('tags'): + tags_str = ", ".join(ext['tags']) + console.print(f" [dim]Tags:[/dim] {tags_str}") + + # Stats + stats = [] + if ext.get('downloads') is not None: + stats.append(f"Downloads: {ext['downloads']:,}") + if ext.get('stars') is not None: + stats.append(f"Stars: {ext['stars']}") + if stats: + console.print(f" [dim]{' | '.join(stats)}[/dim]") + + # Links + if ext.get('repository'): + console.print(f" [dim]Repository:[/dim] {ext['repository']}") + + # Install command + console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}") + console.print() + + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {e}") + console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") + raise typer.Exit(1) + + +@extension_app.command("info") +def extension_info( + extension: str = typer.Argument(help="Extension ID or name"), +): + """Show detailed information about an extension.""" + from .extensions import ExtensionCatalog, ExtensionManager, ExtensionError + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + 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) + manager = ExtensionManager(project_root) + + try: + ext_info = catalog.get_extension_info(extension) + + if not ext_info: + console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog") + console.print("\nTry: specify extension search") + raise typer.Exit(1) + + # Header + verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else "" + console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}") + console.print(f"ID: {ext_info['id']}") + console.print() + + # Description + console.print(f"{ext_info['description']}") + console.print() + + # Author and License + console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}") + console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}") + console.print() + + # Requirements + if ext_info.get('requires'): + console.print("[bold]Requirements:[/bold]") + reqs = ext_info['requires'] + if reqs.get('speckit_version'): + console.print(f" • Spec Kit: {reqs['speckit_version']}") + if reqs.get('tools'): + for tool in reqs['tools']: + tool_name = tool['name'] + tool_version = tool.get('version', 'any') + required = " (required)" if tool.get('required') else " (optional)" + console.print(f" • {tool_name}: {tool_version}{required}") + console.print() + + # Provides + if ext_info.get('provides'): + console.print("[bold]Provides:[/bold]") + provides = ext_info['provides'] + if provides.get('commands'): + console.print(f" • Commands: {provides['commands']}") + if provides.get('hooks'): + console.print(f" • Hooks: {provides['hooks']}") + console.print() + + # Tags + if ext_info.get('tags'): + tags_str = ", ".join(ext_info['tags']) + console.print(f"[bold]Tags:[/bold] {tags_str}") + console.print() + + # Statistics + stats = [] + if ext_info.get('downloads') is not None: + stats.append(f"Downloads: {ext_info['downloads']:,}") + if ext_info.get('stars') is not None: + stats.append(f"Stars: {ext_info['stars']}") + if stats: + console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}") + console.print() + + # Links + console.print("[bold]Links:[/bold]") + if ext_info.get('repository'): + console.print(f" • Repository: {ext_info['repository']}") + if ext_info.get('homepage'): + console.print(f" • Homepage: {ext_info['homepage']}") + if ext_info.get('documentation'): + console.print(f" • Documentation: {ext_info['documentation']}") + if ext_info.get('changelog'): + console.print(f" • Changelog: {ext_info['changelog']}") + console.print() + + # Installation status and command + is_installed = manager.registry.is_installed(ext_info['id']) + if is_installed: + console.print("[green]✓ Installed[/green]") + console.print(f"\nTo remove: specify extension remove {ext_info['id']}") + else: + console.print("[yellow]Not installed[/yellow]") + console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}") + + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {e}") + raise typer.Exit(1) + + +@extension_app.command("update") +def extension_update( + extension: str = typer.Argument(None, help="Extension ID to update (or all)"), +): + """Update extension(s) to latest version.""" + from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError + from packaging import version as pkg_version + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + 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) + + manager = ExtensionManager(project_root) + catalog = ExtensionCatalog(project_root) + + try: + # Get list of extensions to update + if extension: + # Update specific extension + if not manager.registry.is_installed(extension): + console.print(f"[red]Error:[/red] Extension '{extension}' is not installed") + raise typer.Exit(1) + extensions_to_update = [extension] + else: + # Update all extensions + installed = manager.list_installed() + extensions_to_update = [ext["id"] for ext in installed] + + if not extensions_to_update: + console.print("[yellow]No extensions installed[/yellow]") + raise typer.Exit(0) + + console.print("🔄 Checking for updates...\n") + + updates_available = [] + + for ext_id in extensions_to_update: + # Get installed version + metadata = manager.registry.get(ext_id) + installed_version = pkg_version.Version(metadata["version"]) + + # Get catalog info + ext_info = catalog.get_extension_info(ext_id) + if not ext_info: + console.print(f"⚠ {ext_id}: Not found in catalog (skipping)") + continue + + catalog_version = pkg_version.Version(ext_info["version"]) + + if catalog_version > installed_version: + updates_available.append( + { + "id": ext_id, + "installed": str(installed_version), + "available": str(catalog_version), + "download_url": ext_info.get("download_url"), + } + ) + else: + console.print(f"✓ {ext_id}: Up to date (v{installed_version})") + + if not updates_available: + console.print("\n[green]All extensions are up to date![/green]") + raise typer.Exit(0) + + # Show available updates + console.print("\n[bold]Updates available:[/bold]\n") + for update in updates_available: + console.print( + f" • {update['id']}: {update['installed']} → {update['available']}" + ) + + console.print() + confirm = typer.confirm("Update these extensions?") + if not confirm: + console.print("Cancelled") + raise typer.Exit(0) + + # Perform updates + console.print() + for update in updates_available: + ext_id = update["id"] + console.print(f"📦 Updating {ext_id}...") + + # TODO: Implement download and reinstall from URL + # For now, just show message + console.print( + f"[yellow]Note:[/yellow] Automatic update not yet implemented. " + f"Please update manually:" + ) + console.print(f" specify extension remove {ext_id} --keep-config") + console.print(f" specify extension add {ext_id}") + + console.print( + "\n[cyan]Tip:[/cyan] Automatic updates will be available in a future version" + ) + + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {e}") + raise typer.Exit(1) + + +@extension_app.command("enable") +def extension_enable( + extension: str = typer.Argument(help="Extension ID to enable"), +): + """Enable a disabled extension.""" + from .extensions import ExtensionManager, HookExecutor + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + 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) + + manager = ExtensionManager(project_root) + hook_executor = HookExecutor(project_root) + + if not manager.registry.is_installed(extension): + console.print(f"[red]Error:[/red] Extension '{extension}' is not installed") + raise typer.Exit(1) + + # Update registry + metadata = manager.registry.get(extension) + if metadata.get("enabled", True): + console.print(f"[yellow]Extension '{extension}' is already enabled[/yellow]") + raise typer.Exit(0) + + metadata["enabled"] = True + manager.registry.add(extension, metadata) + + # Enable hooks in extensions.yml + config = hook_executor.get_project_config() + if "hooks" in config: + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension: + hook["enabled"] = True + hook_executor.save_project_config(config) + + console.print(f"[green]✓[/green] Extension '{extension}' enabled") + + +@extension_app.command("disable") +def extension_disable( + extension: str = typer.Argument(help="Extension ID to disable"), +): + """Disable an extension without removing it.""" + from .extensions import ExtensionManager, HookExecutor + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + 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) + + manager = ExtensionManager(project_root) + hook_executor = HookExecutor(project_root) + + if not manager.registry.is_installed(extension): + console.print(f"[red]Error:[/red] Extension '{extension}' is not installed") + raise typer.Exit(1) + + # Update registry + metadata = manager.registry.get(extension) + if not metadata.get("enabled", True): + console.print(f"[yellow]Extension '{extension}' is already disabled[/yellow]") + raise typer.Exit(0) + + metadata["enabled"] = False + manager.registry.add(extension, metadata) + + # Disable hooks in extensions.yml + config = hook_executor.get_project_config() + if "hooks" in config: + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension: + hook["enabled"] = False + hook_executor.save_project_config(config) + + console.print(f"[green]✓[/green] Extension '{extension}' disabled") + console.print(f"\nCommands will no longer be available. Hooks will not execute.") + console.print(f"To re-enable: specify extension enable {extension}") + + def main(): app() diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py new file mode 100644 index 0000000000..0d3927ce73 --- /dev/null +++ b/src/specify_cli/extensions.py @@ -0,0 +1,1714 @@ +""" +Extension Manager for Spec Kit + +Handles installation, removal, and management of Spec Kit extensions. +Extensions are modular packages that add commands and functionality to spec-kit +without bloating the core framework. +""" + +import json +import hashlib +import tempfile +import zipfile +import shutil +from pathlib import Path +from typing import Optional, Dict, List, Any +from datetime import datetime, timezone +import re + +import yaml +from packaging import version as pkg_version +from packaging.specifiers import SpecifierSet, InvalidSpecifier + + +class ExtensionError(Exception): + """Base exception for extension-related errors.""" + pass + + +class ValidationError(ExtensionError): + """Raised when extension manifest validation fails.""" + pass + + +class CompatibilityError(ExtensionError): + """Raised when extension is incompatible with current environment.""" + pass + + +class ExtensionManifest: + """Represents and validates an extension manifest (extension.yml).""" + + SCHEMA_VERSION = "1.0" + REQUIRED_FIELDS = ["schema_version", "extension", "requires", "provides"] + + def __init__(self, manifest_path: Path): + """Load and validate extension manifest. + + Args: + manifest_path: Path to extension.yml file + + Raises: + ValidationError: If manifest is invalid + """ + self.path = manifest_path + self.data = self._load_yaml(manifest_path) + self._validate() + + def _load_yaml(self, path: Path) -> dict: + """Load YAML file safely.""" + try: + with open(path, 'r') as f: + return yaml.safe_load(f) or {} + except yaml.YAMLError as e: + raise ValidationError(f"Invalid YAML in {path}: {e}") + except FileNotFoundError: + raise ValidationError(f"Manifest not found: {path}") + + def _validate(self): + """Validate manifest structure and required fields.""" + # Check required top-level fields + for field in self.REQUIRED_FIELDS: + if field not in self.data: + raise ValidationError(f"Missing required field: {field}") + + # Validate schema version + if self.data["schema_version"] != self.SCHEMA_VERSION: + raise ValidationError( + f"Unsupported schema version: {self.data['schema_version']} " + f"(expected {self.SCHEMA_VERSION})" + ) + + # Validate extension metadata + ext = self.data["extension"] + for field in ["id", "name", "version", "description"]: + if field not in ext: + raise ValidationError(f"Missing extension.{field}") + + # Validate extension ID format + if not re.match(r'^[a-z0-9-]+$', ext["id"]): + raise ValidationError( + f"Invalid extension ID '{ext['id']}': " + "must be lowercase alphanumeric with hyphens only" + ) + + # Validate semantic version + try: + pkg_version.Version(ext["version"]) + except pkg_version.InvalidVersion: + raise ValidationError(f"Invalid version: {ext['version']}") + + # Validate requires section + requires = self.data["requires"] + if "speckit_version" not in requires: + raise ValidationError("Missing requires.speckit_version") + + # Validate provides section + provides = self.data["provides"] + if "commands" not in provides or not provides["commands"]: + raise ValidationError("Extension must provide at least one command") + + # Validate commands + for cmd in provides["commands"]: + if "name" not in cmd or "file" not in cmd: + raise ValidationError("Command missing 'name' or 'file'") + + # Validate command name format + if not re.match(r'^speckit\.[a-z0-9-]+\.[a-z0-9-]+$', cmd["name"]): + raise ValidationError( + f"Invalid command name '{cmd['name']}': " + "must follow pattern 'speckit.{extension}.{command}'" + ) + + @property + def id(self) -> str: + """Get extension ID.""" + return self.data["extension"]["id"] + + @property + def name(self) -> str: + """Get extension name.""" + return self.data["extension"]["name"] + + @property + def version(self) -> str: + """Get extension version.""" + return self.data["extension"]["version"] + + @property + def description(self) -> str: + """Get extension description.""" + return self.data["extension"]["description"] + + @property + def requires_speckit_version(self) -> str: + """Get required spec-kit version range.""" + return self.data["requires"]["speckit_version"] + + @property + def commands(self) -> List[Dict[str, Any]]: + """Get list of provided commands.""" + return self.data["provides"]["commands"] + + @property + def hooks(self) -> Dict[str, Any]: + """Get hook definitions.""" + return self.data.get("hooks", {}) + + def get_hash(self) -> str: + """Calculate SHA256 hash of manifest file.""" + with open(self.path, 'rb') as f: + return f"sha256:{hashlib.sha256(f.read()).hexdigest()}" + + +class ExtensionRegistry: + """Manages the registry of installed extensions.""" + + REGISTRY_FILE = ".registry" + SCHEMA_VERSION = "1.0" + + def __init__(self, extensions_dir: Path): + """Initialize registry. + + Args: + extensions_dir: Path to .specify/extensions/ directory + """ + self.extensions_dir = extensions_dir + self.registry_path = extensions_dir / self.REGISTRY_FILE + self.data = self._load() + + def _load(self) -> dict: + """Load registry from disk.""" + if not self.registry_path.exists(): + return { + "schema_version": self.SCHEMA_VERSION, + "extensions": {} + } + + try: + with open(self.registry_path, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + # Corrupted or missing registry, start fresh + return { + "schema_version": self.SCHEMA_VERSION, + "extensions": {} + } + + def _save(self): + """Save registry to disk.""" + self.extensions_dir.mkdir(parents=True, exist_ok=True) + with open(self.registry_path, 'w') as f: + json.dump(self.data, f, indent=2) + + def add(self, extension_id: str, metadata: dict): + """Add extension to registry. + + Args: + extension_id: Extension ID + metadata: Extension metadata (version, source, etc.) + """ + self.data["extensions"][extension_id] = { + **metadata, + "installed_at": datetime.now(timezone.utc).isoformat() + } + self._save() + + def remove(self, extension_id: str): + """Remove extension from registry. + + Args: + extension_id: Extension ID + """ + if extension_id in self.data["extensions"]: + del self.data["extensions"][extension_id] + self._save() + + def get(self, extension_id: str) -> Optional[dict]: + """Get extension metadata from registry. + + Args: + extension_id: Extension ID + + Returns: + Extension metadata or None if not found + """ + return self.data["extensions"].get(extension_id) + + def list(self) -> Dict[str, dict]: + """Get all installed extensions. + + Returns: + Dictionary of extension_id -> metadata + """ + return self.data["extensions"] + + def is_installed(self, extension_id: str) -> bool: + """Check if extension is installed. + + Args: + extension_id: Extension ID + + Returns: + True if extension is installed + """ + return extension_id in self.data["extensions"] + + +class ExtensionManager: + """Manages extension lifecycle: installation, removal, updates.""" + + def __init__(self, project_root: Path): + """Initialize extension manager. + + Args: + project_root: Path to project root directory + """ + self.project_root = project_root + self.extensions_dir = project_root / ".specify" / "extensions" + self.registry = ExtensionRegistry(self.extensions_dir) + + def check_compatibility( + self, + manifest: ExtensionManifest, + speckit_version: str + ) -> bool: + """Check if extension is compatible with current spec-kit version. + + Args: + manifest: Extension manifest + speckit_version: Current spec-kit version + + Returns: + True if compatible + + Raises: + CompatibilityError: If extension is incompatible + """ + required = manifest.requires_speckit_version + current = pkg_version.Version(speckit_version) + + # Parse version specifier (e.g., ">=0.1.0,<2.0.0") + try: + specifier = SpecifierSet(required) + if current not in specifier: + raise CompatibilityError( + f"Extension requires spec-kit {required}, " + f"but {speckit_version} is installed.\n" + f"Upgrade spec-kit with: uv tool install specify-cli --force" + ) + except InvalidSpecifier: + raise CompatibilityError(f"Invalid version specifier: {required}") + + return True + + def install_from_directory( + self, + source_dir: Path, + speckit_version: str, + register_commands: bool = True + ) -> ExtensionManifest: + """Install extension from a local directory. + + Args: + source_dir: Path to extension directory + speckit_version: Current spec-kit version + register_commands: If True, register commands with AI agents + + Returns: + Installed extension manifest + + Raises: + ValidationError: If manifest is invalid + CompatibilityError: If extension is incompatible + """ + # Load and validate manifest + manifest_path = source_dir / "extension.yml" + manifest = ExtensionManifest(manifest_path) + + # Check compatibility + self.check_compatibility(manifest, speckit_version) + + # Check if already installed + if self.registry.is_installed(manifest.id): + raise ExtensionError( + f"Extension '{manifest.id}' is already installed. " + f"Use 'specify extension remove {manifest.id}' first." + ) + + # Install extension + dest_dir = self.extensions_dir / manifest.id + if dest_dir.exists(): + shutil.rmtree(dest_dir) + + shutil.copytree(source_dir, dest_dir) + + # Register commands with AI agents + registered_commands = {} + if register_commands: + registrar = CommandRegistrar() + # Register for all detected agents + registered_commands = registrar.register_commands_for_all_agents( + manifest, dest_dir, self.project_root + ) + + # Register hooks + hook_executor = HookExecutor(self.project_root) + hook_executor.register_hooks(manifest) + + # Update registry + self.registry.add(manifest.id, { + "version": manifest.version, + "source": "local", + "manifest_hash": manifest.get_hash(), + "enabled": True, + "registered_commands": registered_commands + }) + + return manifest + + def install_from_zip( + self, + zip_path: Path, + speckit_version: str + ) -> ExtensionManifest: + """Install extension from ZIP file. + + Args: + zip_path: Path to extension ZIP file + speckit_version: Current spec-kit version + + Returns: + Installed extension manifest + + Raises: + ValidationError: If manifest is invalid + CompatibilityError: If extension is incompatible + """ + with tempfile.TemporaryDirectory() as tmpdir: + temp_path = Path(tmpdir) + + # Extract ZIP + with zipfile.ZipFile(zip_path, 'r') as zf: + zf.extractall(temp_path) + + # Find extension directory (may be nested) + extension_dir = temp_path + manifest_path = extension_dir / "extension.yml" + + # Check if manifest is in a subdirectory + if not manifest_path.exists(): + subdirs = [d for d in temp_path.iterdir() if d.is_dir()] + if len(subdirs) == 1: + extension_dir = subdirs[0] + manifest_path = extension_dir / "extension.yml" + + if not manifest_path.exists(): + raise ValidationError("No extension.yml found in ZIP file") + + # Install from extracted directory + return self.install_from_directory(extension_dir, speckit_version) + + def remove(self, extension_id: str, keep_config: bool = False) -> bool: + """Remove an installed extension. + + Args: + extension_id: Extension ID + keep_config: If True, preserve config files (don't delete extension dir) + + Returns: + True if extension was removed + """ + if not self.registry.is_installed(extension_id): + return False + + # Get registered commands before removal + metadata = self.registry.get(extension_id) + registered_commands = metadata.get("registered_commands", {}) + + extension_dir = self.extensions_dir / extension_id + + # Unregister commands from all AI agents + if registered_commands: + registrar = CommandRegistrar() + for agent_name, cmd_names in registered_commands.items(): + if agent_name not in registrar.AGENT_CONFIGS: + continue + + agent_config = registrar.AGENT_CONFIGS[agent_name] + commands_dir = self.project_root / agent_config["dir"] + + for cmd_name in cmd_names: + cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}" + if cmd_file.exists(): + cmd_file.unlink() + + if keep_config: + # Preserve config files, only remove non-config files + # For now, just skip deletion entirely (simpler approach) + # Future: could selectively preserve only *-config.yml files + pass + else: + # Backup config files before deleting + if extension_dir.exists(): + # Use subdirectory per extension to avoid name accumulation + # (e.g., jira-jira-config.yml on repeated remove/install cycles) + backup_dir = self.extensions_dir / ".backup" / extension_id + backup_dir.mkdir(parents=True, exist_ok=True) + + config_files = list(extension_dir.glob("*-config.yml")) + for config_file in config_files: + backup_path = backup_dir / config_file.name + shutil.copy2(config_file, backup_path) + + # Remove extension directory + if extension_dir.exists(): + shutil.rmtree(extension_dir) + + # Unregister hooks + hook_executor = HookExecutor(self.project_root) + hook_executor.unregister_hooks(extension_id) + + # Update registry + self.registry.remove(extension_id) + + return True + + def list_installed(self) -> List[Dict[str, Any]]: + """List all installed extensions with metadata. + + Returns: + List of extension metadata dictionaries + """ + result = [] + + for ext_id, metadata in self.registry.list().items(): + ext_dir = self.extensions_dir / ext_id + manifest_path = ext_dir / "extension.yml" + + try: + manifest = ExtensionManifest(manifest_path) + result.append({ + "id": ext_id, + "name": manifest.name, + "version": metadata["version"], + "description": manifest.description, + "enabled": metadata.get("enabled", True), + "installed_at": metadata.get("installed_at"), + "command_count": len(manifest.commands), + "hook_count": len(manifest.hooks) + }) + except ValidationError: + # Corrupted extension + result.append({ + "id": ext_id, + "name": ext_id, + "version": metadata.get("version", "unknown"), + "description": "⚠️ Corrupted extension", + "enabled": False, + "installed_at": metadata.get("installed_at"), + "command_count": 0, + "hook_count": 0 + }) + + return result + + def get_extension(self, extension_id: str) -> Optional[ExtensionManifest]: + """Get manifest for an installed extension. + + Args: + extension_id: Extension ID + + Returns: + Extension manifest or None if not installed + """ + if not self.registry.is_installed(extension_id): + return None + + ext_dir = self.extensions_dir / extension_id + manifest_path = ext_dir / "extension.yml" + + try: + return ExtensionManifest(manifest_path) + except ValidationError: + return None + + +def version_satisfies(current: str, required: str) -> bool: + """Check if current version satisfies required version specifier. + + Args: + current: Current version (e.g., "0.1.5") + required: Required version specifier (e.g., ">=0.1.0,<2.0.0") + + Returns: + True if version satisfies requirement + """ + try: + current_ver = pkg_version.Version(current) + specifier = SpecifierSet(required) + return current_ver in specifier + except (pkg_version.InvalidVersion, InvalidSpecifier): + return False + + +class CommandRegistrar: + """Handles registration of extension commands with AI agents.""" + + # Agent configurations with directory, format, and argument placeholder + AGENT_CONFIGS = { + "claude": { + "dir": ".claude/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "gemini": { + "dir": ".gemini/commands", + "format": "toml", + "args": "{{args}}", + "extension": ".toml" + }, + "copilot": { + "dir": ".github/agents", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "cursor": { + "dir": ".cursor/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "qwen": { + "dir": ".qwen/commands", + "format": "toml", + "args": "{{args}}", + "extension": ".toml" + }, + "opencode": { + "dir": ".opencode/command", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "windsurf": { + "dir": ".windsurf/workflows", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "kilocode": { + "dir": ".kilocode/rules", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "auggie": { + "dir": ".augment/rules", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "roo": { + "dir": ".roo/rules", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "codebuddy": { + "dir": ".codebuddy/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "qoder": { + "dir": ".qoder/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "q": { + "dir": ".amazonq/prompts", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "amp": { + "dir": ".agents/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "shai": { + "dir": ".shai/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "bob": { + "dir": ".bob/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + } + } + + @staticmethod + def parse_frontmatter(content: str) -> tuple[dict, str]: + """Parse YAML frontmatter from Markdown content. + + Args: + content: Markdown content with YAML frontmatter + + Returns: + Tuple of (frontmatter_dict, body_content) + """ + if not content.startswith("---"): + return {}, content + + # Find second --- + end_marker = content.find("---", 3) + if end_marker == -1: + return {}, content + + frontmatter_str = content[3:end_marker].strip() + body = content[end_marker + 3:].strip() + + try: + frontmatter = yaml.safe_load(frontmatter_str) or {} + except yaml.YAMLError: + frontmatter = {} + + return frontmatter, body + + @staticmethod + def render_frontmatter(fm: dict) -> str: + """Render frontmatter dictionary as YAML. + + Args: + fm: Frontmatter dictionary + + Returns: + YAML-formatted frontmatter with delimiters + """ + if not fm: + return "" + + yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False) + return f"---\n{yaml_str}---\n" + + def _adjust_script_paths(self, frontmatter: dict) -> dict: + """Adjust script paths from extension-relative to repo-relative. + + Args: + frontmatter: Frontmatter dictionary + + Returns: + Modified frontmatter with adjusted paths + """ + if "scripts" in frontmatter: + for key in frontmatter["scripts"]: + script_path = frontmatter["scripts"][key] + if script_path.startswith("../../scripts/"): + frontmatter["scripts"][key] = f".specify/scripts/{script_path[14:]}" + return frontmatter + + def _render_markdown_command( + self, + frontmatter: dict, + body: str, + ext_id: str + ) -> str: + """Render command in Markdown format. + + Args: + frontmatter: Command frontmatter + body: Command body content + ext_id: Extension ID + + Returns: + Formatted Markdown command file content + """ + context_note = f"\n\n\n" + return self.render_frontmatter(frontmatter) + "\n" + context_note + body + + def _render_toml_command( + self, + frontmatter: dict, + body: str, + ext_id: str + ) -> str: + """Render command in TOML format. + + Args: + frontmatter: Command frontmatter + body: Command body content + ext_id: Extension ID + + Returns: + Formatted TOML command file content + """ + # TOML format for Gemini/Qwen + toml_lines = [] + + # Add description if present + if "description" in frontmatter: + # Escape quotes in description + desc = frontmatter["description"].replace('"', '\\"') + toml_lines.append(f'description = "{desc}"') + toml_lines.append("") + + # Add extension context as comments + toml_lines.append(f"# Extension: {ext_id}") + toml_lines.append(f"# Config: .specify/extensions/{ext_id}/") + toml_lines.append("") + + # Add prompt content + toml_lines.append('prompt = """') + toml_lines.append(body) + toml_lines.append('"""') + + return "\n".join(toml_lines) + + def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str: + """Convert argument placeholder format. + + Args: + content: Command content + from_placeholder: Source placeholder (e.g., "$ARGUMENTS") + to_placeholder: Target placeholder (e.g., "{{args}}") + + Returns: + Content with converted placeholders + """ + return content.replace(from_placeholder, to_placeholder) + + def register_commands_for_agent( + self, + agent_name: str, + manifest: ExtensionManifest, + extension_dir: Path, + project_root: Path + ) -> List[str]: + """Register extension commands for a specific agent. + + Args: + agent_name: Agent name (claude, gemini, copilot, etc.) + manifest: Extension manifest + extension_dir: Path to extension directory + project_root: Path to project root + + Returns: + List of registered command names + + Raises: + ExtensionError: If agent is not supported + """ + if agent_name not in self.AGENT_CONFIGS: + raise ExtensionError(f"Unsupported agent: {agent_name}") + + agent_config = self.AGENT_CONFIGS[agent_name] + commands_dir = project_root / agent_config["dir"] + commands_dir.mkdir(parents=True, exist_ok=True) + + registered = [] + + for cmd_info in manifest.commands: + cmd_name = cmd_info["name"] + cmd_file = cmd_info["file"] + + # Read source command file + source_file = extension_dir / cmd_file + if not source_file.exists(): + continue + + content = source_file.read_text() + frontmatter, body = self.parse_frontmatter(content) + + # Adjust script paths + frontmatter = self._adjust_script_paths(frontmatter) + + # Convert argument placeholders + body = self._convert_argument_placeholder( + body, "$ARGUMENTS", agent_config["args"] + ) + + # Render in agent-specific format + if agent_config["format"] == "markdown": + output = self._render_markdown_command(frontmatter, body, manifest.id) + elif agent_config["format"] == "toml": + output = self._render_toml_command(frontmatter, body, manifest.id) + else: + raise ExtensionError(f"Unsupported format: {agent_config['format']}") + + # Write command file + dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}" + dest_file.write_text(output) + + registered.append(cmd_name) + + # Register aliases + for alias in cmd_info.get("aliases", []): + alias_file = commands_dir / f"{alias}{agent_config['extension']}" + alias_file.write_text(output) + registered.append(alias) + + return registered + + def register_commands_for_all_agents( + self, + manifest: ExtensionManifest, + extension_dir: Path, + project_root: Path + ) -> Dict[str, List[str]]: + """Register extension commands for all detected agents. + + Args: + manifest: Extension manifest + extension_dir: Path to extension directory + project_root: Path to project root + + Returns: + Dictionary mapping agent names to list of registered commands + """ + results = {} + + # Detect which agents are present in the project + for agent_name, agent_config in self.AGENT_CONFIGS.items(): + agent_dir = project_root / agent_config["dir"].split("/")[0] + + # Register if agent directory exists + if agent_dir.exists(): + try: + registered = self.register_commands_for_agent( + agent_name, manifest, extension_dir, project_root + ) + if registered: + results[agent_name] = registered + except ExtensionError: + # Skip agent on error + continue + + return results + + def register_commands_for_claude( + self, + manifest: ExtensionManifest, + extension_dir: Path, + project_root: Path + ) -> List[str]: + """Register extension commands for Claude Code agent. + + Args: + manifest: Extension manifest + extension_dir: Path to extension directory + project_root: Path to project root + + Returns: + List of registered command names + """ + return self.register_commands_for_agent("claude", manifest, extension_dir, project_root) + + +class ExtensionCatalog: + """Manages extension catalog fetching, caching, and searching.""" + + DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json" + CACHE_DURATION = 3600 # 1 hour in seconds + + def __init__(self, project_root: Path): + """Initialize extension catalog manager. + + Args: + project_root: Root directory of the spec-kit project + """ + self.project_root = project_root + self.extensions_dir = project_root / ".specify" / "extensions" + self.cache_dir = self.extensions_dir / ".cache" + self.cache_file = self.cache_dir / "catalog.json" + self.cache_metadata_file = self.cache_dir / "catalog-metadata.json" + + def get_catalog_url(self) -> str: + """Get catalog URL from config or use default. + + Checks in order: + 1. SPECKIT_CATALOG_URL environment variable + 2. Default catalog URL + + Returns: + URL to fetch catalog from + """ + import os + + # Environment variable override (useful for testing) + if catalog_url := os.environ.get("SPECKIT_CATALOG_URL"): + return catalog_url.strip() + + # TODO: Support custom catalogs from .specify/extension-catalogs.yml + return self.DEFAULT_CATALOG_URL + + def is_cache_valid(self) -> bool: + """Check if cached catalog is still valid. + + Returns: + True if cache exists and is within cache duration + """ + if not self.cache_file.exists() or not self.cache_metadata_file.exists(): + return False + + try: + metadata = json.loads(self.cache_metadata_file.read_text()) + cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) + age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds() + return age_seconds < self.CACHE_DURATION + except (json.JSONDecodeError, ValueError, KeyError): + return False + + def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: + """Fetch extension catalog from URL or cache. + + Args: + force_refresh: If True, bypass cache and fetch from network + + Returns: + Catalog data dictionary + + Raises: + ExtensionError: If catalog cannot be fetched + """ + # Check cache first unless force refresh + if not force_refresh and self.is_cache_valid(): + try: + return json.loads(self.cache_file.read_text()) + except json.JSONDecodeError: + pass # Fall through to network fetch + + # Fetch from network + catalog_url = self.get_catalog_url() + + try: + import urllib.request + import urllib.error + + with urllib.request.urlopen(catalog_url, timeout=10) as response: + catalog_data = json.loads(response.read()) + + # Validate catalog structure + if "schema_version" not in catalog_data or "extensions" not in catalog_data: + raise ExtensionError("Invalid catalog format") + + # Save to cache + self.cache_dir.mkdir(parents=True, exist_ok=True) + self.cache_file.write_text(json.dumps(catalog_data, indent=2)) + + # Save cache metadata + metadata = { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": catalog_url, + } + self.cache_metadata_file.write_text(json.dumps(metadata, indent=2)) + + return catalog_data + + except urllib.error.URLError as e: + raise ExtensionError(f"Failed to fetch catalog from {catalog_url}: {e}") + except json.JSONDecodeError as e: + raise ExtensionError(f"Invalid JSON in catalog: {e}") + + def search( + self, + query: Optional[str] = None, + tag: Optional[str] = None, + author: Optional[str] = None, + verified_only: bool = False, + ) -> List[Dict[str, Any]]: + """Search catalog for extensions. + + Args: + query: Search query (searches name, description, tags) + tag: Filter by specific tag + author: Filter by author name + verified_only: If True, show only verified extensions + + Returns: + List of matching extension metadata + """ + catalog = self.fetch_catalog() + extensions = catalog.get("extensions", {}) + + results = [] + + for ext_id, ext_data in extensions.items(): + # Apply filters + if verified_only and not ext_data.get("verified", False): + continue + + if author and ext_data.get("author", "").lower() != author.lower(): + continue + + if tag and tag.lower() not in [t.lower() for t in ext_data.get("tags", [])]: + continue + + if query: + # Search in name, description, and tags + query_lower = query.lower() + searchable_text = " ".join( + [ + ext_data.get("name", ""), + ext_data.get("description", ""), + ext_id, + ] + + ext_data.get("tags", []) + ).lower() + + if query_lower not in searchable_text: + continue + + results.append({"id": ext_id, **ext_data}) + + return results + + def get_extension_info(self, extension_id: str) -> Optional[Dict[str, Any]]: + """Get detailed information about a specific extension. + + Args: + extension_id: ID of the extension + + Returns: + Extension metadata or None if not found + """ + catalog = self.fetch_catalog() + extensions = catalog.get("extensions", {}) + + if extension_id in extensions: + return {"id": extension_id, **extensions[extension_id]} + + return None + + def download_extension(self, extension_id: str, target_dir: Optional[Path] = None) -> Path: + """Download extension ZIP from catalog. + + Args: + extension_id: ID of the extension to download + target_dir: Directory to save ZIP file (defaults to temp directory) + + Returns: + Path to downloaded ZIP file + + Raises: + ExtensionError: If extension not found or download fails + """ + import urllib.request + import urllib.error + + # Get extension info from catalog + ext_info = self.get_extension_info(extension_id) + if not ext_info: + raise ExtensionError(f"Extension '{extension_id}' not found in catalog") + + download_url = ext_info.get("download_url") + if not download_url: + raise ExtensionError(f"Extension '{extension_id}' has no download URL") + + # Determine target path + if target_dir is None: + target_dir = self.cache_dir / "downloads" + target_dir.mkdir(parents=True, exist_ok=True) + + version = ext_info.get("version", "unknown") + zip_filename = f"{extension_id}-{version}.zip" + zip_path = target_dir / zip_filename + + # Download the ZIP file + try: + with urllib.request.urlopen(download_url, timeout=60) as response: + zip_data = response.read() + + zip_path.write_bytes(zip_data) + return zip_path + + except urllib.error.URLError as e: + raise ExtensionError(f"Failed to download extension from {download_url}: {e}") + except IOError as e: + raise ExtensionError(f"Failed to save extension ZIP: {e}") + + def clear_cache(self): + """Clear the catalog cache.""" + if self.cache_file.exists(): + self.cache_file.unlink() + if self.cache_metadata_file.exists(): + self.cache_metadata_file.unlink() + + +class ConfigManager: + """Manages layered configuration for extensions. + + Configuration layers (in order of precedence from lowest to highest): + 1. Defaults (from extension.yml) + 2. Project config (.specify/extensions/{ext-id}/{ext-id}-config.yml) + 3. Local config (.specify/extensions/{ext-id}/local-config.yml) - gitignored + 4. Environment variables (SPECKIT_{EXT_ID}_{KEY}) + """ + + def __init__(self, project_root: Path, extension_id: str): + """Initialize config manager for an extension. + + Args: + project_root: Root directory of the spec-kit project + extension_id: ID of the extension + """ + self.project_root = project_root + self.extension_id = extension_id + self.extension_dir = project_root / ".specify" / "extensions" / extension_id + + def _load_yaml_config(self, file_path: Path) -> Dict[str, Any]: + """Load configuration from YAML file. + + Args: + file_path: Path to YAML file + + Returns: + Configuration dictionary + """ + if not file_path.exists(): + return {} + + try: + return yaml.safe_load(file_path.read_text()) or {} + except (yaml.YAMLError, OSError): + return {} + + def _get_extension_defaults(self) -> Dict[str, Any]: + """Get default configuration from extension manifest. + + Returns: + Default configuration dictionary + """ + manifest_path = self.extension_dir / "extension.yml" + if not manifest_path.exists(): + return {} + + manifest_data = self._load_yaml_config(manifest_path) + return manifest_data.get("config", {}).get("defaults", {}) + + def _get_project_config(self) -> Dict[str, Any]: + """Get project-level configuration. + + Returns: + Project configuration dictionary + """ + config_file = self.extension_dir / f"{self.extension_id}-config.yml" + return self._load_yaml_config(config_file) + + def _get_local_config(self) -> Dict[str, Any]: + """Get local configuration (gitignored, machine-specific). + + Returns: + Local configuration dictionary + """ + config_file = self.extension_dir / "local-config.yml" + return self._load_yaml_config(config_file) + + def _get_env_config(self) -> Dict[str, Any]: + """Get configuration from environment variables. + + Environment variables follow the pattern: + SPECKIT_{EXT_ID}_{SECTION}_{KEY} + + For example: + - SPECKIT_JIRA_CONNECTION_URL + - SPECKIT_JIRA_PROJECT_KEY + + Returns: + Configuration dictionary from environment variables + """ + import os + + env_config = {} + ext_id_upper = self.extension_id.replace("-", "_").upper() + prefix = f"SPECKIT_{ext_id_upper}_" + + for key, value in os.environ.items(): + if not key.startswith(prefix): + continue + + # Remove prefix and split into parts + config_path = key[len(prefix):].lower().split("_") + + # Build nested dict + current = env_config + for part in config_path[:-1]: + if part not in current: + current[part] = {} + current = current[part] + + # Set the final value + current[config_path[-1]] = value + + return env_config + + def _merge_configs(self, base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: + """Recursively merge two configuration dictionaries. + + Args: + base: Base configuration + override: Configuration to merge on top + + Returns: + Merged configuration + """ + result = base.copy() + + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + # Recursive merge for nested dicts + result[key] = self._merge_configs(result[key], value) + else: + # Override value + result[key] = value + + return result + + def get_config(self) -> Dict[str, Any]: + """Get final merged configuration for the extension. + + Merges configuration layers in order: + defaults -> project -> local -> env + + Returns: + Final merged configuration dictionary + """ + # Start with defaults + config = self._get_extension_defaults() + + # Merge project config + config = self._merge_configs(config, self._get_project_config()) + + # Merge local config + config = self._merge_configs(config, self._get_local_config()) + + # Merge environment config + config = self._merge_configs(config, self._get_env_config()) + + return config + + def get_value(self, key_path: str, default: Any = None) -> Any: + """Get a specific configuration value by dot-notation path. + + Args: + key_path: Dot-separated path to config value (e.g., "connection.url") + default: Default value if key not found + + Returns: + Configuration value or default + + Example: + >>> config = ConfigManager(project_root, "jira") + >>> url = config.get_value("connection.url") + >>> timeout = config.get_value("connection.timeout", 30) + """ + config = self.get_config() + keys = key_path.split(".") + + current = config + for key in keys: + if not isinstance(current, dict) or key not in current: + return default + current = current[key] + + return current + + def has_value(self, key_path: str) -> bool: + """Check if a configuration value exists. + + Args: + key_path: Dot-separated path to config value + + Returns: + True if value exists (even if None), False otherwise + """ + config = self.get_config() + keys = key_path.split(".") + + current = config + for key in keys: + if not isinstance(current, dict) or key not in current: + return False + current = current[key] + + return True + + +class HookExecutor: + """Manages extension hook execution.""" + + def __init__(self, project_root: Path): + """Initialize hook executor. + + Args: + project_root: Root directory of the spec-kit project + """ + self.project_root = project_root + self.extensions_dir = project_root / ".specify" / "extensions" + self.config_file = project_root / ".specify" / "extensions.yml" + + def get_project_config(self) -> Dict[str, Any]: + """Load project-level extension configuration. + + Returns: + Extension configuration dictionary + """ + if not self.config_file.exists(): + return { + "installed": [], + "settings": {"auto_execute_hooks": True}, + "hooks": {}, + } + + try: + return yaml.safe_load(self.config_file.read_text()) or {} + except (yaml.YAMLError, OSError): + return { + "installed": [], + "settings": {"auto_execute_hooks": True}, + "hooks": {}, + } + + def save_project_config(self, config: Dict[str, Any]): + """Save project-level extension configuration. + + Args: + config: Configuration dictionary to save + """ + self.config_file.parent.mkdir(parents=True, exist_ok=True) + self.config_file.write_text( + yaml.dump(config, default_flow_style=False, sort_keys=False) + ) + + def register_hooks(self, manifest: ExtensionManifest): + """Register extension hooks in project config. + + Args: + manifest: Extension manifest with hooks to register + """ + if not hasattr(manifest, "hooks") or not manifest.hooks: + return + + config = self.get_project_config() + + # Ensure hooks dict exists + if "hooks" not in config: + config["hooks"] = {} + + # Register each hook + for hook_name, hook_config in manifest.hooks.items(): + if hook_name not in config["hooks"]: + config["hooks"][hook_name] = [] + + # Add hook entry + hook_entry = { + "extension": manifest.id, + "command": hook_config.get("command"), + "enabled": True, + "optional": hook_config.get("optional", True), + "prompt": hook_config.get( + "prompt", f"Execute {hook_config.get('command')}?" + ), + "description": hook_config.get("description", ""), + "condition": hook_config.get("condition"), + } + + # Check if already registered + existing = [ + h + for h in config["hooks"][hook_name] + if h.get("extension") == manifest.id + ] + + if not existing: + config["hooks"][hook_name].append(hook_entry) + else: + # Update existing + for i, h in enumerate(config["hooks"][hook_name]): + if h.get("extension") == manifest.id: + config["hooks"][hook_name][i] = hook_entry + + self.save_project_config(config) + + def unregister_hooks(self, extension_id: str): + """Remove extension hooks from project config. + + Args: + extension_id: ID of extension to unregister + """ + config = self.get_project_config() + + if "hooks" not in config: + return + + # Remove hooks for this extension + for hook_name in config["hooks"]: + config["hooks"][hook_name] = [ + h + for h in config["hooks"][hook_name] + if h.get("extension") != extension_id + ] + + # Clean up empty hook arrays + config["hooks"] = { + name: hooks for name, hooks in config["hooks"].items() if hooks + } + + self.save_project_config(config) + + def get_hooks_for_event(self, event_name: str) -> List[Dict[str, Any]]: + """Get all registered hooks for a specific event. + + Args: + event_name: Name of the event (e.g., 'after_tasks') + + Returns: + List of hook configurations + """ + config = self.get_project_config() + hooks = config.get("hooks", {}).get(event_name, []) + + # Filter to enabled hooks only + return [h for h in hooks if h.get("enabled", True)] + + def should_execute_hook(self, hook: Dict[str, Any]) -> bool: + """Determine if a hook should be executed based on its condition. + + Args: + hook: Hook configuration + + Returns: + True if hook should execute, False otherwise + """ + condition = hook.get("condition") + + if not condition: + return True + + # Parse and evaluate condition + try: + return self._evaluate_condition(condition, hook.get("extension")) + except Exception: + # If condition evaluation fails, default to not executing + return False + + def _evaluate_condition(self, condition: str, extension_id: Optional[str]) -> bool: + """Evaluate a hook condition expression. + + Supported condition patterns: + - "config.key.path is set" - checks if config value exists + - "config.key.path == 'value'" - checks if config equals value + - "config.key.path != 'value'" - checks if config not equals value + - "env.VAR_NAME is set" - checks if environment variable exists + - "env.VAR_NAME == 'value'" - checks if env var equals value + + Args: + condition: Condition expression string + extension_id: Extension ID for config lookup + + Returns: + True if condition is met, False otherwise + """ + import os + import re + + condition = condition.strip() + + # Pattern: "config.key.path is set" + if match := re.match(r'config\.([a-z0-9_.]+)\s+is\s+set', condition, re.IGNORECASE): + key_path = match.group(1) + if not extension_id: + return False + + config_manager = ConfigManager(self.project_root, extension_id) + return config_manager.has_value(key_path) + + # Pattern: "config.key.path == 'value'" or "config.key.path != 'value'" + if match := re.match(r'config\.([a-z0-9_.]+)\s*(==|!=)\s*["\']([^"\']+)["\']', condition, re.IGNORECASE): + key_path = match.group(1) + operator = match.group(2) + expected_value = match.group(3) + + if not extension_id: + return False + + config_manager = ConfigManager(self.project_root, extension_id) + actual_value = config_manager.get_value(key_path) + + if operator == "==": + return str(actual_value) == expected_value + else: # != + return str(actual_value) != expected_value + + # Pattern: "env.VAR_NAME is set" + if match := re.match(r'env\.([A-Z0-9_]+)\s+is\s+set', condition, re.IGNORECASE): + var_name = match.group(1).upper() + return var_name in os.environ + + # Pattern: "env.VAR_NAME == 'value'" or "env.VAR_NAME != 'value'" + if match := re.match(r'env\.([A-Z0-9_]+)\s*(==|!=)\s*["\']([^"\']+)["\']', condition, re.IGNORECASE): + var_name = match.group(1).upper() + operator = match.group(2) + expected_value = match.group(3) + + actual_value = os.environ.get(var_name, "") + + if operator == "==": + return actual_value == expected_value + else: # != + return actual_value != expected_value + + # Unknown condition format, default to False for safety + return False + + def format_hook_message( + self, event_name: str, hooks: List[Dict[str, Any]] + ) -> str: + """Format hook execution message for display in command output. + + Args: + event_name: Name of the event + hooks: List of hooks to execute + + Returns: + Formatted message string + """ + if not hooks: + return "" + + lines = ["\n## Extension Hooks\n"] + lines.append(f"Hooks available for event '{event_name}':\n") + + for hook in hooks: + extension = hook.get("extension") + command = hook.get("command") + optional = hook.get("optional", True) + prompt = hook.get("prompt", "") + description = hook.get("description", "") + + if optional: + lines.append(f"\n**Optional Hook**: {extension}") + lines.append(f"Command: `/{command}`") + if description: + lines.append(f"Description: {description}") + lines.append(f"\nPrompt: {prompt}") + lines.append(f"To execute: `/{command}`") + else: + lines.append(f"\n**Automatic Hook**: {extension}") + lines.append(f"Executing: `/{command}`") + lines.append(f"EXECUTE_COMMAND: {command}") + + return "\n".join(lines) + + def check_hooks_for_event(self, event_name: str) -> Dict[str, Any]: + """Check for hooks registered for a specific event. + + This method is designed to be called by AI agents after core commands complete. + + Args: + event_name: Name of the event (e.g., 'after_spec', 'after_tasks') + + Returns: + Dictionary with hook information: + - has_hooks: bool - Whether hooks exist for this event + - hooks: List[Dict] - List of hooks (with condition evaluation applied) + - message: str - Formatted message for display + """ + hooks = self.get_hooks_for_event(event_name) + + if not hooks: + return { + "has_hooks": False, + "hooks": [], + "message": "" + } + + # Filter hooks by condition + executable_hooks = [] + for hook in hooks: + if self.should_execute_hook(hook): + executable_hooks.append(hook) + + if not executable_hooks: + return { + "has_hooks": False, + "hooks": [], + "message": f"# No executable hooks for event '{event_name}' (conditions not met)" + } + + return { + "has_hooks": True, + "hooks": executable_hooks, + "message": self.format_hook_message(event_name, executable_hooks) + } + + def execute_hook(self, hook: Dict[str, Any]) -> Dict[str, Any]: + """Execute a single hook command. + + Note: This returns information about how to execute the hook. + The actual execution is delegated to the AI agent. + + Args: + hook: Hook configuration + + Returns: + Dictionary with execution information: + - command: str - Command to execute + - extension: str - Extension ID + - optional: bool - Whether hook is optional + - description: str - Hook description + """ + return { + "command": hook.get("command"), + "extension": hook.get("extension"), + "optional": hook.get("optional", True), + "description": hook.get("description", ""), + "prompt": hook.get("prompt", "") + } + + def enable_hooks(self, extension_id: str): + """Enable all hooks for an extension. + + Args: + extension_id: Extension ID + """ + config = self.get_project_config() + + if "hooks" not in config: + return + + # Enable all hooks for this extension + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension_id: + hook["enabled"] = True + + self.save_project_config(config) + + def disable_hooks(self, extension_id: str): + """Disable all hooks for an extension. + + Args: + extension_id: Extension ID + """ + config = self.get_project_config() + + if "hooks" not in config: + return + + # Disable all hooks for this extension + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension_id: + hook["enabled"] = False + + self.save_project_config(config) + + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..f5b8b01cf1 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for Spec Kit.""" diff --git a/tests/test_extensions.py b/tests/test_extensions.py new file mode 100644 index 0000000000..eba39993e8 --- /dev/null +++ b/tests/test_extensions.py @@ -0,0 +1,984 @@ +""" +Unit tests for the extension system. + +Tests cover: +- Extension manifest validation +- Extension registry operations +- Extension manager installation/removal +- Command registration +""" + +import pytest +import json +import tempfile +import shutil +from pathlib import Path +from datetime import datetime, timezone + +from specify_cli.extensions import ( + ExtensionManifest, + ExtensionRegistry, + ExtensionManager, + CommandRegistrar, + ExtensionCatalog, + ExtensionError, + ValidationError, + CompatibilityError, + version_satisfies, +) + + +# ===== Fixtures ===== + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for tests.""" + tmpdir = tempfile.mkdtemp() + yield Path(tmpdir) + shutil.rmtree(tmpdir) + + +@pytest.fixture +def valid_manifest_data(): + """Valid extension manifest data.""" + return { + "schema_version": "1.0", + "extension": { + "id": "test-ext", + "name": "Test Extension", + "version": "1.0.0", + "description": "A test extension", + "author": "Test Author", + "repository": "https://github.com/test/test-ext", + "license": "MIT", + }, + "requires": { + "speckit_version": ">=0.1.0", + "commands": ["speckit.tasks"], + }, + "provides": { + "commands": [ + { + "name": "speckit.test.hello", + "file": "commands/hello.md", + "description": "Test command", + } + ] + }, + "hooks": { + "after_tasks": { + "command": "speckit.test.hello", + "optional": True, + "prompt": "Run test?", + } + }, + "tags": ["testing", "example"], + } + + +@pytest.fixture +def extension_dir(temp_dir, valid_manifest_data): + """Create a complete extension directory structure.""" + ext_dir = temp_dir / "test-ext" + ext_dir.mkdir() + + # Write manifest + import yaml + manifest_path = ext_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + # Create commands directory + commands_dir = ext_dir / "commands" + commands_dir.mkdir() + + # Write command file + cmd_file = commands_dir / "hello.md" + cmd_file.write_text("""--- +description: "Test hello command" +--- + +# Test Hello Command + +$ARGUMENTS +""") + + return ext_dir + + +@pytest.fixture +def project_dir(temp_dir): + """Create a mock spec-kit project directory.""" + proj_dir = temp_dir / "project" + proj_dir.mkdir() + + # Create .specify directory + specify_dir = proj_dir / ".specify" + specify_dir.mkdir() + + return proj_dir + + +# ===== ExtensionManifest Tests ===== + +class TestExtensionManifest: + """Test ExtensionManifest validation and parsing.""" + + def test_valid_manifest(self, extension_dir): + """Test loading a valid manifest.""" + manifest_path = extension_dir / "extension.yml" + manifest = ExtensionManifest(manifest_path) + + assert manifest.id == "test-ext" + assert manifest.name == "Test Extension" + assert manifest.version == "1.0.0" + assert manifest.description == "A test extension" + assert len(manifest.commands) == 1 + assert manifest.commands[0]["name"] == "speckit.test.hello" + + def test_missing_required_field(self, temp_dir): + """Test manifest missing required field.""" + import yaml + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump({"schema_version": "1.0"}, f) # Missing 'extension' + + with pytest.raises(ValidationError, match="Missing required field"): + ExtensionManifest(manifest_path) + + def test_invalid_extension_id(self, temp_dir, valid_manifest_data): + """Test manifest with invalid extension ID format.""" + import yaml + + valid_manifest_data["extension"]["id"] = "Invalid_ID" # Uppercase not allowed + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid extension ID"): + ExtensionManifest(manifest_path) + + def test_invalid_version(self, temp_dir, valid_manifest_data): + """Test manifest with invalid semantic version.""" + import yaml + + valid_manifest_data["extension"]["version"] = "invalid" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid version"): + ExtensionManifest(manifest_path) + + def test_invalid_command_name(self, temp_dir, valid_manifest_data): + """Test manifest with invalid command name format.""" + import yaml + + valid_manifest_data["provides"]["commands"][0]["name"] = "invalid-name" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid command name"): + ExtensionManifest(manifest_path) + + def test_no_commands(self, temp_dir, valid_manifest_data): + """Test manifest with no commands provided.""" + import yaml + + valid_manifest_data["provides"]["commands"] = [] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="must provide at least one command"): + ExtensionManifest(manifest_path) + + def test_manifest_hash(self, extension_dir): + """Test manifest hash calculation.""" + manifest_path = extension_dir / "extension.yml" + manifest = ExtensionManifest(manifest_path) + + hash_value = manifest.get_hash() + assert hash_value.startswith("sha256:") + assert len(hash_value) > 10 + + +# ===== ExtensionRegistry Tests ===== + +class TestExtensionRegistry: + """Test ExtensionRegistry operations.""" + + def test_empty_registry(self, temp_dir): + """Test creating a new empty registry.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + + assert registry.data["schema_version"] == "1.0" + assert registry.data["extensions"] == {} + assert len(registry.list()) == 0 + + def test_add_extension(self, temp_dir): + """Test adding an extension to registry.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + + metadata = { + "version": "1.0.0", + "source": "local", + "enabled": True, + } + registry.add("test-ext", metadata) + + assert registry.is_installed("test-ext") + ext_data = registry.get("test-ext") + assert ext_data["version"] == "1.0.0" + assert "installed_at" in ext_data + + def test_remove_extension(self, temp_dir): + """Test removing an extension from registry.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("test-ext", {"version": "1.0.0"}) + + assert registry.is_installed("test-ext") + + registry.remove("test-ext") + + assert not registry.is_installed("test-ext") + assert registry.get("test-ext") is None + + def test_registry_persistence(self, temp_dir): + """Test that registry persists to disk.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + # Create registry and add extension + registry1 = ExtensionRegistry(extensions_dir) + registry1.add("test-ext", {"version": "1.0.0"}) + + # Load new registry instance + registry2 = ExtensionRegistry(extensions_dir) + + # Should still have the extension + assert registry2.is_installed("test-ext") + assert registry2.get("test-ext")["version"] == "1.0.0" + + +# ===== ExtensionManager Tests ===== + +class TestExtensionManager: + """Test ExtensionManager installation and removal.""" + + def test_check_compatibility_valid(self, extension_dir, project_dir): + """Test compatibility check with valid version.""" + manager = ExtensionManager(project_dir) + manifest = ExtensionManifest(extension_dir / "extension.yml") + + # Should not raise + result = manager.check_compatibility(manifest, "0.1.0") + assert result is True + + def test_check_compatibility_invalid(self, extension_dir, project_dir): + """Test compatibility check with invalid version.""" + manager = ExtensionManager(project_dir) + manifest = ExtensionManifest(extension_dir / "extension.yml") + + # Requires >=0.1.0, but we have 0.0.1 + with pytest.raises(CompatibilityError, match="Extension requires spec-kit"): + manager.check_compatibility(manifest, "0.0.1") + + def test_install_from_directory(self, extension_dir, project_dir): + """Test installing extension from directory.""" + manager = ExtensionManager(project_dir) + + manifest = manager.install_from_directory( + extension_dir, + "0.1.0", + register_commands=False # Skip command registration for now + ) + + assert manifest.id == "test-ext" + assert manager.registry.is_installed("test-ext") + + # Check extension directory was copied + ext_dir = project_dir / ".specify" / "extensions" / "test-ext" + assert ext_dir.exists() + assert (ext_dir / "extension.yml").exists() + assert (ext_dir / "commands" / "hello.md").exists() + + def test_install_duplicate(self, extension_dir, project_dir): + """Test installing already installed extension.""" + manager = ExtensionManager(project_dir) + + # Install once + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + # Try to install again + with pytest.raises(ExtensionError, match="already installed"): + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + def test_remove_extension(self, extension_dir, project_dir): + """Test removing an installed extension.""" + manager = ExtensionManager(project_dir) + + # Install extension + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + ext_dir = project_dir / ".specify" / "extensions" / "test-ext" + assert ext_dir.exists() + + # Remove extension + result = manager.remove("test-ext", keep_config=False) + + assert result is True + assert not manager.registry.is_installed("test-ext") + assert not ext_dir.exists() + + def test_remove_nonexistent(self, project_dir): + """Test removing non-existent extension.""" + manager = ExtensionManager(project_dir) + + result = manager.remove("nonexistent") + assert result is False + + def test_list_installed(self, extension_dir, project_dir): + """Test listing installed extensions.""" + manager = ExtensionManager(project_dir) + + # Initially empty + assert len(manager.list_installed()) == 0 + + # Install extension + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + # Should have one extension + installed = manager.list_installed() + assert len(installed) == 1 + assert installed[0]["id"] == "test-ext" + assert installed[0]["name"] == "Test Extension" + assert installed[0]["version"] == "1.0.0" + assert installed[0]["command_count"] == 1 + assert installed[0]["hook_count"] == 1 + + def test_config_backup_on_remove(self, extension_dir, project_dir): + """Test that config files are backed up on removal.""" + manager = ExtensionManager(project_dir) + + # Install extension + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + # Create a config file + ext_dir = project_dir / ".specify" / "extensions" / "test-ext" + config_file = ext_dir / "test-ext-config.yml" + config_file.write_text("test: config") + + # Remove extension (without keep_config) + manager.remove("test-ext", keep_config=False) + + # Check backup was created + backup_dir = project_dir / ".specify" / "extensions" / ".backup" + backup_file = backup_dir / "test-ext-test-ext-config.yml" + assert backup_file.exists() + assert backup_file.read_text() == "test: config" + + +# ===== CommandRegistrar Tests ===== + +class TestCommandRegistrar: + """Test CommandRegistrar command registration.""" + + def test_parse_frontmatter_valid(self): + """Test parsing valid YAML frontmatter.""" + content = """--- +description: "Test command" +tools: + - tool1 + - tool2 +--- + +# Command body +$ARGUMENTS +""" + registrar = CommandRegistrar() + frontmatter, body = registrar.parse_frontmatter(content) + + assert frontmatter["description"] == "Test command" + assert frontmatter["tools"] == ["tool1", "tool2"] + assert "Command body" in body + assert "$ARGUMENTS" in body + + def test_parse_frontmatter_no_frontmatter(self): + """Test parsing content without frontmatter.""" + content = "# Just a command\n$ARGUMENTS" + + registrar = CommandRegistrar() + frontmatter, body = registrar.parse_frontmatter(content) + + assert frontmatter == {} + assert body == content + + def test_render_frontmatter(self): + """Test rendering frontmatter to YAML.""" + frontmatter = { + "description": "Test command", + "tools": ["tool1", "tool2"] + } + + registrar = CommandRegistrar() + output = registrar.render_frontmatter(frontmatter) + + assert output.startswith("---\n") + assert output.endswith("---\n") + assert "description: Test command" in output + + def test_register_commands_for_claude(self, extension_dir, project_dir): + """Test registering commands for Claude agent.""" + # Create .claude directory + claude_dir = project_dir / ".claude" / "commands" + claude_dir.mkdir(parents=True) + + manager = ExtensionManager(project_dir) + manifest = ExtensionManifest(extension_dir / "extension.yml") + + registrar = CommandRegistrar() + registered = registrar.register_commands_for_claude( + manifest, + extension_dir, + project_dir + ) + + assert len(registered) == 1 + assert "speckit.test.hello" in registered + + # Check command file was created + cmd_file = claude_dir / "speckit.test.hello.md" + assert cmd_file.exists() + + content = cmd_file.read_text() + assert "description: Test hello command" in content + assert "" in content + assert "" in content + + def test_command_with_aliases(self, project_dir, temp_dir): + """Test registering a command with aliases.""" + import yaml + + # Create extension with command alias + ext_dir = temp_dir / "ext-alias" + ext_dir.mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "ext-alias", + "name": "Extension with Alias", + "version": "1.0.0", + "description": "Test", + }, + "requires": { + "speckit_version": ">=0.1.0", + }, + "provides": { + "commands": [ + { + "name": "speckit.alias.cmd", + "file": "commands/cmd.md", + "aliases": ["speckit.shortcut"], + } + ] + }, + } + + with open(ext_dir / "extension.yml", 'w') as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nTest") + + claude_dir = project_dir / ".claude" / "commands" + claude_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + registrar = CommandRegistrar() + registered = registrar.register_commands_for_claude(manifest, ext_dir, project_dir) + + assert len(registered) == 2 + assert "speckit.alias.cmd" in registered + assert "speckit.shortcut" in registered + assert (claude_dir / "speckit.alias.cmd.md").exists() + assert (claude_dir / "speckit.shortcut.md").exists() + + +# ===== Utility Function Tests ===== + +class TestVersionSatisfies: + """Test version_satisfies utility function.""" + + def test_version_satisfies_simple(self): + """Test simple version comparison.""" + assert version_satisfies("1.0.0", ">=1.0.0") + assert version_satisfies("1.0.1", ">=1.0.0") + assert not version_satisfies("0.9.9", ">=1.0.0") + + def test_version_satisfies_range(self): + """Test version range.""" + assert version_satisfies("1.5.0", ">=1.0.0,<2.0.0") + assert not version_satisfies("2.0.0", ">=1.0.0,<2.0.0") + assert not version_satisfies("0.9.0", ">=1.0.0,<2.0.0") + + def test_version_satisfies_complex(self): + """Test complex version specifier.""" + assert version_satisfies("1.0.5", ">=1.0.0,!=1.0.3") + assert not version_satisfies("1.0.3", ">=1.0.0,!=1.0.3") + + def test_version_satisfies_invalid(self): + """Test invalid version strings.""" + assert not version_satisfies("invalid", ">=1.0.0") + assert not version_satisfies("1.0.0", "invalid specifier") + + +# ===== Integration Tests ===== + +class TestIntegration: + """Integration tests for complete workflows.""" + + def test_full_install_and_remove_workflow(self, extension_dir, project_dir): + """Test complete installation and removal workflow.""" + # Create Claude directory + (project_dir / ".claude" / "commands").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + + # Install + manifest = manager.install_from_directory( + extension_dir, + "0.1.0", + register_commands=True + ) + + # Verify installation + assert manager.registry.is_installed("test-ext") + installed = manager.list_installed() + assert len(installed) == 1 + assert installed[0]["id"] == "test-ext" + + # Verify command registered + cmd_file = project_dir / ".claude" / "commands" / "speckit.test.hello.md" + assert cmd_file.exists() + + # Verify registry has registered commands + metadata = manager.registry.get("test-ext") + assert "speckit.test.hello" in metadata["registered_commands"] + + # Remove + result = manager.remove("test-ext") + assert result is True + + # Verify removal + assert not manager.registry.is_installed("test-ext") + assert not cmd_file.exists() + assert len(manager.list_installed()) == 0 + + def test_multiple_extensions(self, temp_dir, project_dir): + """Test installing multiple extensions.""" + import yaml + + # Create two extensions + for i in range(1, 3): + ext_dir = temp_dir / f"ext{i}" + ext_dir.mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": f"ext{i}", + "name": f"Extension {i}", + "version": "1.0.0", + "description": f"Extension {i}", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": f"speckit.ext{i}.cmd", + "file": "commands/cmd.md", + } + ] + }, + } + + with open(ext_dir / "extension.yml", 'w') as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\nTest") + + manager = ExtensionManager(project_dir) + + # Install both + manager.install_from_directory(temp_dir / "ext1", "0.1.0", register_commands=False) + manager.install_from_directory(temp_dir / "ext2", "0.1.0", register_commands=False) + + # Verify both installed + installed = manager.list_installed() + assert len(installed) == 2 + assert {ext["id"] for ext in installed} == {"ext1", "ext2"} + + # Remove first + manager.remove("ext1") + + # Verify only second remains + installed = manager.list_installed() + assert len(installed) == 1 + assert installed[0]["id"] == "ext2" + + +# ===== Extension Catalog Tests ===== + + +class TestExtensionCatalog: + """Test extension catalog functionality.""" + + def test_catalog_initialization(self, temp_dir): + """Test catalog initialization.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + assert catalog.project_root == project_dir + assert catalog.cache_dir == project_dir / ".specify" / "extensions" / ".cache" + + def test_cache_directory_creation(self, temp_dir): + """Test catalog cache directory is created when fetching.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + # Create mock catalog data + catalog_data = { + "schema_version": "1.0", + "extensions": { + "test-ext": { + "name": "Test Extension", + "id": "test-ext", + "version": "1.0.0", + "description": "Test", + } + }, + } + + # Manually save to cache to test cache reading + 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": "http://test.com/catalog.json", + } + ) + ) + + # Should use cache + result = catalog.fetch_catalog() + assert result == catalog_data + + def test_cache_expiration(self, temp_dir): + """Test that expired cache is not used.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + # Create expired cache + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + catalog_data = {"schema_version": "1.0", "extensions": {}} + catalog.cache_file.write_text(json.dumps(catalog_data)) + + # Set cache time to 2 hours ago (expired) + expired_time = datetime.now(timezone.utc).timestamp() - 7200 + expired_datetime = datetime.fromtimestamp(expired_time, tz=timezone.utc) + catalog.cache_metadata_file.write_text( + json.dumps( + { + "cached_at": expired_datetime.isoformat(), + "catalog_url": "http://test.com/catalog.json", + } + ) + ) + + # Cache should be invalid + assert not catalog.is_cache_valid() + + def test_search_all_extensions(self, temp_dir): + """Test searching all extensions without filters.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + # Create mock catalog + catalog_data = { + "schema_version": "1.0", + "extensions": { + "jira": { + "name": "Jira Integration", + "id": "jira", + "version": "1.0.0", + "description": "Jira integration", + "author": "Stats Perform", + "tags": ["issue-tracking", "jira"], + "verified": True, + }, + "linear": { + "name": "Linear Integration", + "id": "linear", + "version": "0.9.0", + "description": "Linear integration", + "author": "Community", + "tags": ["issue-tracking"], + "verified": False, + }, + }, + } + + # Save to cache + 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": "http://test.com", + } + ) + ) + + # Search without filters + results = catalog.search() + assert len(results) == 2 + + def test_search_by_query(self, temp_dir): + """Test searching by query text.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + # Create mock catalog + catalog_data = { + "schema_version": "1.0", + "extensions": { + "jira": { + "name": "Jira Integration", + "id": "jira", + "version": "1.0.0", + "description": "Jira issue tracking", + "tags": ["jira"], + }, + "linear": { + "name": "Linear Integration", + "id": "linear", + "version": "1.0.0", + "description": "Linear project management", + "tags": ["linear"], + }, + }, + } + + 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": "http://test.com", + } + ) + ) + + # Search for "jira" + results = catalog.search(query="jira") + assert len(results) == 1 + assert results[0]["id"] == "jira" + + def test_search_by_tag(self, temp_dir): + """Test searching by tag.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + # Create mock catalog + catalog_data = { + "schema_version": "1.0", + "extensions": { + "jira": { + "name": "Jira", + "id": "jira", + "version": "1.0.0", + "description": "Jira", + "tags": ["issue-tracking", "jira"], + }, + "linear": { + "name": "Linear", + "id": "linear", + "version": "1.0.0", + "description": "Linear", + "tags": ["issue-tracking", "linear"], + }, + "github": { + "name": "GitHub", + "id": "github", + "version": "1.0.0", + "description": "GitHub", + "tags": ["vcs", "github"], + }, + }, + } + + 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": "http://test.com", + } + ) + ) + + # Search by tag "issue-tracking" + results = catalog.search(tag="issue-tracking") + assert len(results) == 2 + assert {r["id"] for r in results} == {"jira", "linear"} + + def test_search_verified_only(self, temp_dir): + """Test searching verified extensions only.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + # Create mock catalog + catalog_data = { + "schema_version": "1.0", + "extensions": { + "jira": { + "name": "Jira", + "id": "jira", + "version": "1.0.0", + "description": "Jira", + "verified": True, + }, + "linear": { + "name": "Linear", + "id": "linear", + "version": "1.0.0", + "description": "Linear", + "verified": False, + }, + }, + } + + 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": "http://test.com", + } + ) + ) + + # Search verified only + results = catalog.search(verified_only=True) + assert len(results) == 1 + assert results[0]["id"] == "jira" + + def test_get_extension_info(self, temp_dir): + """Test getting specific extension info.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + # Create mock catalog + catalog_data = { + "schema_version": "1.0", + "extensions": { + "jira": { + "name": "Jira Integration", + "id": "jira", + "version": "1.0.0", + "description": "Jira integration", + "author": "Stats Perform", + }, + }, + } + + 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": "http://test.com", + } + ) + ) + + # Get extension info + info = catalog.get_extension_info("jira") + assert info is not None + assert info["id"] == "jira" + assert info["name"] == "Jira Integration" + + # Non-existent extension + info = catalog.get_extension_info("nonexistent") + assert info is None + + def test_clear_cache(self, temp_dir): + """Test clearing catalog cache.""" + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + # Create cache + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + catalog.cache_file.write_text("{}") + catalog.cache_metadata_file.write_text("{}") + + assert catalog.cache_file.exists() + assert catalog.cache_metadata_file.exists() + + # Clear cache + catalog.clear_cache() + + assert not catalog.cache_file.exists() + assert not catalog.cache_metadata_file.exists() From aeb87e0e0b9c303ddb3852f30b3e7244a26c5169 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Tue, 3 Feb 2026 17:54:54 +0100 Subject: [PATCH 2/8] Update Jira extension to v2.1.0 in catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 2-level mode support (Epic → Stories only). Co-Authored-By: Claude Opus 4.5 --- extensions/catalog.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/catalog.json b/extensions/catalog.json index a7e84342c0..9e636ef1d0 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -8,8 +8,8 @@ "id": "jira", "description": "Create Jira Epics, Stories, and Issues from spec-kit artifacts", "author": "Michal Bachorik", - "version": "2.0.0", - "download_url": "https://github.com/mbachorik/spec-kit-jira/archive/refs/tags/v2.0.0.zip", + "version": "2.1.0", + "download_url": "https://github.com/mbachorik/spec-kit-jira/archive/refs/tags/v2.1.0.zip", "repository": "https://github.com/mbachorik/spec-kit-jira", "homepage": "https://github.com/mbachorik/spec-kit-jira", "documentation": "https://github.com/mbachorik/spec-kit-jira/blob/main/docs/", From cfa0f7fca6b3f4633e818aaef129531edb5819b5 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Tue, 3 Feb 2026 21:56:44 +0100 Subject: [PATCH 3/8] Address PR review feedback - Fix Zip Slip vulnerability in ZIP extraction with path validation - Fix keep_config option to actually preserve config files on removal - Add URL validation for SPECKIT_CATALOG_URL (HTTPS required, localhost exception) - Add security warning when installing from custom URLs (--from flag) - Empty catalog.json so organizations can ship their own catalogs - Fix markdown linter errors (MD040: add language to code blocks) - Remove redundant import and fix unused variables in tests - Add comment explaining empty except clause for backwards compatibility Co-Authored-By: Claude Opus 4.5 --- extensions/EXTENSION-API-REFERENCE.md | 2 +- extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 20 +++----- extensions/EXTENSION-PUBLISHING-GUIDE.md | 4 +- extensions/EXTENSION-USER-GUIDE.md | 20 ++++---- extensions/RFC-EXTENSION-SYSTEM.md | 25 +++++----- extensions/catalog.json | 45 ++--------------- src/specify_cli/__init__.py | 15 ++++++ src/specify_cli/extensions.py | 59 ++++++++++++++++++++--- tests/test_extensions.py | 4 +- 9 files changed, 104 insertions(+), 90 deletions(-) diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 3e7cc48157..9764ca8315 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -679,7 +679,7 @@ satisfied = version_satisfies("1.2.3", ">=1.0.0,<2.0.0") # bool ## File System Layout -``` +```text .specify/ ├── extensions/ │ ├── .registry # Extension registry (JSON) diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index 3f165404b7..ff7a3aabe5 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -102,8 +102,6 @@ echo "Arguments: $ARGUMENTS" Load extension config from `.specify/extensions/my-ext/my-ext-config.yml`. -``` - ### 5. Test Locally ```bash @@ -247,7 +245,7 @@ Use standard Markdown with special placeholders: **Example**: -```markdown +````markdown ## Steps 1. Parse arguments @@ -257,14 +255,14 @@ Use standard Markdown with special placeholders: args="$ARGUMENTS" echo "Running with args: $args" ``` - -``` +```` ### Script Path Rewriting Extension commands use relative paths that get rewritten during registration: **In extension**: + ```yaml scripts: sh: ../../scripts/bash/helper.sh @@ -425,7 +423,7 @@ def test_command_files_exist(): 1. **Create repository**: `spec-kit-my-ext` 2. **Add files**: - ``` + ```text spec-kit-my-ext/ ├── extension.yml ├── commands/ @@ -531,7 +529,7 @@ provides: file: "commands/hello.md" ``` -```markdown +````markdown --- description: "Hello command" @@ -542,8 +540,7 @@ description: "Hello command" ```bash echo "Hello, $ARGUMENTS!" ``` - -``` +```` ### Extension with Config @@ -565,7 +562,7 @@ api_endpoint: "https://api.example.com" timeout: 30 ``` -```markdown +````markdown # Use Config @@ -575,8 +572,7 @@ config_file=".specify/extensions/tool/tool-config.yml" endpoint=$(yq eval '.api_endpoint' "$config_file") echo "Using endpoint: $endpoint" ``` - -``` +```` ### Extension with Hooks diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md index 74d7e227eb..10eacbf909 100644 --- a/extensions/EXTENSION-PUBLISHING-GUIDE.md +++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md @@ -32,7 +32,7 @@ Before publishing an extension, ensure you have: Ensure your extension follows the standard structure: -``` +```text your-extension/ ├── extension.yml # Required: Extension manifest ├── README.md # Required: Documentation @@ -109,7 +109,7 @@ git push origin v1.0.0 The release archive URL will be: -``` +```text https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip ``` diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index 80d2ebe0a2..fb3e91573f 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -170,7 +170,7 @@ specify extension add --dev /path/to/extension ### Installation Output -``` +```text ✓ Extension installed successfully! Jira Integration (v1.0.0) @@ -193,7 +193,7 @@ Provided commands: Extensions add commands that appear in your AI agent (Claude Code): -``` +```text # In Claude Code > /speckit.jira.specstoissues @@ -226,7 +226,7 @@ Some extensions provide hooks that execute after core commands: **Example**: Jira extension hooks into `/speckit.tasks` -``` +```text # Run core command > /speckit.tasks @@ -255,7 +255,7 @@ specify extension list Output: -``` +```text Installed Extensions: ✓ Jira Integration (v1.0.0) @@ -275,7 +275,7 @@ specify extension update jira Output: -``` +```text 🔄 Checking for updates... Updates available: @@ -326,7 +326,7 @@ specify extension remove jira --force Extensions can have multiple configuration files: -``` +```text .specify/extensions/jira/ ├── jira-config.yml # Main config (version controlled) ├── jira-config.local.yml # Local overrides (gitignored) @@ -400,10 +400,10 @@ In addition to extension-specific environment variables (`SPECKIT_{EXT_ID}_*`), | Variable | Description | Default | |----------|-------------|---------| -| `SPECKIT_CATALOG_URL` | Override the extension catalog URL | GitHub-hosted catalog | -| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub API token for downloads | None | +| `SPECKIT_CATALOG_URL` | Override the extension catalog URL | GitHub-hosted catalog | +| `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 # Point to a local or alternative catalog @@ -519,7 +519,7 @@ export SPECKIT_CATALOG_URL="https://example.com/staging/catalog.json" Add to `.gitignore`: -``` +```gitignore .specify/extensions/.cache/ .specify/extensions/.backup/ .specify/extensions/*/*.local.yml diff --git a/extensions/RFC-EXTENSION-SYSTEM.md b/extensions/RFC-EXTENSION-SYSTEM.md index db0abff880..3bfa0ea060 100644 --- a/extensions/RFC-EXTENSION-SYSTEM.md +++ b/extensions/RFC-EXTENSION-SYSTEM.md @@ -104,7 +104,7 @@ Introduce an extension system to Spec Kit that allows modular integration with e ### Directory Structure -``` +```text project/ ├── .specify/ │ ├── scripts/ # Core scripts (unchanged) @@ -125,7 +125,7 @@ project/ ### Component Diagram -``` +```text ┌─────────────────────────────────────────────────────────┐ │ Spec Kit Core │ │ ┌──────────────────────────────────────────────────┐ │ @@ -627,7 +627,7 @@ export SPECKIT_JIRA_PROJECT_KEY="DEVTEST" **Location**: Extension command (e.g., `commands/specstoissues.md`) -```markdown +````markdown ## Load Configuration 1. Run helper script to load and merge config: @@ -638,8 +638,7 @@ echo "$config_json" ``` 1. Parse JSON and use in subsequent steps - -``` +```` **Script**: `.specify/extensions/jira/scripts/parse-jira-config.sh` @@ -679,7 +678,7 @@ echo "$defaults" **In command file**: -```markdown +````markdown ## Validate Configuration 1. Load config (from previous step) @@ -700,8 +699,7 @@ except jsonschema.ValidationError as e: ``` 1. Proceed with validated config - -``` +```` --- @@ -760,7 +758,7 @@ hooks: Add at end of command: -```markdown +````markdown ## Extension Hooks After task generation completes, check for registered hooks: @@ -802,8 +800,7 @@ if [ -f ".specify/extensions.yml" ]; then fi fi ``` - -``` +```` **AI Agent Handling:** @@ -1243,7 +1240,7 @@ provides: **At runtime, show warning:** -``` +```text ⚠️ Warning: /speckit.jira.old-command is deprecated Use /speckit.jira.new-command instead This command will be removed in v2.0.0 @@ -1276,7 +1273,7 @@ Extensions run with **same privileges as AI agent**: - Not verified, use at own risk - Show warning during installation: - ``` + ```text ⚠️ This extension is not verified. Review code before installing: https://github.com/... @@ -1594,7 +1591,7 @@ AI agent registers both names, so old scripts work. **Complete structure of `spec-kit-jira` extension:** -``` +```text spec-kit-jira/ ├── README.md # Overview, features, installation ├── LICENSE # MIT license diff --git a/extensions/catalog.json b/extensions/catalog.json index 9e636ef1d0..bdebd83dd4 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -1,45 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-01-28T15:54:00Z", + "updated_at": "2026-02-03T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", - "extensions": { - "jira": { - "name": "Jira Integration", - "id": "jira", - "description": "Create Jira Epics, Stories, and Issues from spec-kit artifacts", - "author": "Michal Bachorik", - "version": "2.1.0", - "download_url": "https://github.com/mbachorik/spec-kit-jira/archive/refs/tags/v2.1.0.zip", - "repository": "https://github.com/mbachorik/spec-kit-jira", - "homepage": "https://github.com/mbachorik/spec-kit-jira", - "documentation": "https://github.com/mbachorik/spec-kit-jira/blob/main/docs/", - "changelog": "https://github.com/mbachorik/spec-kit-jira/blob/main/CHANGELOG.md", - "license": "MIT", - "requires": { - "speckit_version": ">=0.1.0", - "tools": [ - { - "name": "atlassian/atlassian-mcp-server", - "version": ">=1.0.0", - "required": true - } - ] - }, - "provides": { - "commands": 3, - "hooks": 1 - }, - "tags": [ - "issue-tracking", - "jira", - "atlassian", - "project-management" - ], - "verified": true, - "downloads": 0, - "stars": 0, - "created_at": "2026-01-28T15:54:00Z", - "updated_at": "2026-01-28T15:54:00Z" - } - } -} \ No newline at end of file + "extensions": {} +} diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 397a099762..e387dd74bc 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1387,6 +1387,8 @@ def get_speckit_version() -> str: data = tomllib.load(f) return data.get("project", {}).get("version", "unknown") except Exception: + # Intentionally ignore any errors while reading/parsing pyproject.toml. + # If this lookup fails for any reason, we fall back to returning "unknown" below. pass return "unknown" @@ -1474,7 +1476,20 @@ def extension_add( # Install from URL (ZIP file) import urllib.request import urllib.error + from urllib.parse import urlparse + # Validate URL + parsed = urlparse(from_url) + is_localhost = parsed.netloc.startswith("localhost") or parsed.netloc.startswith("127.0.0.1") + + if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): + console.print("[red]Error:[/red] URL must use HTTPS for security.") + console.print("HTTP is only allowed for localhost URLs.") + raise typer.Exit(1) + + # Warn about untrusted sources + console.print("[yellow]Warning:[/yellow] Installing from external URL.") + console.print("Only install extensions from sources you trust.\n") console.print(f"Downloading from {from_url}...") # Download ZIP to temp location diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 0d3927ce73..4aad04e915 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -388,8 +388,15 @@ def install_from_zip( with tempfile.TemporaryDirectory() as tmpdir: temp_path = Path(tmpdir) - # Extract ZIP + # Extract ZIP safely (prevent Zip Slip attack) with zipfile.ZipFile(zip_path, 'r') as zf: + for member in zf.namelist(): + # Resolve the target path and ensure it's within temp_path + member_path = (temp_path / member).resolve() + if not str(member_path).startswith(str(temp_path.resolve())): + raise ValidationError( + f"Unsafe path in ZIP archive: {member} (potential path traversal)" + ) zf.extractall(temp_path) # Find extension directory (may be nested) @@ -445,9 +452,18 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: if keep_config: # Preserve config files, only remove non-config files - # For now, just skip deletion entirely (simpler approach) - # Future: could selectively preserve only *-config.yml files - pass + if extension_dir.exists(): + for child in extension_dir.iterdir(): + # Keep top-level *-config.yml and *-config.local.yml files + if child.is_file() and ( + child.name.endswith("-config.yml") or + child.name.endswith("-config.local.yml") + ): + continue + if child.is_dir(): + shutil.rmtree(child) + else: + child.unlink() else: # Backup config files before deleting if extension_dir.exists(): @@ -939,12 +955,42 @@ def get_catalog_url(self) -> str: Returns: URL to fetch catalog from + + Raises: + ValidationError: If custom URL is invalid (non-HTTPS) """ import os + import sys + from urllib.parse import urlparse # Environment variable override (useful for testing) - if catalog_url := os.environ.get("SPECKIT_CATALOG_URL"): - return catalog_url.strip() + if env_value := os.environ.get("SPECKIT_CATALOG_URL"): + catalog_url = env_value.strip() + parsed = urlparse(catalog_url) + + # Require HTTPS for security (prevent man-in-the-middle attacks) + # Allow http://localhost for local development/testing + is_localhost = parsed.netloc.startswith("localhost") or parsed.netloc.startswith("127.0.0.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 + if catalog_url != self.DEFAULT_CATALOG_URL: + print( + "Warning: Using non-default extension catalog. " + "Only use catalogs from sources you trust.", + file=sys.stderr, + ) + + return catalog_url # TODO: Support custom catalogs from .specify/extension-catalogs.yml return self.DEFAULT_CATALOG_URL @@ -1517,7 +1563,6 @@ def _evaluate_condition(self, condition: str, extension_id: Optional[str]) -> bo True if condition is met, False otherwise """ import os - import re condition = condition.strip() diff --git a/tests/test_extensions.py b/tests/test_extensions.py index eba39993e8..0ad9aa8543 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -449,7 +449,7 @@ def test_register_commands_for_claude(self, extension_dir, project_dir): claude_dir = project_dir / ".claude" / "commands" claude_dir.mkdir(parents=True) - manager = ExtensionManager(project_dir) + ExtensionManager(project_dir) # Initialize manager (side effects only) manifest = ExtensionManifest(extension_dir / "extension.yml") registrar = CommandRegistrar() @@ -562,7 +562,7 @@ def test_full_install_and_remove_workflow(self, extension_dir, project_dir): manager = ExtensionManager(project_dir) # Install - manifest = manager.install_from_directory( + manager.install_from_directory( extension_dir, "0.1.0", register_commands=True From 063a04b683351c863d830285ea056b5bf047cd35 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Tue, 3 Feb 2026 22:02:19 +0100 Subject: [PATCH 4/8] Add comprehensive organization catalog customization docs - Explain why default catalog is empty (org control) - Document how to create and host custom catalogs - Add catalog JSON schema reference - Include use cases: private extensions, curated catalogs, air-gapped environments - Add examples for combining catalog with direct installation Co-Authored-By: Claude Opus 4.5 --- extensions/EXTENSION-USER-GUIDE.md | 209 +++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index fb3e91573f..46e87cf6cc 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -415,6 +415,215 @@ export SPECKIT_CATALOG_URL="https://example.com/staging/catalog.json" --- +## Organization Catalog Customization + +### Why the Default Catalog is Empty + +The default spec-kit catalog ships empty by design. This allows organizations to: + +- **Control available extensions** - Curate which extensions your team can install +- **Host private extensions** - Internal tools that shouldn't be public +- **Customize for compliance** - Meet security/audit requirements +- **Support air-gapped environments** - Work without internet access + +### Setting Up a Custom Catalog + +#### 1. Create Your Catalog File + +Create a `catalog.json` file with your extensions: + +```json +{ + "schema_version": "1.0", + "updated_at": "2026-02-03T00:00:00Z", + "catalog_url": "https://your-org.com/spec-kit/catalog.json", + "extensions": { + "jira": { + "name": "Jira Integration", + "id": "jira", + "description": "Create Jira issues from spec-kit artifacts", + "author": "Your Organization", + "version": "2.1.0", + "download_url": "https://github.com/your-org/spec-kit-jira/archive/refs/tags/v2.1.0.zip", + "repository": "https://github.com/your-org/spec-kit-jira", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + {"name": "atlassian-mcp-server", "required": true} + ] + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": ["jira", "atlassian", "issue-tracking"], + "verified": true + }, + "internal-tool": { + "name": "Internal Tool Integration", + "id": "internal-tool", + "description": "Connect to internal company systems", + "author": "Your Organization", + "version": "1.0.0", + "download_url": "https://internal.your-org.com/extensions/internal-tool-1.0.0.zip", + "repository": "https://github.internal.your-org.com/spec-kit-internal", + "license": "Proprietary", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 2 + }, + "tags": ["internal", "proprietary"], + "verified": true + } + } +} +``` + +#### 2. Host the Catalog + +Options for hosting your catalog: + +| Method | URL Example | Use Case | +| ------ | ----------- | -------- | +| GitHub Pages | `https://your-org.github.io/spec-kit-catalog/catalog.json` | Public or org-visible | +| Internal web server | `https://internal.company.com/spec-kit/catalog.json` | Corporate network | +| S3/Cloud storage | `https://s3.amazonaws.com/your-bucket/catalog.json` | Cloud-hosted teams | +| Local file server | `http://localhost:8000/catalog.json` | Development/testing | + +**Security requirement**: URLs must use HTTPS (except `localhost` for testing). + +#### 3. Configure Your Environment + +##### Option A: Environment variable (recommended for CI/CD) + +```bash +# In ~/.bashrc, ~/.zshrc, or CI pipeline +export SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json" +``` + +##### Option B: Per-project configuration + +Create `.env` or set in your shell before running spec-kit commands: + +```bash +SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json" specify extension search +``` + +#### 4. Verify Configuration + +```bash +# Search should now show your catalog's extensions +specify extension search + +# Install from your catalog +specify extension add jira +``` + +### Catalog JSON Schema + +Required fields for each extension entry: + +| Field | Type | Required | Description | +| ----- | ---- | -------- | ----------- | +| `name` | string | Yes | Human-readable name | +| `id` | string | Yes | Unique identifier (lowercase, hyphens) | +| `version` | string | Yes | Semantic version (X.Y.Z) | +| `download_url` | string | Yes | URL to ZIP archive | +| `repository` | string | Yes | Source code URL | +| `description` | string | No | Brief description | +| `author` | string | No | Author/organization | +| `license` | string | No | SPDX license identifier | +| `requires.speckit_version` | string | No | Version constraint | +| `requires.tools` | array | No | Required external tools | +| `provides.commands` | number | No | Number of commands | +| `provides.hooks` | number | No | Number of hooks | +| `tags` | array | No | Search tags | +| `verified` | boolean | No | Verification status | + +### Use Cases + +#### Private/Internal Extensions + +Host proprietary extensions that integrate with internal systems: + +```json +{ + "internal-auth": { + "name": "Internal SSO Integration", + "download_url": "https://artifactory.company.com/spec-kit/internal-auth-1.0.0.zip", + "verified": true + } +} +``` + +#### Curated Team Catalog + +Limit which extensions your team can install: + +```json +{ + "extensions": { + "jira": { "..." }, + "github": { "..." } + } +} +``` + +Only `jira` and `github` will appear in `specify extension search`. + +#### Air-Gapped Environments + +For networks without internet access: + +1. Download extension ZIPs to internal file server +2. Create catalog pointing to internal URLs +3. Host catalog on internal web server + +```json +{ + "jira": { + "download_url": "https://files.internal/spec-kit/jira-2.1.0.zip" + } +} +``` + +#### Development/Testing + +Test new extensions before publishing: + +```bash +# Start local server +python -m http.server 8000 --directory ./my-catalog/ + +# Point spec-kit to local catalog +export SPECKIT_CATALOG_URL="http://localhost:8000/catalog.json" + +# Test installation +specify extension add my-new-extension +``` + +### Combining with Direct Installation + +You can still install extensions not in your catalog using `--from`: + +```bash +# From catalog +specify extension add jira + +# Direct URL (bypasses catalog) +specify extension add --from https://github.com/someone/spec-kit-ext/archive/v1.0.0.zip + +# Local development +specify extension add --dev /path/to/extension +``` + +**Note**: Direct URL installation shows a security warning since the extension isn't from your configured catalog. + +--- + ## Troubleshooting ### Extension Not Found From 6d4c6f52b1079a9128e5050ca2224299927ed975 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Tue, 3 Feb 2026 22:06:32 +0100 Subject: [PATCH 5/8] Fix test assertions for extension system data structures - Update test_config_backup_on_remove to use new subdirectory structure (.backup/test-ext/file.yml instead of .backup/test-ext-file.yml) - Update test_full_install_and_remove_workflow to handle registered_commands being a dict keyed by agent name instead of a flat list Co-Authored-By: Claude Opus 4.5 --- tests/test_extensions.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 0ad9aa8543..a2c4121ed4 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -387,9 +387,9 @@ def test_config_backup_on_remove(self, extension_dir, project_dir): # Remove extension (without keep_config) manager.remove("test-ext", keep_config=False) - # Check backup was created - backup_dir = project_dir / ".specify" / "extensions" / ".backup" - backup_file = backup_dir / "test-ext-test-ext-config.yml" + # Check backup was created (now in subdirectory per extension) + backup_dir = project_dir / ".specify" / "extensions" / ".backup" / "test-ext" + backup_file = backup_dir / "test-ext-config.yml" assert backup_file.exists() assert backup_file.read_text() == "test: config" @@ -578,9 +578,14 @@ def test_full_install_and_remove_workflow(self, extension_dir, project_dir): cmd_file = project_dir / ".claude" / "commands" / "speckit.test.hello.md" assert cmd_file.exists() - # Verify registry has registered commands + # Verify registry has registered commands (now a dict keyed by agent) metadata = manager.registry.get("test-ext") - assert "speckit.test.hello" in metadata["registered_commands"] + registered_commands = metadata["registered_commands"] + # Check that the command is registered for at least one agent + assert any( + "speckit.test.hello" in cmds + for cmds in registered_commands.values() + ) # Remove result = manager.remove("test-ext") From 352bd8021c8812b346ae6b0617fde9d543a7b340 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Tue, 3 Feb 2026 22:30:53 +0100 Subject: [PATCH 6/8] Address Copilot review feedback - Fix localhost URL check to use parsed.hostname instead of netloc.startswith() This correctly handles URLs with ports like localhost:8080 - Fix YAML indentation error in config-template.yml (line 57) - Fix double space typo in example.md (line 172) Co-Authored-By: Claude Opus 4.5 --- extensions/template/commands/example.md | 2 +- extensions/template/config-template.yml | 2 +- src/specify_cli/__init__.py | 2 +- src/specify_cli/extensions.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/template/commands/example.md b/extensions/template/commands/example.md index c2b6b30c57..9507120fc7 100644 --- a/extensions/template/commands/example.md +++ b/extensions/template/commands/example.md @@ -169,7 +169,7 @@ cp .specify/extensions/my-extension/config-template.yml \ - This command requires an active connection to the external service -- Results are cached for performance +- Results are cached for performance - Re-run the command to refresh data ## Examples diff --git a/extensions/template/config-template.yml b/extensions/template/config-template.yml index d424a3507e..74725373d7 100644 --- a/extensions/template/config-template.yml +++ b/extensions/template/config-template.yml @@ -54,7 +54,7 @@ advanced: timeout: 30 # Retry attempts - retry_count: 3 + retry_count: 3 # Cache duration in seconds cache_duration: 3600 diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e387dd74bc..9f9027af97 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1480,7 +1480,7 @@ def extension_add( # Validate URL parsed = urlparse(from_url) - is_localhost = parsed.netloc.startswith("localhost") or parsed.netloc.startswith("127.0.0.1") + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): console.print("[red]Error:[/red] URL must use HTTPS for security.") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 4aad04e915..3a4b9867cc 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -970,7 +970,7 @@ def get_catalog_url(self) -> str: # Require HTTPS for security (prevent man-in-the-middle attacks) # Allow http://localhost for local development/testing - is_localhost = parsed.netloc.startswith("localhost") or parsed.netloc.startswith("127.0.0.1") + 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}://). " From 42b869a20209d1b6d7c78751fc461d8cf204573c Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Thu, 5 Feb 2026 21:15:11 +0100 Subject: [PATCH 7/8] Add catalog.example.json as reference for organizations The main catalog.json is intentionally empty so organizations can ship their own curated catalogs. This example file shows the expected schema and structure for creating organization-specific catalogs. Co-Authored-By: Claude Opus 4.5 --- extensions/catalog.example.json | 60 +++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 extensions/catalog.example.json diff --git a/extensions/catalog.example.json b/extensions/catalog.example.json new file mode 100644 index 0000000000..afbcc0b566 --- /dev/null +++ b/extensions/catalog.example.json @@ -0,0 +1,60 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-02-03T00:00:00Z", + "catalog_url": "https://your-org.example.com/speckit/catalog.json", + "extensions": { + "jira": { + "name": "Jira Integration", + "id": "jira", + "description": "Create Jira Epics, Stories, and Issues from spec-kit artifacts", + "author": "Your Organization", + "version": "2.1.0", + "download_url": "https://github.com/your-org/spec-kit-jira/archive/refs/tags/v2.1.0.zip", + "repository": "https://github.com/your-org/spec-kit-jira", + "homepage": "https://github.com/your-org/spec-kit-jira", + "documentation": "https://github.com/your-org/spec-kit-jira/blob/main/README.md", + "changelog": "https://github.com/your-org/spec-kit-jira/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "atlassian", + "version": ">=1.0.0", + "required": true + } + ] + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": ["jira", "atlassian", "issue-tracking"], + "verified": true, + "downloads": 0, + "stars": 0, + "created_at": "2026-01-28T00:00:00Z", + "updated_at": "2026-02-03T00:00:00Z" + }, + "linear": { + "name": "Linear Integration", + "id": "linear", + "description": "Sync specs and tasks with Linear issues", + "author": "Your Organization", + "version": "1.0.0", + "download_url": "https://github.com/your-org/spec-kit-linear/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/your-org/spec-kit-linear", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 2 + }, + "tags": ["linear", "issue-tracking"], + "verified": false, + "created_at": "2026-01-30T00:00:00Z", + "updated_at": "2026-01-30T00:00:00Z" + } + } +} From 95f818a2fd5d0a521475063a9d871e241e728fbf Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Thu, 5 Feb 2026 23:12:13 +0100 Subject: [PATCH 8/8] Address remaining Copilot security and logic review feedback - Fix Zip Slip vulnerability by using relative_to() for safe path validation - Add HTTPS validation for extension download URLs - Backup both *-config.yml and *-config.local.yml files on remove - Normalize boolean values to lowercase for hook condition comparisons - Show non-default catalog warning only once per instance Co-Authored-By: Claude Opus 4.5 --- src/specify_cli/extensions.py | 48 +++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 3a4b9867cc..08ce2beab3 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -390,13 +390,18 @@ def install_from_zip( # Extract ZIP safely (prevent Zip Slip attack) with zipfile.ZipFile(zip_path, 'r') as zf: + # Validate all paths first before extracting anything + temp_path_resolved = temp_path.resolve() for member in zf.namelist(): - # Resolve the target path and ensure it's within temp_path member_path = (temp_path / member).resolve() - if not str(member_path).startswith(str(temp_path.resolve())): + # Use is_relative_to for safe path containment check + try: + member_path.relative_to(temp_path_resolved) + except ValueError: raise ValidationError( f"Unsafe path in ZIP archive: {member} (potential path traversal)" ) + # Only extract after all paths are validated zf.extractall(temp_path) # Find extension directory (may be nested) @@ -472,7 +477,10 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: backup_dir = self.extensions_dir / ".backup" / extension_id backup_dir.mkdir(parents=True, exist_ok=True) - config_files = list(extension_dir.glob("*-config.yml")) + # Backup both primary and local override config files + config_files = list(extension_dir.glob("*-config.yml")) + list( + extension_dir.glob("*-config.local.yml") + ) for config_file in config_files: backup_path = backup_dir / config_file.name shutil.copy2(config_file, backup_path) @@ -982,13 +990,15 @@ def get_catalog_url(self) -> str: "Invalid SPECKIT_CATALOG_URL: must be a valid URL with a host." ) - # Warn users when using a non-default catalog + # Warn users when using a non-default catalog (once per instance) if catalog_url != self.DEFAULT_CATALOG_URL: - print( - "Warning: Using non-default extension catalog. " - "Only use catalogs from sources you trust.", - file=sys.stderr, - ) + if not getattr(self, "_non_default_catalog_warning_shown", False): + print( + "Warning: Using non-default extension catalog. " + "Only use catalogs from sources you trust.", + file=sys.stderr, + ) + self._non_default_catalog_warning_shown = True return catalog_url @@ -1158,6 +1168,15 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non if not download_url: raise ExtensionError(f"Extension '{extension_id}' has no download URL") + # Validate download URL requires HTTPS (prevent man-in-the-middle attacks) + from urllib.parse import urlparse + parsed = urlparse(download_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 ExtensionError( + f"Extension download URL must use HTTPS: {download_url}" + ) + # Determine target path if target_dir is None: target_dir = self.cache_dir / "downloads" @@ -1587,10 +1606,17 @@ def _evaluate_condition(self, condition: str, extension_id: Optional[str]) -> bo config_manager = ConfigManager(self.project_root, extension_id) actual_value = config_manager.get_value(key_path) + # Normalize boolean values to lowercase for comparison + # (YAML True/False vs condition strings 'true'/'false') + if isinstance(actual_value, bool): + normalized_value = "true" if actual_value else "false" + else: + normalized_value = str(actual_value) + if operator == "==": - return str(actual_value) == expected_value + return normalized_value == expected_value else: # != - return str(actual_value) != expected_value + return normalized_value != expected_value # Pattern: "env.VAR_NAME is set" if match := re.match(r'env\.([A-Z0-9_]+)\s+is\s+set', condition, re.IGNORECASE):