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..9764ca8315 --- /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 + +```text +.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..ff7a3aabe5 --- /dev/null +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -0,0 +1,649 @@ +# 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**: + + ```text + 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..10eacbf909 --- /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: + +```text +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: + +```text +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..46e87cf6cc --- /dev/null +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -0,0 +1,885 @@ +# 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 + +```text +✓ 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): + +```text +# 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` + +```text +# 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: + +```text +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: + +```text +🔄 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: + +```text +.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" +``` + +--- + +## 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 + +**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`: + +```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..3bfa0ea060 --- /dev/null +++ b/extensions/RFC-EXTENSION-SYSTEM.md @@ -0,0 +1,1791 @@ +# 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 + +```text +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 + +```text +┌─────────────────────────────────────────────────────────┐ +│ 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:** + +```text +⚠️ 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: + + ```text + ⚠️ 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:** + +```text +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.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" + } + } +} diff --git a/extensions/catalog.json b/extensions/catalog.json new file mode 100644 index 0000000000..bdebd83dd4 --- /dev/null +++ b/extensions/catalog.json @@ -0,0 +1,6 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-02-03T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", + "extensions": {} +} 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..9507120fc7 --- /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..74725373d7 --- /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..9f9027af97 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1361,6 +1361,641 @@ 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: + # 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" + + +@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 + from urllib.parse import urlparse + + # Validate URL + parsed = urlparse(from_url) + 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.") + 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 + 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..08ce2beab3 --- /dev/null +++ b/src/specify_cli/extensions.py @@ -0,0 +1,1785 @@ +""" +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 safely (prevent Zip Slip attack) + with zipfile.ZipFile(zip_path, 'r') as zf: + # Validate all paths first before extracting anything + temp_path_resolved = temp_path.resolve() + for member in zf.namelist(): + member_path = (temp_path / member).resolve() + # Use is_relative_to for safe path containment check + try: + member_path.relative_to(temp_path_resolved) + except ValueError: + raise ValidationError( + f"Unsafe path in ZIP archive: {member} (potential path traversal)" + ) + # Only extract after all paths are validated + zf.extractall(temp_path) + + # Find extension directory (may be nested) + extension_dir = temp_path + 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 + 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(): + # 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) + + # 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) + + # 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 + + 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 env_value := os.environ.get("SPECKIT_CATALOG_URL"): + catalog_url = env_value.strip() + parsed = urlparse(catalog_url) + + # Require HTTPS for security (prevent man-in-the-middle attacks) + # Allow http://localhost for local development/testing + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): + raise ValidationError( + f"Invalid SPECKIT_CATALOG_URL: must use HTTPS (got {parsed.scheme}://). " + "HTTP is only allowed for localhost." + ) + + if not parsed.netloc: + raise ValidationError( + "Invalid SPECKIT_CATALOG_URL: must be a valid URL with a host." + ) + + # Warn users when using a non-default catalog (once per instance) + if catalog_url != self.DEFAULT_CATALOG_URL: + 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 + + # 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") + + # 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" + 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 + + 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) + + # 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 normalized_value == expected_value + else: # != + 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): + 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..a2c4121ed4 --- /dev/null +++ b/tests/test_extensions.py @@ -0,0 +1,989 @@ +""" +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 (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" + + +# ===== 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) + + ExtensionManager(project_dir) # Initialize manager (side effects only) + 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 + 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 (now a dict keyed by agent) + metadata = manager.registry.get("test-ext") + 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") + 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()