From 58ce653908a3770c84a26b5dad66821afdf7a7c0 Mon Sep 17 00:00:00 2001 From: Michal Bachorik Date: Fri, 13 Mar 2026 13:23:37 +0100 Subject: [PATCH 01/11] feat(extensions): Quality of life improvements for RFC-aligned catalog integration (#1776) * feat(extensions): implement automatic updates with atomic backup/restore - Implement automatic extension updates with download from catalog - Add comprehensive backup/restore mechanism for failed updates: - Backup registry entry before update - Backup extension directory - Backup command files for all AI agents - Backup hooks from extensions.yml - Add extension ID verification after install - Add KeyboardInterrupt handling to allow clean cancellation - Fix enable/disable to preserve installed_at timestamp by using direct registry manipulation instead of registry.add() - Add rollback on any update failure with command file, hook, and registry restoration Co-Authored-By: Claude Opus 4.5 * fix(extensions): comprehensive name resolution and error handling improvements - Add shared _resolve_installed_extension helper for ID/display name resolution with proper ambiguous name handling (shows table of matches) - Add _resolve_catalog_extension helper for catalog lookups by ID or display name - Update enable/disable/update/remove commands to use name resolution helpers - Fix extension_info to handle catalog errors gracefully: - Fallback to local installed info when catalog unavailable - Distinguish "catalog unavailable" from "not found in catalog" - Support display name lookup for both installed and catalog extensions - Use resolved display names in all status messages for consistency - Extract _print_extension_info helper for DRY catalog info printing Addresses reviewer feedback: - Ambiguous name handling in enable/disable/update - Catalog error fallback for installed extensions - UX message clarity (catalog unavailable vs not found) - Resolved ID in status messages Co-Authored-By: Claude Opus 4.5 * fix(extensions): properly detect ambiguous names in extension_info The extension_info command was breaking on the first name match without checking for ambiguity. This fix separates ID matching from name matching and checks for ambiguity before selecting a match, consistent with the _resolve_installed_extension() helper used by other commands. Co-Authored-By: Claude Opus 4.5 * refactor(extensions): add public update() method to ExtensionRegistry Add a proper public API for updating registry metadata while preserving installed_at timestamp, instead of directly mutating internal registry data and calling private _save() method. Changes: - Add ExtensionRegistry.update() method that preserves installed_at - Update enable/disable commands to use registry.update() - Update rollback logic to use registry.update() This decouples the CLI from registry internals and maintains proper encapsulation. Co-Authored-By: Claude Opus 4.5 * fix(extensions): safely access optional author field in extension_info ExtensionManifest doesn't expose an author property - the author field is optional in extension.yml and stored in data["extension"]["author"]. Use safe dict access to avoid AttributeError. Co-Authored-By: Claude Opus 4.5 * fix(extensions): address multiple reviewer comments - ExtensionRegistry.update() now preserves original installed_at timestamp - Add ExtensionRegistry.restore() for rollback (entry was removed) - Clean up wrongly installed extension on ID mismatch before rollback - Remove unused catalog_error parameter from _print_extension_info() Co-Authored-By: Claude Opus 4.5 * fix(extensions): check _install_allowed for updates, preserve backup on failed rollback - Skip automatic updates for extensions from catalogs with install_allowed=false - Only delete backup directory on successful rollback, preserve it on failure for manual recovery Co-Authored-By: Claude Opus 4.5 * fix(extensions): address reviewer feedback on update/rollback logic - Hook rollback: handle empty backup_hooks by checking `is not None` instead of truthiness (falsy empty dict would skip hook cleanup) - extension_info: use resolved_installed_id for catalog lookup when extension was found by display name (prevents wrong catalog match) - Rollback: always remove extension dir first, then restore if backup exists (handles case when no original dir existed before update) - Validate extension ID from ZIP before installing, not after (avoids side effects of installing wrong extension before rollback) - Preserve enabled state during updates: re-apply disabled state and hook enabled flags after successful update - Optimize _resolve_catalog_extension: pass query to catalog.search() instead of fetching all extensions - update() now merges metadata with existing entry instead of replacing (preserves fields like registered_commands when only updating enabled) - Add tests for ExtensionRegistry.update() and restore() methods: - test_update_preserves_installed_at - test_update_merges_with_existing - test_update_raises_for_missing_extension - test_restore_overwrites_completely - test_restore_can_recreate_removed_entry Co-Authored-By: Claude Opus 4.5 * docs(extensions): update RFC to reflect implemented status - Change status from "Draft" to "Implemented" - Update all Implementation Phases to show completed items - Add new features implemented beyond original RFC: - Display name resolution for all commands - Ambiguous name handling with tables - Atomic update with rollback - Pre-install ID validation - Enabled state preservation - Registry update/restore methods - Catalog error fallback - _install_allowed flag - Cache invalidation - Convert Open Questions to Resolved Questions with decisions - Add remaining Open Questions (sandboxing, signatures) as future work - Fix table of contents links Co-Authored-By: Claude Opus 4.5 * fix(extensions): address third round of PR review comments - Refactor extension_info to use _resolve_installed_extension() helper with new allow_not_found parameter instead of duplicating resolution logic - Fix rollback hook restoration to not create empty hooks: {} in config when original config had no hooks section - Fix ZIP pre-validation to handle nested extension.yml files (GitHub auto-generated ZIPs have structure like repo-name-branch/extension.yml) - Replace unused installed_manifest variable with _ placeholder - Add display name to update status messages for better UX Co-Authored-By: Claude Opus 4.5 * fix(extensions): address fourth round of PR review comments Rollback fixes: - Preserve installed_at timestamp after successful update (was reset by install_from_zip calling registry.add) - Fix rollback to only delete extension_dir if backup exists (avoids destroying valid installation when failure happens before modification) - Fix rollback to remove NEW command files created by failed install (files that weren't in original backup are now cleaned up) - Fix rollback to delete hooks key entirely when backup_hooks is None (original config had no hooks key, so restore should remove it) Cross-command consistency fix: - Add display name resolution to `extension add` command using _resolve_catalog_extension() helper (was only in `extension info`) - Use resolved extension ID for download_extension() call, not original argument which may be a display name Security fix (fail-closed): - Malformed catalog config (empty/missing URLs) now raises ValidationError instead of silently falling back to built-in catalogs Co-Authored-By: Claude Opus 4.5 * fix(lint): address ruff linting errors and registry.update() semantics - Remove unused import ExtensionError in extension_info - Remove extraneous f-prefix from strings without placeholders - Use registry.restore() instead of registry.update() for installed_at preservation (update() always preserves existing installed_at, ignoring our override) Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: iamaeroplane Co-authored-by: Claude Opus 4.5 --- extensions/RFC-EXTENSION-SYSTEM.md | 247 +++++---- src/specify_cli/__init__.py | 792 +++++++++++++++++++++++------ src/specify_cli/extensions.py | 90 +++- tests/test_extensions.py | 397 ++++++++++++++- 4 files changed, 1238 insertions(+), 288 deletions(-) diff --git a/extensions/RFC-EXTENSION-SYSTEM.md b/extensions/RFC-EXTENSION-SYSTEM.md index c6469c48..a0f6034e 100644 --- a/extensions/RFC-EXTENSION-SYSTEM.md +++ b/extensions/RFC-EXTENSION-SYSTEM.md @@ -1,9 +1,9 @@ # RFC: Spec Kit Extension System -**Status**: Draft +**Status**: Implemented **Author**: Stats Perform Engineering **Created**: 2026-01-28 -**Updated**: 2026-01-28 +**Updated**: 2026-03-11 --- @@ -24,8 +24,9 @@ 13. [Security Considerations](#security-considerations) 14. [Migration Strategy](#migration-strategy) 15. [Implementation Phases](#implementation-phases) -16. [Open Questions](#open-questions) -17. [Appendices](#appendices) +16. [Resolved Questions](#resolved-questions) +17. [Open Questions (Remaining)](#open-questions-remaining) +18. [Appendices](#appendices) --- @@ -1504,203 +1505,225 @@ AI agent registers both names, so old scripts work. ## Implementation Phases -### Phase 1: Core Extension System (Week 1-2) +### Phase 1: Core Extension System ✅ COMPLETED **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) +- [x] Extension manifest schema (`extension.yml`) +- [x] Extension directory structure +- [x] CLI commands: + - [x] `specify extension list` + - [x] `specify extension add` (from URL and local `--dev`) + - [x] `specify extension remove` +- [x] Extension registry (`.specify/extensions/.registry`) +- [x] Command registration (Claude and 15+ other agents) +- [x] Basic validation (manifest schema, compatibility) +- [x] Documentation (extension development guide) **Testing**: -- [ ] Unit tests for manifest parsing -- [ ] Integration test: Install dummy extension -- [ ] Integration test: Register commands with Claude +- [x] Unit tests for manifest parsing +- [x] Integration test: Install dummy extension +- [x] Integration test: Register commands with Claude -### Phase 2: Jira Extension (Week 3) +### Phase 2: Jira Extension ✅ COMPLETED **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 +- [x] Create `spec-kit-jira` repository +- [x] Port Jira functionality to extension +- [x] Create `jira-config.yml` template +- [x] Commands: + - [x] `specstoissues.md` + - [x] `discover-fields.md` + - [x] `sync-status.md` +- [x] Helper scripts +- [x] Documentation (README, configuration guide, examples) +- [x] Release v3.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 +- [x] Test on `eng-msa-ts` project +- [x] Verify spec→Epic, phase→Story, task→Issue mapping +- [x] Test configuration loading and validation +- [x] Test custom field application -### Phase 3: Extension Catalog (Week 4) +### Phase 3: Extension Catalog ✅ COMPLETED **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) +- [x] Central catalog (`extensions/catalog.json` in spec-kit repo) +- [x] Community catalog (`extensions/catalog.community.json`) +- [x] Catalog fetch and parsing with multi-catalog support +- [x] CLI commands: + - [x] `specify extension search` + - [x] `specify extension info` + - [x] `specify extension catalog list` + - [x] `specify extension catalog add` + - [x] `specify extension catalog remove` +- [x] Documentation (how to publish extensions) **Testing**: -- [ ] Test catalog fetch -- [ ] Test extension search/filtering -- [ ] Test catalog caching +- [x] Test catalog fetch +- [x] Test extension search/filtering +- [x] Test catalog caching +- [x] Test multi-catalog merge with priority -### Phase 4: Advanced Features (Week 5-6) +### Phase 4: Advanced Features ✅ COMPLETED **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) +- [x] Hook system (`hooks` in extension.yml) +- [x] Hook registration and execution +- [x] Project extensions config (`.specify/extensions.yml`) +- [x] CLI commands: + - [x] `specify extension update` (with atomic backup/restore) + - [x] `specify extension enable/disable` +- [x] Command registration for multiple agents (15+ agents including Claude, Copilot, Gemini, Cursor, etc.) +- [x] Extension update notifications (version comparison) +- [x] Configuration layer resolution (project, local, env) + +**Additional features implemented beyond original RFC**: + +- [x] **Display name resolution**: All commands accept extension display names in addition to IDs +- [x] **Ambiguous name handling**: User-friendly tables when multiple extensions match a name +- [x] **Atomic update with rollback**: Full backup of extension dir, commands, hooks, and registry with automatic rollback on failure +- [x] **Pre-install ID validation**: Validates extension ID from ZIP before installing (security) +- [x] **Enabled state preservation**: Disabled extensions stay disabled after update +- [x] **Registry update/restore methods**: Clean API for enable/disable and rollback operations +- [x] **Catalog error fallback**: `extension info` falls back to local info when catalog unavailable +- [x] **`_install_allowed` flag**: Discovery-only catalogs can't be used for installation +- [x] **Cache invalidation**: Cache invalidated when `SPECKIT_CATALOG_URL` changes **Testing**: -- [ ] Test hooks in core commands -- [ ] Test extension updates (preserve config) -- [ ] Test multi-agent registration +- [x] Test hooks in core commands +- [x] Test extension updates (preserve config) +- [x] Test multi-agent registration +- [x] Test atomic rollback on update failure +- [x] Test enabled state preservation +- [x] Test display name resolution -### Phase 5: Polish & Documentation (Week 7) +### Phase 5: Polish & Documentation ✅ COMPLETED **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 +- [x] Comprehensive documentation: + - [x] User guide (EXTENSION-USER-GUIDE.md) + - [x] Extension development guide (EXTENSION-DEV-GUIDE.md) + - [x] Extension API reference (EXTENSION-API-REFERENCE.md) +- [x] Error messages and validation improvements +- [x] CLI help text updates **Testing**: -- [ ] End-to-end testing on multiple projects -- [ ] Community beta testing -- [ ] Performance testing (large projects) +- [x] End-to-end testing on multiple projects +- [x] 163 unit tests passing --- -## Open Questions +## Resolved Questions -### 1. Extension Namespace +The following questions from the original RFC have been resolved during implementation: + +### 1. Extension Namespace ✅ RESOLVED **Question**: Should extension commands use namespace prefix? -**Options**: +**Decision**: **Option C** - Both prefixed and aliases are supported. Commands use `speckit.{extension}.{command}` as canonical name, with optional aliases defined in manifest. -- 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 +**Implementation**: The `aliases` field in `extension.yml` allows extensions to register additional command names. --- -### 2. Config File Location +### 2. Config File Location ✅ RESOLVED **Question**: Where should extension configs live? -**Options**: +**Decision**: **Option A** - Extension directory (`.specify/extensions/{ext-id}/{ext-id}-config.yml`). This keeps extensions self-contained and easier to manage. -- 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 +**Implementation**: Each extension has its own config file within its directory, with layered resolution (defaults → project → local → env vars). --- -### 3. Command File Format +### 3. Command File Format ✅ RESOLVED **Question**: Should extensions use universal format or agent-specific? -**Options**: +**Decision**: **Option A** - Universal Markdown format. Extensions write commands once, CLI converts to agent-specific format during registration. -- 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 +**Implementation**: `CommandRegistrar` class handles conversion to 15+ agent formats (Claude, Copilot, Gemini, Cursor, etc.). --- -### 4. Hook Execution Model +### 4. Hook Execution Model ✅ RESOLVED **Question**: How should hooks execute? -**Options**: +**Decision**: **Option A** - Hooks are registered in `.specify/extensions.yml` and executed by the AI agent when it sees the hook trigger. Hook state (enabled/disabled) is managed per-extension. -- 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 +**Implementation**: `HookExecutor` class manages hook registration and state in `extensions.yml`. --- -### 5. Extension Distribution +### 5. Extension Distribution ✅ RESOLVED **Question**: How should extensions be packaged? -**Options**: +**Decision**: **Option A** - ZIP archives downloaded from GitHub releases (via catalog `download_url`). Local development uses `--dev` flag with directory path. -- 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 +**Implementation**: `ExtensionManager.install_from_zip()` handles ZIP extraction and validation. --- -### 6. Multi-Version Support +### 6. Multi-Version Support ✅ RESOLVED **Question**: Can multiple versions of same extension coexist? +**Decision**: **Option A** - Single version only. Updates replace the existing version with atomic rollback on failure. + +**Implementation**: `extension update` performs atomic backup/restore to ensure safe updates. + +--- + +## Open Questions (Remaining) + +### 1. Sandboxing / Permissions (Future) + +**Question**: Should extensions declare required permissions? + **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 +- A) No sandboxing (current): Extensions run with same privileges as AI agent +- B) Permission declarations: Extensions declare `filesystem:read`, `network:external`, etc. +- C) Opt-in sandboxing: Organizations can enable permission enforcement -**Recommendation**: A initially (simpler), consider B in future if needed +**Status**: Deferred to future version. Currently using trust-based model where users trust extension authors. + +--- + +### 2. Package Signatures (Future) + +**Question**: Should extensions be cryptographically signed? + +**Options**: + +- A) No signatures (current): Trust based on catalog source +- B) GPG/Sigstore signatures: Verify package integrity +- C) Catalog-level verification: Catalog maintainers verify packages + +**Status**: Deferred to future version. `checksum` field is available in catalog schema but not enforced. --- diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index dac7eaa5..55e97ea9 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1813,6 +1813,126 @@ def get_speckit_version() -> str: return "unknown" +def _resolve_installed_extension( + argument: str, + installed_extensions: list, + command_name: str = "command", + allow_not_found: bool = False, +) -> tuple[Optional[str], Optional[str]]: + """Resolve an extension argument (ID or display name) to an installed extension. + + Args: + argument: Extension ID or display name provided by user + installed_extensions: List of installed extension dicts from manager.list_installed() + command_name: Name of the command for error messages (e.g., "enable", "disable") + allow_not_found: If True, return (None, None) when not found instead of raising + + Returns: + Tuple of (extension_id, display_name), or (None, None) if allow_not_found=True and not found + + Raises: + typer.Exit: If extension not found (and allow_not_found=False) or name is ambiguous + """ + from rich.table import Table + + # First, try exact ID match + for ext in installed_extensions: + if ext["id"] == argument: + return (ext["id"], ext["name"]) + + # If not found by ID, try display name match + name_matches = [ext for ext in installed_extensions if ext["name"].lower() == argument.lower()] + + if len(name_matches) == 1: + # Unique display-name match + return (name_matches[0]["id"], name_matches[0]["name"]) + elif len(name_matches) > 1: + # Ambiguous display-name match + console.print( + f"[red]Error:[/red] Extension name '{argument}' is ambiguous. " + "Multiple installed extensions share this name:" + ) + table = Table(title="Matching extensions") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Name", style="white") + table.add_column("Version", style="green") + for ext in name_matches: + table.add_row(ext.get("id", ""), ext.get("name", ""), str(ext.get("version", ""))) + console.print(table) + console.print("\nPlease rerun using the extension ID:") + console.print(f" [bold]specify extension {command_name} [/bold]") + raise typer.Exit(1) + else: + # No match by ID or display name + if allow_not_found: + return (None, None) + console.print(f"[red]Error:[/red] Extension '{argument}' is not installed") + raise typer.Exit(1) + + +def _resolve_catalog_extension( + argument: str, + catalog, + command_name: str = "info", +) -> tuple[Optional[dict], Optional[Exception]]: + """Resolve an extension argument (ID or display name) from the catalog. + + Args: + argument: Extension ID or display name provided by user + catalog: ExtensionCatalog instance + command_name: Name of the command for error messages + + Returns: + Tuple of (extension_info, catalog_error) + - If found: (ext_info_dict, None) + - If catalog error: (None, error) + - If not found: (None, None) + """ + from rich.table import Table + from .extensions import ExtensionError + + try: + # First try by ID + ext_info = catalog.get_extension_info(argument) + if ext_info: + return (ext_info, None) + + # Try by display name - search using argument as query, then filter for exact match + search_results = catalog.search(query=argument) + name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()] + + if len(name_matches) == 1: + return (name_matches[0], None) + elif len(name_matches) > 1: + # Ambiguous display-name match in catalog + console.print( + f"[red]Error:[/red] Extension name '{argument}' is ambiguous. " + "Multiple catalog extensions share this name:" + ) + table = Table(title="Matching extensions") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Name", style="white") + table.add_column("Version", style="green") + table.add_column("Catalog", style="dim") + for ext in name_matches: + table.add_row( + ext.get("id", ""), + ext.get("name", ""), + str(ext.get("version", "")), + ext.get("_catalog_name", ""), + ) + console.print(table) + console.print("\nPlease rerun using the extension ID:") + console.print(f" [bold]specify extension {command_name} [/bold]") + raise typer.Exit(1) + + # Not found + return (None, None) + + except ExtensionError as e: + return (None, e) + + @extension_app.command("list") def extension_list( available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"), @@ -2111,8 +2231,11 @@ def extension_add( # Install from catalog catalog = ExtensionCatalog(project_root) - # Check if extension exists in catalog - ext_info = catalog.get_extension_info(extension) + # Check if extension exists in catalog (supports both ID and display name) + ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add") + if catalog_error: + console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}") + raise typer.Exit(1) if not ext_info: console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog") console.print("\nSearch available extensions:") @@ -2132,9 +2255,10 @@ def extension_add( ) raise typer.Exit(1) - # Download extension ZIP + # Download extension ZIP (use resolved ID, not original argument which may be display name) + extension_id = ext_info['id'] console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...") - zip_path = catalog.download_extension(extension) + zip_path = catalog.download_extension(extension_id) try: # Install from downloaded ZIP @@ -2167,7 +2291,7 @@ def extension_add( @extension_app.command("remove") def extension_remove( - extension: str = typer.Argument(help="Extension ID to remove"), + extension: str = typer.Argument(help="Extension ID or name 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"), ): @@ -2185,25 +2309,19 @@ def extension_remove( 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) + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "remove") - # 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 + # Get extension info for command count + ext_manifest = manager.get_extension(extension_id) + cmd_count = len(ext_manifest.commands) if ext_manifest else 0 # Confirm removal if not force: console.print("\n[yellow]⚠ This will remove:[/yellow]") console.print(f" • {cmd_count} commands from AI agent") - console.print(f" • Extension directory: .specify/extensions/{extension}/") + console.print(f" • Extension directory: .specify/extensions/{extension_id}/") if not keep_config: console.print(" • Config files (will be backed up)") console.print() @@ -2214,15 +2332,15 @@ def extension_remove( raise typer.Exit(0) # Remove extension - success = manager.remove(extension, keep_config=keep_config) + success = manager.remove(extension_id, keep_config=keep_config) if success: - console.print(f"\n[green]✓[/green] Extension '{ext_name}' removed successfully") + console.print(f"\n[green]✓[/green] Extension '{display_name}' removed successfully") if keep_config: - console.print(f"\nConfig files preserved in .specify/extensions/{extension}/") + console.print(f"\nConfig files preserved in .specify/extensions/{extension_id}/") else: - console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension}/") - console.print(f"\nTo reinstall: specify extension add {extension}") + console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension_id}/") + console.print(f"\nTo reinstall: specify extension add {extension_id}") else: console.print("[red]Error:[/red] Failed to remove extension") raise typer.Exit(1) @@ -2320,7 +2438,7 @@ def extension_info( extension: str = typer.Argument(help="Extension ID or name"), ): """Show detailed information about an extension.""" - from .extensions import ExtensionCatalog, ExtensionManager, ExtensionError + from .extensions import ExtensionCatalog, ExtensionManager project_root = Path.cwd() @@ -2333,118 +2451,181 @@ def extension_info( catalog = ExtensionCatalog(project_root) manager = ExtensionManager(project_root) + installed = manager.list_installed() - try: - ext_info = catalog.get_extension_info(extension) + # Try to resolve from installed extensions first (by ID or name) + # Use allow_not_found=True since the extension may be catalog-only + resolved_installed_id, resolved_installed_name = _resolve_installed_extension( + extension, installed, "info", allow_not_found=True + ) - 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) + # Try catalog lookup (with error handling) + # If we resolved an installed extension by display name, use its ID for catalog lookup + # to ensure we get the correct catalog entry (not a different extension with same name) + lookup_key = resolved_installed_id if resolved_installed_id else extension + ext_info, catalog_error = _resolve_catalog_extension(lookup_key, catalog, "info") - # 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']}") + # Case 1: Found in catalog - show full catalog info + if ext_info: + _print_extension_info(ext_info, manager) + return + + # Case 2: Installed locally but catalog lookup failed or not in catalog + if resolved_installed_id: + # Get local manifest info + ext_manifest = manager.get_extension(resolved_installed_id) + metadata = manager.registry.get(resolved_installed_id) + + console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{metadata.get('version', 'unknown')})") + console.print(f"ID: {resolved_installed_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')}") - - # Source catalog - if ext_info.get("_catalog_name"): - install_allowed = ext_info.get("_install_allowed", True) - install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" - console.print(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}") - console.print() - - # Requirements - 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}") + if ext_manifest: + console.print(f"{ext_manifest.description}") console.print() + # Author is optional in extension.yml, safely retrieve it + author = ext_manifest.data.get("extension", {}).get("author") + if author: + console.print(f"[dim]Author:[/dim] {author}") + 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() + if ext_manifest.commands: + console.print("[bold]Commands:[/bold]") + for cmd in ext_manifest.commands: + console.print(f" • {cmd['name']}: {cmd.get('description', '')}") + 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']) - install_allowed = ext_info.get("_install_allowed", True) - if is_installed: - console.print("[green]✓ Installed[/green]") - console.print(f"\nTo remove: specify extension remove {ext_info['id']}") - elif install_allowed: - console.print("[yellow]Not installed[/yellow]") - console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}") + # Show catalog status + if catalog_error: + console.print(f"[yellow]Catalog unavailable:[/yellow] {catalog_error}") + console.print("[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]") else: - catalog_name = ext_info.get("_catalog_name", "community") - console.print("[yellow]Not installed[/yellow]") - console.print( - f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog " - f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml " - f"with install_allowed: true to enable installation." - ) + console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)") - except ExtensionError as e: - console.print(f"\n[red]Error:[/red] {e}") - raise typer.Exit(1) + console.print() + console.print("[green]✓ Installed[/green]") + console.print(f"\nTo remove: specify extension remove {resolved_installed_id}") + return + + # Case 3: Not found anywhere + if catalog_error: + console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}") + console.print("\nTry again when online, or use the extension ID directly.") + else: + console.print(f"[red]Error:[/red] Extension '{extension}' not found") + console.print("\nTry: specify extension search") + raise typer.Exit(1) + + +def _print_extension_info(ext_info: dict, manager): + """Print formatted extension info from catalog data.""" + # 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')}") + + # Source catalog + if ext_info.get("_catalog_name"): + install_allowed = ext_info.get("_install_allowed", True) + install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" + console.print(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}") + console.print() + + # Requirements + 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']) + install_allowed = ext_info.get("_install_allowed", True) + if is_installed: + console.print("[green]✓ Installed[/green]") + console.print(f"\nTo remove: specify extension remove {ext_info['id']}") + elif install_allowed: + console.print("[yellow]Not installed[/yellow]") + console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}") + else: + catalog_name = ext_info.get("_catalog_name", "community") + console.print("[yellow]Not installed[/yellow]") + console.print( + f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog " + f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml " + f"with install_allowed: true to enable installation." + ) @extension_app.command("update") def extension_update( - extension: str = typer.Argument(None, help="Extension ID to update (or all)"), + extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"), ): """Update extension(s) to latest version.""" - from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError + from .extensions import ( + ExtensionManager, + ExtensionCatalog, + ExtensionError, + ValidationError, + CommandRegistrar, + HookExecutor, + ) from packaging import version as pkg_version + import shutil project_root = Path.cwd() @@ -2457,18 +2638,17 @@ def extension_update( manager = ExtensionManager(project_root) catalog = ExtensionCatalog(project_root) + speckit_version = get_speckit_version() try: # Get list of extensions to update + installed = manager.list_installed() 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] + # Update specific extension - resolve ID from argument (handles ambiguous names) + extension_id, _ = _resolve_installed_extension(extension, installed, "update") + extensions_to_update = [extension_id] else: # Update all extensions - installed = manager.list_installed() extensions_to_update = [ext["id"] for ext in installed] if not extensions_to_update: @@ -2482,7 +2662,16 @@ def extension_update( for ext_id in extensions_to_update: # Get installed version metadata = manager.registry.get(ext_id) - installed_version = pkg_version.Version(metadata["version"]) + if metadata is None or "version" not in metadata: + console.print(f"⚠ {ext_id}: Registry entry corrupted or missing (skipping)") + continue + try: + installed_version = pkg_version.Version(metadata["version"]) + except pkg_version.InvalidVersion: + console.print( + f"⚠ {ext_id}: Invalid installed version '{metadata.get('version')}' in registry (skipping)" + ) + continue # Get catalog info ext_info = catalog.get_extension_info(ext_id) @@ -2490,12 +2679,24 @@ def extension_update( console.print(f"⚠ {ext_id}: Not found in catalog (skipping)") continue - catalog_version = pkg_version.Version(ext_info["version"]) + # Check if installation is allowed from this catalog + if not ext_info.get("_install_allowed", True): + console.print(f"⚠ {ext_id}: Updates not allowed from '{ext_info.get('_catalog_name', 'catalog')}' (skipping)") + continue + + try: + catalog_version = pkg_version.Version(ext_info["version"]) + except pkg_version.InvalidVersion: + console.print( + f"⚠ {ext_id}: Invalid catalog version '{ext_info.get('version')}' (skipping)" + ) + continue if catalog_version > installed_version: updates_available.append( { "id": ext_id, + "name": ext_info.get("name", ext_id), # Display name for status messages "installed": str(installed_version), "available": str(catalog_version), "download_url": ext_info.get("download_url"), @@ -2521,25 +2722,288 @@ def extension_update( console.print("Cancelled") raise typer.Exit(0) - # Perform updates + # Perform updates with atomic backup/restore console.print() + updated_extensions = [] + failed_updates = [] + registrar = CommandRegistrar() + hook_executor = HookExecutor(project_root) + for update in updates_available: - ext_id = update["id"] - console.print(f"📦 Updating {ext_id}...") + extension_id = update["id"] + ext_name = update["name"] # Use display name for user-facing messages + console.print(f"📦 Updating {ext_name}...") - # TODO: Implement download and reinstall from URL - # For now, just show message - console.print( - "[yellow]Note:[/yellow] Automatic update not yet implemented. " - "Please update manually:" - ) - console.print(f" specify extension remove {ext_id} --keep-config") - console.print(f" specify extension add {ext_id}") + # Backup paths + backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update" + backup_ext_dir = backup_base / "extension" + backup_commands_dir = backup_base / "commands" + backup_config_dir = backup_base / "config" - console.print( - "\n[cyan]Tip:[/cyan] Automatic updates will be available in a future version" - ) + # Store backup state + backup_registry_entry = None + backup_hooks = None # None means no hooks key in config; {} means hooks key existed + backed_up_command_files = {} + try: + # 1. Backup registry entry (always, even if extension dir doesn't exist) + backup_registry_entry = manager.registry.get(extension_id) + + # 2. Backup extension directory + extension_dir = manager.extensions_dir / extension_id + if extension_dir.exists(): + backup_base.mkdir(parents=True, exist_ok=True) + if backup_ext_dir.exists(): + shutil.rmtree(backup_ext_dir) + shutil.copytree(extension_dir, backup_ext_dir) + + # Backup config files separately so they can be restored + # after a successful install (install_from_directory clears dest dir). + config_files = list(extension_dir.glob("*-config.yml")) + list( + extension_dir.glob("*-config.local.yml") + ) + for cfg_file in config_files: + backup_config_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(cfg_file, backup_config_dir / cfg_file.name) + + # 3. Backup command files for all agents + registered_commands = backup_registry_entry.get("registered_commands", {}) + 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 = 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(): + backup_cmd_path = backup_commands_dir / agent_name / cmd_file.name + backup_cmd_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(cmd_file, backup_cmd_path) + backed_up_command_files[str(cmd_file)] = str(backup_cmd_path) + + # Also backup copilot prompt files + if agent_name == "copilot": + prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + if prompt_file.exists(): + backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file.name + backup_prompt_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(prompt_file, backup_prompt_path) + backed_up_command_files[str(prompt_file)] = str(backup_prompt_path) + + # 4. Backup hooks from extensions.yml + # Use backup_hooks=None to indicate config had no "hooks" key (don't create on restore) + # Use backup_hooks={} to indicate config had "hooks" key with no hooks for this extension + config = hook_executor.get_project_config() + if "hooks" in config: + backup_hooks = {} # Config has hooks key - preserve this fact + for hook_name, hook_list in config["hooks"].items(): + ext_hooks = [h for h in hook_list if h.get("extension") == extension_id] + if ext_hooks: + backup_hooks[hook_name] = ext_hooks + + # 5. Download new version + zip_path = catalog.download_extension(extension_id) + try: + # 6. Validate extension ID from ZIP BEFORE modifying installation + # Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs) + with zipfile.ZipFile(zip_path, "r") as zf: + import yaml + manifest_data = None + namelist = zf.namelist() + + # First try root-level extension.yml + if "extension.yml" in namelist: + with zf.open("extension.yml") as f: + manifest_data = yaml.safe_load(f) or {} + else: + # Look for extension.yml in a single top-level subdirectory + # (e.g., "repo-name-branch/extension.yml") + manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1] + if len(manifest_paths) == 1: + with zf.open(manifest_paths[0]) as f: + manifest_data = yaml.safe_load(f) or {} + + if manifest_data is None: + raise ValueError("Downloaded extension archive is missing 'extension.yml'") + + zip_extension_id = manifest_data.get("extension", {}).get("id") + if zip_extension_id != extension_id: + raise ValueError( + f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'" + ) + + # 7. Remove old extension (handles command file cleanup and registry removal) + manager.remove(extension_id, keep_config=True) + + # 8. Install new version + _ = manager.install_from_zip(zip_path, speckit_version) + + # Restore user config files from backup after successful install. + new_extension_dir = manager.extensions_dir / extension_id + if backup_config_dir.exists() and new_extension_dir.exists(): + for cfg_file in backup_config_dir.iterdir(): + if cfg_file.is_file(): + shutil.copy2(cfg_file, new_extension_dir / cfg_file.name) + + # 9. Restore metadata from backup (installed_at, enabled state) + if backup_registry_entry: + # Copy current registry entry to avoid mutating internal + # registry state before explicit restore(). + current_metadata = manager.registry.get(extension_id) + if current_metadata is None: + raise RuntimeError( + f"Registry entry for '{extension_id}' missing after install — update incomplete" + ) + new_metadata = dict(current_metadata) + + # Preserve the original installation timestamp + if "installed_at" in backup_registry_entry: + new_metadata["installed_at"] = backup_registry_entry["installed_at"] + + # If extension was disabled before update, disable it again + if not backup_registry_entry.get("enabled", True): + new_metadata["enabled"] = False + + # Use restore() instead of update() because update() always + # preserves the existing installed_at, ignoring our override + manager.registry.restore(extension_id, new_metadata) + + # Also disable hooks in extensions.yml if extension was disabled + if not backup_registry_entry.get("enabled", True): + 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_id: + hook["enabled"] = False + hook_executor.save_project_config(config) + finally: + # Clean up downloaded ZIP + if zip_path.exists(): + zip_path.unlink() + + # 10. Clean up backup on success + if backup_base.exists(): + shutil.rmtree(backup_base) + + console.print(f" [green]✓[/green] Updated to v{update['available']}") + updated_extensions.append(ext_name) + + except KeyboardInterrupt: + raise + except Exception as e: + console.print(f" [red]✗[/red] Failed: {e}") + failed_updates.append((ext_name, str(e))) + + # Rollback on failure + console.print(f" [yellow]↩[/yellow] Rolling back {ext_name}...") + + try: + # Restore extension directory + # Only perform destructive rollback if backup exists (meaning we + # actually modified the extension). This avoids deleting a valid + # installation when failure happened before changes were made. + extension_dir = manager.extensions_dir / extension_id + if backup_ext_dir.exists(): + if extension_dir.exists(): + shutil.rmtree(extension_dir) + shutil.copytree(backup_ext_dir, extension_dir) + + # Remove any NEW command files created by failed install + # (files that weren't in the original backup) + try: + new_registry_entry = manager.registry.get(extension_id) + if new_registry_entry is None: + new_registered_commands = {} + else: + new_registered_commands = new_registry_entry.get("registered_commands", {}) + for agent_name, cmd_names in new_registered_commands.items(): + if agent_name not in registrar.AGENT_CONFIGS: + continue + agent_config = registrar.AGENT_CONFIGS[agent_name] + commands_dir = project_root / agent_config["dir"] + + for cmd_name in cmd_names: + cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}" + # Delete if it exists and wasn't in our backup + if cmd_file.exists() and str(cmd_file) not in backed_up_command_files: + cmd_file.unlink() + + # Also handle copilot prompt files + if agent_name == "copilot": + prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + if prompt_file.exists() and str(prompt_file) not in backed_up_command_files: + prompt_file.unlink() + except KeyError: + pass # No new registry entry exists, nothing to clean up + + # Restore backed up command files + for original_path, backup_path in backed_up_command_files.items(): + backup_file = Path(backup_path) + if backup_file.exists(): + original_file = Path(original_path) + original_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(backup_file, original_file) + + # Restore hooks in extensions.yml + # - backup_hooks=None means original config had no "hooks" key + # - backup_hooks={} or {...} means config had hooks key + config = hook_executor.get_project_config() + if "hooks" in config: + modified = False + + if backup_hooks is None: + # Original config had no "hooks" key; remove it entirely + del config["hooks"] + modified = True + else: + # Remove any hooks for this extension added by failed install + for hook_name, hooks_list in config["hooks"].items(): + original_len = len(hooks_list) + config["hooks"][hook_name] = [ + h for h in hooks_list + if h.get("extension") != extension_id + ] + if len(config["hooks"][hook_name]) != original_len: + modified = True + + # Add back the backed up hooks if any + if backup_hooks: + for hook_name, hooks in backup_hooks.items(): + if hook_name not in config["hooks"]: + config["hooks"][hook_name] = [] + config["hooks"][hook_name].extend(hooks) + modified = True + + if modified: + hook_executor.save_project_config(config) + + # Restore registry entry (use restore() since entry was removed) + if backup_registry_entry: + manager.registry.restore(extension_id, backup_registry_entry) + + console.print(" [green]✓[/green] Rollback successful") + # Clean up backup directory only on successful rollback + if backup_base.exists(): + shutil.rmtree(backup_base) + except Exception as rollback_error: + console.print(f" [red]✗[/red] Rollback failed: {rollback_error}") + console.print(f" [dim]Backup preserved at: {backup_base}[/dim]") + + # Summary + console.print() + if updated_extensions: + console.print(f"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)") + if failed_updates: + console.print(f"[red]✗[/red] Failed to update {len(failed_updates)} extension(s):") + for ext_name, error in failed_updates: + console.print(f" • {ext_name}: {error}") + raise typer.Exit(1) + + except ValidationError as e: + console.print(f"\n[red]Validation Error:[/red] {e}") + raise typer.Exit(1) except ExtensionError as e: console.print(f"\n[red]Error:[/red] {e}") raise typer.Exit(1) @@ -2547,7 +3011,7 @@ def extension_update( @extension_app.command("enable") def extension_enable( - extension: str = typer.Argument(help="Extension ID to enable"), + extension: str = typer.Argument(help="Extension ID or name to enable"), ): """Enable a disabled extension.""" from .extensions import ExtensionManager, HookExecutor @@ -2564,34 +3028,38 @@ def extension_enable( 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) + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "enable") # Update registry - metadata = manager.registry.get(extension) + metadata = manager.registry.get(extension_id) + if metadata is None: + console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + if metadata.get("enabled", True): - console.print(f"[yellow]Extension '{extension}' is already enabled[/yellow]") + console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]") raise typer.Exit(0) metadata["enabled"] = True - manager.registry.add(extension, metadata) + manager.registry.update(extension_id, 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: + if hook.get("extension") == extension_id: hook["enabled"] = True hook_executor.save_project_config(config) - console.print(f"[green]✓[/green] Extension '{extension}' enabled") + console.print(f"[green]✓[/green] Extension '{display_name}' enabled") @extension_app.command("disable") def extension_disable( - extension: str = typer.Argument(help="Extension ID to disable"), + extension: str = typer.Argument(help="Extension ID or name to disable"), ): """Disable an extension without removing it.""" from .extensions import ExtensionManager, HookExecutor @@ -2608,31 +3076,35 @@ def extension_disable( 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) + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "disable") # Update registry - metadata = manager.registry.get(extension) + metadata = manager.registry.get(extension_id) + if metadata is None: + console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + if not metadata.get("enabled", True): - console.print(f"[yellow]Extension '{extension}' is already disabled[/yellow]") + console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]") raise typer.Exit(0) metadata["enabled"] = False - manager.registry.add(extension, metadata) + manager.registry.update(extension_id, 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: + if hook.get("extension") == extension_id: hook["enabled"] = False hook_executor.save_project_config(config) - console.print(f"[green]✓[/green] Extension '{extension}' disabled") + console.print(f"[green]✓[/green] Extension '{display_name}' disabled") console.print("\nCommands will no longer be available. Hooks will not execute.") - console.print(f"To re-enable: specify extension enable {extension}") + console.print(f"To re-enable: specify extension enable {extension_id}") def main(): diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 383ed215..fa9766b0 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -12,6 +12,7 @@ import os import tempfile import zipfile import shutil +import copy from dataclasses import dataclass from pathlib import Path from typing import Optional, Dict, List, Any, Callable, Set @@ -228,6 +229,54 @@ class ExtensionRegistry: } self._save() + def update(self, extension_id: str, metadata: dict): + """Update extension metadata in registry, merging with existing entry. + + Merges the provided metadata with the existing entry, preserving any + fields not specified in the new metadata. The installed_at timestamp + is always preserved from the original entry. + + Use this method instead of add() when updating existing extension + metadata (e.g., enabling/disabling) to preserve the original + installation timestamp and other existing fields. + + Args: + extension_id: Extension ID + metadata: Extension metadata fields to update (merged with existing) + + Raises: + KeyError: If extension is not installed + """ + if extension_id not in self.data["extensions"]: + raise KeyError(f"Extension '{extension_id}' is not installed") + # Merge new metadata with existing, preserving original installed_at + existing = self.data["extensions"][extension_id] + # Merge: existing fields preserved, new fields override + merged = {**existing, **metadata} + # Always preserve original installed_at based on key existence, not truthiness, + # to handle cases where the field exists but may be falsy (legacy/corruption) + if "installed_at" in existing: + merged["installed_at"] = existing["installed_at"] + else: + # If not present in existing, explicitly remove from merged if caller provided it + merged.pop("installed_at", None) + self.data["extensions"][extension_id] = merged + self._save() + + def restore(self, extension_id: str, metadata: dict): + """Restore extension metadata to registry without modifying timestamps. + + Use this method for rollback scenarios where you have a complete backup + of the registry entry (including installed_at) and want to restore it + exactly as it was. + + Args: + extension_id: Extension ID + metadata: Complete extension metadata including installed_at + """ + self.data["extensions"][extension_id] = dict(metadata) + self._save() + def remove(self, extension_id: str): """Remove extension from registry. @@ -241,21 +290,28 @@ class ExtensionRegistry: def get(self, extension_id: str) -> Optional[dict]: """Get extension metadata from registry. + Returns a deep copy to prevent callers from accidentally mutating + nested internal registry state without going through the write path. + Args: extension_id: Extension ID Returns: - Extension metadata or None if not found + Deep copy of extension metadata, or None if not found """ - return self.data["extensions"].get(extension_id) + entry = self.data["extensions"].get(extension_id) + return copy.deepcopy(entry) if entry is not None else None def list(self) -> Dict[str, dict]: """Get all installed extensions. + Returns a deep copy of the extensions mapping to prevent callers + from accidentally mutating nested internal registry state. + Returns: - Dictionary of extension_id -> metadata + Dictionary of extension_id -> metadata (deep copies) """ - return self.data["extensions"] + return copy.deepcopy(self.data["extensions"]) def is_installed(self, extension_id: str) -> bool: """Check if extension is installed. @@ -600,7 +656,7 @@ class ExtensionManager: result.append({ "id": ext_id, "name": manifest.name, - "version": metadata["version"], + "version": metadata.get("version", "unknown"), "description": manifest.description, "enabled": metadata.get("enabled", True), "installed_at": metadata.get("installed_at"), @@ -1112,12 +1168,13 @@ class ExtensionCatalog: config_path: Path to extension-catalogs.yml Returns: - Ordered list of CatalogEntry objects, or None if file doesn't exist - or contains no valid catalog entries. + Ordered list of CatalogEntry objects, or None if file doesn't exist. Raises: ValidationError: If any catalog entry has an invalid URL, - the file cannot be parsed, or a priority value is invalid. + the file cannot be parsed, a priority value is invalid, + or the file exists but contains no valid catalog entries + (fail-closed for security). """ if not config_path.exists(): return None @@ -1129,12 +1186,17 @@ class ExtensionCatalog: ) catalogs_data = data.get("catalogs", []) if not catalogs_data: - return None + # File exists but has no catalogs key or empty list - fail closed + raise ValidationError( + f"Catalog config {config_path} exists but contains no 'catalogs' entries. " + f"Remove the file to use built-in defaults, or add valid catalog entries." + ) if not isinstance(catalogs_data, list): raise ValidationError( f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}" ) entries: List[CatalogEntry] = [] + skipped_entries: List[int] = [] for idx, item in enumerate(catalogs_data): if not isinstance(item, dict): raise ValidationError( @@ -1142,6 +1204,7 @@ class ExtensionCatalog: ) url = str(item.get("url", "")).strip() if not url: + skipped_entries.append(idx) continue self._validate_catalog_url(url) try: @@ -1164,7 +1227,14 @@ class ExtensionCatalog: description=str(item.get("description", "")), )) entries.sort(key=lambda e: e.priority) - return entries if entries else None + if not entries: + # All entries were invalid (missing URLs) - fail closed for security + raise ValidationError( + f"Catalog config {config_path} contains {len(catalogs_data)} entries but none have valid URLs " + f"(entries at indices {skipped_entries} were skipped). " + f"Each catalog entry must have a 'url' field." + ) + return entries def get_active_catalogs(self) -> List[CatalogEntry]: """Get the ordered list of active catalogs. diff --git a/tests/test_extensions.py b/tests/test_extensions.py index ba52d034..4c098c25 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -277,6 +277,135 @@ class TestExtensionRegistry: assert registry2.is_installed("test-ext") assert registry2.get("test-ext")["version"] == "1.0.0" + def test_update_preserves_installed_at(self, temp_dir): + """Test that update() preserves the original installed_at timestamp.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("test-ext", {"version": "1.0.0", "enabled": True}) + + # Get original installed_at + original_data = registry.get("test-ext") + original_installed_at = original_data["installed_at"] + + # Update with new metadata + registry.update("test-ext", {"version": "2.0.0", "enabled": False}) + + # Verify installed_at is preserved + updated_data = registry.get("test-ext") + assert updated_data["installed_at"] == original_installed_at + assert updated_data["version"] == "2.0.0" + assert updated_data["enabled"] is False + + def test_update_merges_with_existing(self, temp_dir): + """Test that update() merges new metadata with existing fields.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("test-ext", { + "version": "1.0.0", + "enabled": True, + "registered_commands": {"claude": ["cmd1", "cmd2"]}, + }) + + # Update with partial metadata (only enabled field) + registry.update("test-ext", {"enabled": False}) + + # Verify existing fields are preserved + updated_data = registry.get("test-ext") + assert updated_data["enabled"] is False + assert updated_data["version"] == "1.0.0" # Preserved + assert updated_data["registered_commands"] == {"claude": ["cmd1", "cmd2"]} # Preserved + + def test_update_raises_for_missing_extension(self, temp_dir): + """Test that update() raises KeyError for non-installed extension.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + + with pytest.raises(KeyError, match="not installed"): + registry.update("nonexistent-ext", {"enabled": False}) + + def test_restore_overwrites_completely(self, temp_dir): + """Test that restore() overwrites the registry entry completely.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("test-ext", {"version": "2.0.0", "enabled": True}) + + # Restore with complete backup data + backup_data = { + "version": "1.0.0", + "enabled": False, + "installed_at": "2024-01-01T00:00:00+00:00", + "registered_commands": {"claude": ["old-cmd"]}, + } + registry.restore("test-ext", backup_data) + + # Verify entry is exactly as restored + restored_data = registry.get("test-ext") + assert restored_data == backup_data + + def test_restore_can_recreate_removed_entry(self, temp_dir): + """Test that restore() can recreate an entry after remove().""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("test-ext", {"version": "1.0.0"}) + + # Save backup and remove + backup = registry.get("test-ext").copy() + registry.remove("test-ext") + assert not registry.is_installed("test-ext") + + # Restore should recreate the entry + registry.restore("test-ext", backup) + assert registry.is_installed("test-ext") + assert registry.get("test-ext")["version"] == "1.0.0" + + def test_get_returns_deep_copy(self, temp_dir): + """Test that get() returns deep copies for nested structures.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + metadata = { + "version": "1.0.0", + "registered_commands": {"claude": ["cmd1"]}, + } + registry.add("test-ext", metadata) + + fetched = registry.get("test-ext") + fetched["registered_commands"]["claude"].append("cmd2") + + # Internal registry must remain unchanged. + internal = registry.data["extensions"]["test-ext"] + assert internal["registered_commands"] == {"claude": ["cmd1"]} + + def test_list_returns_deep_copy(self, temp_dir): + """Test that list() returns deep copies for nested structures.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + metadata = { + "version": "1.0.0", + "registered_commands": {"claude": ["cmd1"]}, + } + registry.add("test-ext", metadata) + + listed = registry.list() + listed["test-ext"]["registered_commands"]["claude"].append("cmd2") + + # Internal registry must remain unchanged. + internal = registry.data["extensions"]["test-ext"] + assert internal["registered_commands"] == {"claude": ["cmd1"]} + # ===== ExtensionManager Tests ===== @@ -1402,8 +1531,8 @@ class TestCatalogStack: with pytest.raises(ValidationError, match="HTTPS"): catalog.get_active_catalogs() - def test_empty_project_config_falls_back_to_defaults(self, temp_dir): - """Empty catalogs list in config falls back to default stack.""" + def test_empty_project_config_raises_error(self, temp_dir): + """Empty catalogs list in config raises ValidationError (fail-closed for security).""" import yaml as yaml_module project_dir = self._make_project(temp_dir) @@ -1412,11 +1541,32 @@ class TestCatalogStack: yaml_module.dump({"catalogs": []}, f) catalog = ExtensionCatalog(project_dir) - entries = catalog.get_active_catalogs() - # Falls back to default stack - assert len(entries) == 2 - assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL + # Fail-closed: empty config should raise, not fall back to defaults + with pytest.raises(ValidationError) as exc_info: + catalog.get_active_catalogs() + assert "contains no 'catalogs' entries" in str(exc_info.value) + + def test_catalog_entries_without_urls_raises_error(self, temp_dir): + """Catalog entries without URLs raise ValidationError (fail-closed for security).""" + import yaml as yaml_module + + project_dir = self._make_project(temp_dir) + config_path = project_dir / ".specify" / "extension-catalogs.yml" + with open(config_path, "w") as f: + yaml_module.dump({ + "catalogs": [ + {"name": "no-url-catalog", "priority": 1}, + {"name": "another-no-url", "description": "Also missing URL"}, + ] + }, f) + + catalog = ExtensionCatalog(project_dir) + + # Fail-closed: entries without URLs should raise, not fall back to defaults + with pytest.raises(ValidationError) as exc_info: + catalog.get_active_catalogs() + assert "none have valid URLs" in str(exc_info.value) # --- _load_catalog_config --- @@ -1943,3 +2093,238 @@ class TestExtensionIgnore: assert not (dest / "docs" / "guide.md").exists() assert not (dest / "docs" / "internal.md").exists() assert (dest / "docs" / "api.md").exists() + + +class TestExtensionAddCLI: + """CLI integration tests for extension add command.""" + + def test_add_by_display_name_uses_resolved_id_for_download(self, tmp_path): + """extension add by display name should use resolved ID for download_extension().""" + from typer.testing import CliRunner + from unittest.mock import patch, MagicMock + from specify_cli import app + + runner = CliRunner() + + # Create project structure + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".specify" / "extensions").mkdir(parents=True) + + # Mock catalog that returns extension by display name + mock_catalog = MagicMock() + mock_catalog.get_extension_info.return_value = None # ID lookup fails + mock_catalog.search.return_value = [ + { + "id": "acme-jira-integration", + "name": "Jira Integration", + "version": "1.0.0", + "description": "Jira integration extension", + "_install_allowed": True, + } + ] + + # Track what ID was passed to download_extension + download_called_with = [] + def mock_download(extension_id): + download_called_with.append(extension_id) + # Return a path that will fail install (we just want to verify the ID) + raise ExtensionError("Mock download - checking ID was resolved") + + mock_catalog.download_extension.side_effect = mock_download + + with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ + patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", "Jira Integration"], + catch_exceptions=True, + ) + + assert result.exit_code != 0, ( + f"Expected non-zero exit code since mock download raises, got {result.exit_code}" + ) + + # Verify download_extension was called with the resolved ID, not the display name + assert len(download_called_with) == 1 + assert download_called_with[0] == "acme-jira-integration", ( + f"Expected download_extension to be called with resolved ID 'acme-jira-integration', " + f"but was called with '{download_called_with[0]}'" + ) + + +class TestExtensionUpdateCLI: + """CLI integration tests for extension update command.""" + + @staticmethod + def _create_extension_source(base_dir: Path, version: str, include_config: bool = False) -> Path: + """Create a minimal extension source directory for install tests.""" + import yaml + + ext_dir = base_dir / f"test-ext-{version}" + ext_dir.mkdir(parents=True, exist_ok=True) + + manifest = { + "schema_version": "1.0", + "extension": { + "id": "test-ext", + "name": "Test Extension", + "version": version, + "description": "A test extension", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.test.hello", + "file": "commands/hello.md", + "description": "Test command", + } + ] + }, + "hooks": { + "after_tasks": { + "command": "speckit.test.hello", + "optional": True, + } + }, + } + + (ext_dir / "extension.yml").write_text(yaml.dump(manifest, sort_keys=False)) + commands_dir = ext_dir / "commands" + commands_dir.mkdir(exist_ok=True) + (commands_dir / "hello.md").write_text("---\ndescription: Test\n---\n\n$ARGUMENTS\n") + if include_config: + (ext_dir / "linear-config.yml").write_text("custom: true\nvalue: original\n") + return ext_dir + + @staticmethod + def _create_catalog_zip(zip_path: Path, version: str): + """Create a minimal ZIP that passes extension_update ID validation.""" + import zipfile + import yaml + + manifest = { + "schema_version": "1.0", + "extension": { + "id": "test-ext", + "name": "Test Extension", + "version": version, + "description": "A test extension", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"commands": [{"name": "speckit.test.hello", "file": "commands/hello.md"}]}, + } + + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("extension.yml", yaml.dump(manifest, sort_keys=False)) + + def test_update_success_preserves_installed_at(self, tmp_path): + """Successful update should keep original installed_at and apply new version.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "commands").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0", include_config=True) + manager.install_from_directory(v1_dir, "0.1.0") + original_installed_at = manager.registry.get("test-ext")["installed_at"] + original_config_content = ( + project_dir / ".specify" / "extensions" / "test-ext" / "linear-config.yml" + ).read_text() + + zip_path = tmp_path / "test-ext-update.zip" + self._create_catalog_zip(zip_path, "2.0.0") + v2_dir = self._create_extension_source(tmp_path, "2.0.0") + + def fake_install_from_zip(self_obj, _zip_path, speckit_version): + return self_obj.install_from_directory(v2_dir, speckit_version) + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "_install_allowed": True, + }), \ + patch.object(ExtensionCatalog, "download_extension", return_value=zip_path), \ + patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip): + result = runner.invoke(app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True) + + assert result.exit_code == 0, result.output + + updated = ExtensionManager(project_dir).registry.get("test-ext") + assert updated["version"] == "2.0.0" + assert updated["installed_at"] == original_installed_at + restored_config_content = ( + project_dir / ".specify" / "extensions" / "test-ext" / "linear-config.yml" + ).read_text() + assert restored_config_content == original_config_content + + def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path): + """Failed update should restore original registry, hooks, and command files.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + import yaml + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "commands").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + + backup_registry_entry = manager.registry.get("test-ext") + hooks_before = yaml.safe_load((project_dir / ".specify" / "extensions.yml").read_text()) + + registered_commands = backup_registry_entry.get("registered_commands", {}) + command_files = [] + registrar = CommandRegistrar() + for agent_name, cmd_names in registered_commands.items(): + if agent_name not in registrar.AGENT_CONFIGS: + continue + agent_cfg = registrar.AGENT_CONFIGS[agent_name] + commands_dir = project_dir / agent_cfg["dir"] + for cmd_name in cmd_names: + cmd_path = commands_dir / f"{cmd_name}{agent_cfg['extension']}" + command_files.append(cmd_path) + + assert command_files, "Expected at least one registered command file" + for cmd_file in command_files: + assert cmd_file.exists(), f"Expected command file to exist before update: {cmd_file}" + + zip_path = tmp_path / "test-ext-update.zip" + self._create_catalog_zip(zip_path, "2.0.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "_install_allowed": True, + }), \ + patch.object(ExtensionCatalog, "download_extension", return_value=zip_path), \ + patch.object(ExtensionManager, "install_from_zip", side_effect=RuntimeError("install failed")): + result = runner.invoke(app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True) + + assert result.exit_code == 1, result.output + + restored_entry = ExtensionManager(project_dir).registry.get("test-ext") + assert restored_entry == backup_registry_entry + + hooks_after = yaml.safe_load((project_dir / ".specify" / "extensions.yml").read_text()) + assert hooks_after == hooks_before + + for cmd_file in command_files: + assert cmd_file.exists(), f"Expected command file to be restored after rollback: {cmd_file}" From d3fc0567434ea1d37fae25c411de1229c2de02fe Mon Sep 17 00:00:00 2001 From: Dhilip Date: Fri, 13 Mar 2026 08:26:01 -0400 Subject: [PATCH 02/11] Add /selftest.extension core extension to test other extensions (#1758) * test(commands): create extension-commands LLM playground sandbox * update(tests): format LLM evaluation as an automated test runner * test(commands): map extension-commands python script with timestamps * test(commands): map extension-commands python script with timestamps * test(commands): update TESTING.md to evaluate discovery, lint, and deploy explicitly * test(commands): simplify execution expectations and add timestamp calculation * fix(tests): address copilot review comments on prompt formatting and relative paths * fix(tests): resolve copilot PR feedback regarding extension schema structure and argparse mutually exclusive groups * feat(extensions): add core selftest utility and migrate away from manual tests sandbox * fix(selftest): update command name array to match spec-kit validation schema * fix(selftest): wrap arguments in quotes to support multi-word extension names * update the command to be more meaningful * fix: if the extension is discovery only, it should not be installable * Address review comments for selftest documentation * address review comments * address review comments * Update extensions/selftest/commands/selftest.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/catalog.json | 21 ++++++-- extensions/selftest/commands/selftest.md | 69 ++++++++++++++++++++++++ extensions/selftest/extension.yml | 16 ++++++ 3 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 extensions/selftest/commands/selftest.md create mode 100644 extensions/selftest/extension.yml diff --git a/extensions/catalog.json b/extensions/catalog.json index bdebd83d..f06cfe57 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -1,6 +1,21 @@ { "schema_version": "1.0", - "updated_at": "2026-02-03T00:00:00Z", + "updated_at": "2026-03-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", - "extensions": {} -} + "extensions": { + "selftest": { + "name": "Spec Kit Self-Test Utility", + "id": "selftest", + "version": "1.0.0", + "description": "Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle.", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "download_url": "https://github.com/github/spec-kit/releases/download/selftest-v1.0.0/selftest.zip", + "tags": [ + "testing", + "core", + "utility" + ] + } + } +} \ No newline at end of file diff --git a/extensions/selftest/commands/selftest.md b/extensions/selftest/commands/selftest.md new file mode 100644 index 00000000..6f5655ed --- /dev/null +++ b/extensions/selftest/commands/selftest.md @@ -0,0 +1,69 @@ +--- +description: "Validate the lifecycle of an extension from the catalog." +--- + +# Extension Self-Test: `$ARGUMENTS` + +This command drives a self-test simulating the developer experience with the `$ARGUMENTS` extension. + +## Goal + +Validate the end-to-end lifecycle (discovery, installation, registration) for the extension: `$ARGUMENTS`. +If `$ARGUMENTS` is empty, you must tell the user to provide an extension name, for example: `/speckit.selftest.extension linear`. + +## Steps + +### Step 1: Catalog Discovery Validation + +Check if the extension exists in the Spec Kit catalog. +Execute this command and verify that it completes successfully and that the returned extension ID exactly matches `$ARGUMENTS`. If the command fails or the ID does not match `$ARGUMENTS`, fail the test. + +```bash +specify extension info "$ARGUMENTS" +``` + +### Step 2: Simulate Installation + +First, try to add the extension to the current workspace configuration directly. If the catalog provides the extension as `install_allowed: false` (discovery-only), this step is *expected* to fail. + +```bash +specify extension add "$ARGUMENTS" +``` + +Then, simulate adding the extension by installing it from its catalog download URL, which should bypass the restriction. +Obtain the extension's `download_url` from the catalog metadata (for example, via a catalog info command or UI), then run: + +```bash +specify extension add "$ARGUMENTS" --from "" +``` + +### Step 3: Registration Verification + +Once the `add` command completes, verify the installation by checking the project configuration. +Use terminal tools (like `cat`) to verify that the following file contains a record for `$ARGUMENTS`. + +```bash +cat .specify/extensions/.registry/$ARGUMENTS.json +``` + +### Step 4: Verification Report + +Analyze the standard output of the three steps. +Generate a terminal-style test output format detailing the results of discovery, installation, and registration. Return this directly to the user. + +Example output format: +```text +============================= test session starts ============================== +collected 3 items + +test_selftest_discovery.py::test_catalog_search [PASS/FAIL] + Details: [Provide execution result of specify extension search] + +test_selftest_installation.py::test_extension_add [PASS/FAIL] + Details: [Provide execution result of specify extension add] + +test_selftest_registration.py::test_config_verification [PASS/FAIL] + Details: [Provide execution result of registry record verification] + +============================== [X] passed in ... ============================== +``` diff --git a/extensions/selftest/extension.yml b/extensions/selftest/extension.yml new file mode 100644 index 00000000..2d47fdf2 --- /dev/null +++ b/extensions/selftest/extension.yml @@ -0,0 +1,16 @@ +schema_version: "1.0" +extension: + id: selftest + name: Spec Kit Self-Test Utility + version: 1.0.0 + description: Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle. + author: spec-kit-core + repository: https://github.com/github/spec-kit + license: MIT +requires: + speckit_version: ">=0.2.0" +provides: + commands: + - name: speckit.selftest.extension + file: commands/selftest.md + description: Validate the lifecycle of an extension from the catalog. From 976c9981a471b376af035f24b40bc69bdd1979f0 Mon Sep 17 00:00:00 2001 From: Dhilip Date: Fri, 13 Mar 2026 08:35:30 -0400 Subject: [PATCH 03/11] fix(cli): deprecate explicit command support for agy (#1798) (#1808) * fix(cli): deprecate explicit command support for agy (#1798) * docs(cli): add tests and docs for agy deprecation (#1798) * fix: address review comments for agy deprecation * fix: address round 2 review comments for agy deprecation * fix: address round 3 review comments for agy deprecation * fix: address round 4 review comments for agy deprecation * fix: address round 5 review comments for agy deprecation * docs: add inline contextual comments to explain agy deprecation * docs: clarify historical context in agy deprecation docstring * fix: correct skills path in deprecation comment and make test mock fully deterministic --- .../scripts/create-release-packages.ps1 | 2 +- .../scripts/create-release-packages.sh | 4 +- AGENTS.md | 2 +- README.md | 4 +- src/specify_cli/__init__.py | 85 +++++++++++++------ tests/test_agent_config_consistency.py | 9 +- tests/test_ai_skills.py | 53 ++++++++++++ 7 files changed, 127 insertions(+), 32 deletions(-) diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index 8c7d4078..fc307084 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -442,7 +442,7 @@ function Build-Variant { if (Test-Path $tabnineTemplate) { Copy-Item $tabnineTemplate (Join-Path $baseDir 'TABNINE.md') } } 'agy' { - $cmdDir = Join-Path $baseDir ".agent/workflows" + $cmdDir = Join-Path $baseDir ".agent/commands" Generate-Commands -Agent 'agy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script } 'vibe' { diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index 8be5a054..ada3a289 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -280,8 +280,8 @@ build_variant() { mkdir -p "$base_dir/.kiro/prompts" generate_commands kiro-cli md "\$ARGUMENTS" "$base_dir/.kiro/prompts" "$script" ;; agy) - mkdir -p "$base_dir/.agent/workflows" - generate_commands agy md "\$ARGUMENTS" "$base_dir/.agent/workflows" "$script" ;; + mkdir -p "$base_dir/.agent/commands" + generate_commands agy md "\$ARGUMENTS" "$base_dir/.agent/commands" "$script" ;; bob) mkdir -p "$base_dir/.bob/commands" generate_commands bob md "\$ARGUMENTS" "$base_dir/.bob/commands" "$script" ;; diff --git a/AGENTS.md b/AGENTS.md index aa373022..561bf257 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ This eliminates the need for special-case mappings throughout the codebase. - `folder`: Directory where agent-specific files are stored (relative to project root) - `commands_subdir`: Subdirectory name within the agent folder where command/prompt files are stored (default: `"commands"`) - Most agents use `"commands"` (e.g., `.claude/commands/`) - - Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode, agy), `"prompts"` (codex, kiro-cli), `"command"` (opencode - singular) + - Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode), `"prompts"` (codex, kiro-cli), `"command"` (opencode - singular) - This field enables `--ai-skills` to locate command templates correctly for skill generation - `install_url`: Installation documentation URL (set to `None` for IDE-based agents) - `requires_cli`: Whether the agent requires a CLI tool check during initialization diff --git a/README.md b/README.md index 7bda2b9c..006d5522 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ See Spec-Driven Development in action across different scenarios with these comm | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | ✅ | | | [Kimi Code](https://code.kimi.com/) | ✅ | | | [Windsurf](https://windsurf.com/) | ✅ | | -| [Antigravity (agy)](https://antigravity.google/) | ✅ | | +| [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` | | Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir ` for unsupported agents | ## 🔧 Specify CLI Reference @@ -248,7 +248,7 @@ specify init my-project --ai vibe specify init my-project --ai bob # Initialize with Antigravity support -specify init my-project --ai agy +specify init my-project --ai agy --ai-skills # Initialize with an unsupported agent (generic / bring your own agent) specify init my-project --ai generic --ai-commands-dir .myagent/commands/ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 55e97ea9..e7781b2b 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -247,7 +247,7 @@ AGENT_CONFIG = { "agy": { "name": "Antigravity", "folder": ".agent/", - "commands_subdir": "workflows", # Special: uses workflows/ not commands/ + "commands_subdir": "commands", "install_url": None, # IDE-based "requires_cli": False, }, @@ -1270,6 +1270,22 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker return installed_count > 0 or skipped_count > 0 +def _handle_agy_deprecation(console: Console) -> None: + """ + Print the deprecation error for the Antigravity (agy) agent and exit. + + Context for future AI reviewers (e.g., GitHub Copilot): + - Prior to Antigravity v1.20.5, users could rely on explicit agent command definitions generated by this tool. + - v1.20.5 removed this "command support" in favor of "Agent Skills". + - `specify_cli` handles this by throwing a fail-fast deprecation error if `--ai agy` is + invoked without the accompanying `--ai-skills` flag. Users are instructed to add + the skills flag to generate agent skills templates instead. + """ + console.print("\n[red]Error:[/red] Explicit command support was deprecated in Antigravity version 1.20.5.") + console.print("Please use [cyan]--ai-skills[/cyan] when initializing to install templates as agent skills instead.") + console.print("[yellow]Usage:[/yellow] specify init --ai agy --ai-skills") + raise typer.Exit(1) + @app.command() def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), @@ -1379,6 +1395,49 @@ def init( console.print(error_panel) raise typer.Exit(1) + if ai_assistant: + if ai_assistant not in AGENT_CONFIG: + console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}") + raise typer.Exit(1) + selected_ai = ai_assistant + else: + # Create options dict for selection (agent_key: display_name) + ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()} + selected_ai = select_with_arrows( + ai_choices, + "Choose your AI assistant:", + "copilot" + ) + + # [DEPRECATION NOTICE: Antigravity (agy)] + # As of Antigravity v1.20.5, traditional CLI "command" support was fully removed + # in favor of "Agent Skills" (SKILL.md files under /skills//). + # Because 'specify_cli' historically populated .agent/commands/, we now must explicitly + # enforce the `--ai-skills` flag for `agy` to ensure valid template generation. + if selected_ai == "agy" and not ai_skills: + # If agy was selected interactively (no --ai provided), automatically enable + # ai_skills so the agent remains usable without requiring an extra flag. + # Preserve deprecation behavior only for explicit '--ai agy' without skills. + if ai_assistant: + _handle_agy_deprecation(console) + else: + ai_skills = True + console.print( + "\n[yellow]Note:[/yellow] 'agy' was selected interactively; " + "enabling [cyan]--ai-skills[/cyan] automatically for compatibility " + "(explicit .agent/commands usage is deprecated)." + ) + + # Validate --ai-commands-dir usage + if selected_ai == "generic": + if not ai_commands_dir: + console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic") + console.print("[dim]Example: specify init my-project --ai generic --ai-commands-dir .myagent/commands/[/dim]") + raise typer.Exit(1) + elif ai_commands_dir: + console.print(f"[red]Error:[/red] --ai-commands-dir can only be used with --ai generic (not '{selected_ai}')") + raise typer.Exit(1) + current_dir = Path.cwd() setup_lines = [ @@ -1399,30 +1458,6 @@ def init( if not should_init_git: console.print("[yellow]Git not found - will skip repository initialization[/yellow]") - if ai_assistant: - if ai_assistant not in AGENT_CONFIG: - console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}") - raise typer.Exit(1) - selected_ai = ai_assistant - else: - # Create options dict for selection (agent_key: display_name) - ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()} - selected_ai = select_with_arrows( - ai_choices, - "Choose your AI assistant:", - "copilot" - ) - - # Validate --ai-commands-dir usage - if selected_ai == "generic": - if not ai_commands_dir: - console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic") - console.print("[dim]Example: specify init my-project --ai generic --ai-commands-dir .myagent/commands/[/dim]") - raise typer.Exit(1) - elif ai_commands_dir: - console.print(f"[red]Error:[/red] --ai-commands-dir can only be used with --ai generic (not '{selected_ai}')") - raise typer.Exit(1) - if not ignore_agent_tools: agent_config = AGENT_CONFIG.get(selected_ai) if agent_config and agent_config["requires_cli"]: diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index d77c6f10..6831fad3 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -62,7 +62,14 @@ class TestAgentConfigConsistency: ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") assert re.search(r"'shai'\s*\{.*?\.shai/commands", ps_text, re.S) is not None - assert re.search(r"'agy'\s*\{.*?\.agent/workflows", ps_text, re.S) is not None + assert re.search(r"'agy'\s*\{.*?\.agent/commands", ps_text, re.S) is not None + + def test_release_sh_switch_has_shai_and_agy_generation(self): + """Bash release builder must generate files for shai and agy agents.""" + sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") + + assert re.search(r"shai\)\s*\n.*?\.shai/commands", sh_text, re.S) is not None + assert re.search(r"agy\)\s*\n.*?\.agent/commands", sh_text, re.S) is not None def test_init_ai_help_includes_roo_and_kiro_alias(self): """CLI help text for --ai should stay in sync with agent config and alias guidance.""" diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index e9bd71d0..3c50cd50 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -661,6 +661,59 @@ class TestCliValidation: assert "Usage:" in result.output assert "--ai" in result.output + def test_agy_without_ai_skills_fails(self): + """--ai agy without --ai-skills should fail with exit code 1.""" + from typer.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(app, ["init", "test-proj", "--ai", "agy"]) + + assert result.exit_code == 1 + assert "Explicit command support was deprecated in Antigravity version 1.20.5." in result.output + assert "--ai-skills" in result.output + + def test_interactive_agy_without_ai_skills_prompts_skills(self, monkeypatch): + """Interactive selector returning agy without --ai-skills should automatically enable --ai-skills.""" + from typer.testing import CliRunner + + # Mock select_with_arrows to simulate the user picking 'agy' for AI, + # and return a deterministic default for any other prompts to avoid + # calling the real interactive implementation. + def _fake_select_with_arrows(*args, **kwargs): + options = kwargs.get("options") + if options is None and len(args) >= 1: + options = args[0] + + # If the options include 'agy', simulate selecting it. + if isinstance(options, dict) and "agy" in options: + return "agy" + if isinstance(options, (list, tuple)) and "agy" in options: + return "agy" + + # For any other prompt, return a deterministic, non-interactive default: + # pick the first option if available. + if isinstance(options, dict) and options: + return next(iter(options.keys())) + if isinstance(options, (list, tuple)) and options: + return options[0] + + # If no options are provided, fall back to None (should not occur in normal use). + return None + + monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows) + + # Mock download_and_extract_template to prevent real HTTP downloads during testing + monkeypatch.setattr("specify_cli.download_and_extract_template", lambda *args, **kwargs: None) + # We need to bypass the `git init` step, wait, it has `--no-git` by default in tests maybe? + runner = CliRunner() + # Create temp dir to avoid directory already exists errors or whatever + with runner.isolated_filesystem(): + result = runner.invoke(app, ["init", "test-proj", "--no-git"]) + + # Interactive selection should NOT raise the deprecation error! + assert result.exit_code == 0 + assert "Explicit command support was deprecated" not in result.output + def test_ai_skills_flag_appears_in_help(self): """--ai-skills should appear in init --help output.""" from typer.testing import CliRunner From 7562664fd123803018f9b0b4dc164b755e5f525d Mon Sep 17 00:00:00 2001 From: fuyongde Date: Fri, 13 Mar 2026 20:43:14 +0800 Subject: [PATCH 04/11] fix: migrate Qwen Code CLI from TOML to Markdown format (#1589) (#1730) * fix: migrate Qwen Code CLI from TOML to Markdown format (#1589) Qwen Code CLI v0.10.0 deprecated TOML format and fully switched to Markdown as the core format for configuration and interaction files. - Update create-release-packages.sh: generate .md files with $ARGUMENTS instead of .toml files with {{args}} for qwen agent - Update create-release-packages.ps1: same change for PowerShell script - Update AGENTS.md: reflect Qwen's new Markdown format in docs and remove Qwen from TOML format section - Update tests/test_ai_skills.py: add commands_dir_qwen fixture and tests covering Markdown-format skills installation for Qwen Co-Authored-By: Claude Sonnet 4.6 * fix: update CommandRegistrar qwen config to Markdown format extensions.py CommandRegistrar.AGENT_CONFIGS['qwen'] was still set to TOML format, causing `specify extension` to write .toml files into .qwen/commands, conflicting with Qwen Code CLI v0.10.0+ expectations. - Change qwen format from toml to markdown - Change qwen args from {{args}} to $ARGUMENTS - Change qwen extension from .toml to .md - Add test to assert qwen config is Markdown format Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../scripts/create-release-packages.ps1 | 2 +- .../scripts/create-release-packages.sh | 2 +- AGENTS.md | 6 +-- src/specify_cli/extensions.py | 6 +-- tests/test_ai_skills.py | 41 +++++++++++++++++++ tests/test_extensions.py | 9 ++++ 6 files changed, 58 insertions(+), 8 deletions(-) diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index fc307084..60ad3da9 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -382,7 +382,7 @@ function Build-Variant { } 'qwen' { $cmdDir = Join-Path $baseDir ".qwen/commands" - Generate-Commands -Agent 'qwen' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script + Generate-Commands -Agent 'qwen' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script if (Test-Path "agent_templates/qwen/QWEN.md") { Copy-Item -Path "agent_templates/qwen/QWEN.md" -Destination (Join-Path $baseDir "QWEN.md") } diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index ada3a289..620da023 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -240,7 +240,7 @@ build_variant() { generate_commands cursor-agent md "\$ARGUMENTS" "$base_dir/.cursor/commands" "$script" ;; qwen) mkdir -p "$base_dir/.qwen/commands" - generate_commands qwen toml "{{args}}" "$base_dir/.qwen/commands" "$script" + generate_commands qwen md "\$ARGUMENTS" "$base_dir/.qwen/commands" "$script" [[ -f agent_templates/qwen/QWEN.md ]] && cp agent_templates/qwen/QWEN.md "$base_dir/QWEN.md" ;; opencode) mkdir -p "$base_dir/.opencode/command" diff --git a/AGENTS.md b/AGENTS.md index 561bf257..82b444b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Specify supports multiple AI agents by generating agent-specific command files a | **Gemini CLI** | `.gemini/commands/` | TOML | `gemini` | Google's Gemini CLI | | **GitHub Copilot** | `.github/agents/` | Markdown | N/A (IDE-based) | GitHub Copilot in VS Code | | **Cursor** | `.cursor/commands/` | Markdown | `cursor-agent` | Cursor CLI | -| **Qwen Code** | `.qwen/commands/` | TOML | `qwen` | Alibaba's Qwen Code CLI | +| **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI | | **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI | | **Codex CLI** | `.codex/commands/` | Markdown | `codex` | Codex CLI | | **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows | @@ -339,7 +339,7 @@ Work within integrated development environments: ### Markdown Format -Used by: Claude, Cursor, opencode, Windsurf, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code +Used by: Claude, Cursor, opencode, Windsurf, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen **Standard format:** @@ -364,7 +364,7 @@ Command content with {SCRIPT} and $ARGUMENTS placeholders. ### TOML Format -Used by: Gemini, Qwen, Tabnine +Used by: Gemini, Tabnine ```toml description = "Command description" diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index fa9766b0..156daff6 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -748,9 +748,9 @@ class CommandRegistrar: }, "qwen": { "dir": ".qwen/commands", - "format": "toml", - "args": "{{args}}", - "extension": ".toml" + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" }, "opencode": { "dir": ".opencode/command", diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 3c50cd50..45d45cc4 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -132,6 +132,16 @@ def commands_dir_gemini(project_dir): return cmd_dir +@pytest.fixture +def commands_dir_qwen(project_dir): + """Create a populated .qwen/commands directory (Markdown format).""" + cmd_dir = project_dir / ".qwen" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + for name in ["speckit.specify.md", "speckit.plan.md", "speckit.tasks.md"]: + (cmd_dir / name).write_text(f"# {name}\nContent here\n") + return cmd_dir + + # ===== _get_skills_dir Tests ===== class TestGetSkillsDir: @@ -390,6 +400,28 @@ class TestInstallAiSkills: # .toml commands should be untouched assert (cmds_dir / "speckit.specify.toml").exists() + def test_qwen_md_commands_dir_installs_skills(self, project_dir): + """Qwen now uses Markdown format; skills should install directly from .qwen/commands/.""" + cmds_dir = project_dir / ".qwen" / "commands" + cmds_dir.mkdir(parents=True) + (cmds_dir / "speckit.specify.md").write_text( + "---\ndescription: Create or update the feature specification.\n---\n\n# Specify\n\nBody.\n" + ) + (cmds_dir / "speckit.plan.md").write_text( + "---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n" + ) + + result = install_ai_skills(project_dir, "qwen") + + assert result is True + skills_dir = project_dir / ".qwen" / "skills" + assert skills_dir.exists() + skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] + assert len(skill_dirs) >= 1 + # .md commands should be untouched + assert (cmds_dir / "speckit.specify.md").exists() + assert (cmds_dir / "speckit.plan.md").exists() + @pytest.mark.parametrize("agent_key", [k for k in AGENT_CONFIG.keys() if k != "generic"]) def test_skills_install_for_all_agents(self, temp_dir, agent_key): """install_ai_skills should produce skills for every configured agent.""" @@ -446,6 +478,15 @@ class TestCommandCoexistence: remaining = list(commands_dir_gemini.glob("speckit.*")) assert len(remaining) == 3 + def test_existing_commands_preserved_qwen(self, project_dir, templates_dir, commands_dir_qwen): + """install_ai_skills must NOT remove pre-existing .qwen/commands files.""" + assert len(list(commands_dir_qwen.glob("speckit.*"))) == 3 + + install_ai_skills(project_dir, "qwen") + + remaining = list(commands_dir_qwen.glob("speckit.*")) + assert len(remaining) == 3 + def test_commands_dir_not_removed(self, project_dir, templates_dir, commands_dir_claude): """install_ai_skills must not remove the commands directory.""" install_ai_skills(project_dir, "claude") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 4c098c25..6299abbb 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -541,6 +541,15 @@ class TestCommandRegistrar: assert "codex" in CommandRegistrar.AGENT_CONFIGS assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".codex/prompts" + def test_qwen_agent_config_is_markdown(self): + """Qwen should use Markdown format with $ARGUMENTS (not TOML).""" + assert "qwen" in CommandRegistrar.AGENT_CONFIGS + cfg = CommandRegistrar.AGENT_CONFIGS["qwen"] + assert cfg["dir"] == ".qwen/commands" + assert cfg["format"] == "markdown" + assert cfg["args"] == "$ARGUMENTS" + assert cfg["extension"] == ".md" + def test_parse_frontmatter_valid(self): """Test parsing valid YAML frontmatter.""" content = """--- From 017e1c4c2fb68485714f56b97de1af4e81808f3b Mon Sep 17 00:00:00 2001 From: Pierluigi Lenoci Date: Fri, 13 Mar 2026 14:21:55 +0100 Subject: [PATCH 05/11] fix: clean up command templates (specify, analyze) (#1810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: clean up command templates (specify, analyze) - specify.md: fix self-referential step number (step 6c → proceed to step 7) - specify.md: remove empty "General Guidelines" heading before "Quick Guidelines" - analyze.md: deduplicate {ARGS} — already present in "User Input" section at top * fix: restore ## Context heading in analyze template Address PR review feedback from @dhilipkumars: keep the ## Context markdown heading to preserve structural hierarchy for LLM parsing. --- templates/commands/specify.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/templates/commands/specify.md b/templates/commands/specify.md index d66f3fcc..0713b68e 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -129,7 +129,7 @@ Given that feature description, do this: c. **Handle Validation Results**: - - **If all items pass**: Mark checklist complete and proceed to step 6 + - **If all items pass**: Mark checklist complete and proceed to step 7 - **If items fail (excluding [NEEDS CLARIFICATION])**: 1. List the failing items and specific issues @@ -178,8 +178,6 @@ Given that feature description, do this: **NOTE:** The script creates and checks out the new branch and initializes the spec file before writing. -## General Guidelines - ## Quick Guidelines - Focus on **WHAT** users need and **WHY**. From 46bc65b1cefc487e507aa7b809ce4e48b96ba4ec Mon Sep 17 00:00:00 2001 From: Pierluigi Lenoci Date: Fri, 13 Mar 2026 16:47:17 +0100 Subject: [PATCH 06/11] fix: harden bash scripts against shell injection and improve robustness (#1809) - Replace eval of unquoted get_feature_paths output with safe pattern: capture into variable, check return code, then eval quoted result - Use printf '%q' in get_feature_paths to safely emit shell assignments, preventing injection via paths containing quotes or metacharacters - Add json_escape() helper for printf JSON fallback paths, handling backslash, double-quote, and control characters when jq is unavailable - Use jq -cn for safe JSON construction with proper escaping when available, with printf + json_escape() fallback - Replace declare -A (bash 4+) with indexed array for bash 3.2 compatibility (macOS default) - Use inline command -v jq check in create-new-feature.sh since it does not source common.sh - Guard trap cleanup against re-entrant invocation by disarming traps at entry - Use printf '%q' for shell-escaped branch names in user-facing output - Return failure instead of silently returning wrong path on ambiguous spec directory matches - Deduplicate agent file updates via realpath to prevent multiple writes to the same file (e.g. AGENTS.md aliased by multiple variables) --- scripts/bash/check-prerequisites.sh | 42 ++++-- scripts/bash/common.sh | 51 +++++--- scripts/bash/create-new-feature.sh | 27 +++- scripts/bash/setup-plan.sh | 18 ++- scripts/bash/update-agent-context.sh | 189 ++++++++++----------------- 5 files changed, 178 insertions(+), 149 deletions(-) diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index 98e387c2..6f7c99e0 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -79,15 +79,28 @@ SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" # Get feature paths and validate branch -eval $(get_feature_paths) +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 # If paths-only mode, output paths and exit (support JSON + paths-only combined) if $PATHS_ONLY; then if $JSON_MODE; then # Minimal JSON paths payload (no validation performed) - printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \ - "$REPO_ROOT" "$CURRENT_BRANCH" "$FEATURE_DIR" "$FEATURE_SPEC" "$IMPL_PLAN" "$TASKS" + if has_jq; then + jq -cn \ + --arg repo_root "$REPO_ROOT" \ + --arg branch "$CURRENT_BRANCH" \ + --arg feature_dir "$FEATURE_DIR" \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg tasks "$TASKS" \ + '{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}' + else + printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \ + "$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")" + fi else echo "REPO_ROOT: $REPO_ROOT" echo "BRANCH: $CURRENT_BRANCH" @@ -141,14 +154,25 @@ fi # Output results if $JSON_MODE; then # Build JSON array of documents - if [[ ${#docs[@]} -eq 0 ]]; then - json_docs="[]" + if has_jq; then + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) + fi + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}' else - json_docs=$(printf '"%s",' "${docs[@]}") - json_docs="[${json_docs%,}]" + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '"%s",' "${docs[@]}") + json_docs="[${json_docs%,}]" + fi + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" fi - - printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$FEATURE_DIR" "$json_docs" else # Text output echo "FEATURE_DIR:$FEATURE_DIR" diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 2c3165e4..7161f43b 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -120,7 +120,7 @@ find_feature_dir_by_prefix() { # Multiple matches - this shouldn't happen with proper naming convention echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2 echo "Please ensure only one spec directory exists per numeric prefix." >&2 - echo "$specs_dir/$branch_name" # Return something to avoid breaking the script + return 1 fi } @@ -134,21 +134,42 @@ get_feature_paths() { fi # Use prefix-based lookup to support multiple branches per spec - local feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch") + local feature_dir + if ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then + echo "ERROR: Failed to resolve feature directory" >&2 + return 1 + fi - cat </dev/null 2>&1 +} + +# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). +# Handles backslash, double-quote, and control characters (newline, tab, carriage return). +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + printf '%s' "$s" } check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 54697024..725f84c8 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -162,6 +162,17 @@ clean_branch_name() { echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' } +# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + printf '%s' "$s" +} + # Resolve repository root. Prefer git information when available, but fall back # to searching for repository markers so the workflow still functions in repositories that # were initialised with --no-git. @@ -300,14 +311,22 @@ TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md" SPEC_FILE="$FEATURE_DIR/spec.md" if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi -# Set the SPECIFY_FEATURE environment variable for the current session -export SPECIFY_FEATURE="$BRANCH_NAME" +# Inform the user how to persist the feature variable in their own shell +printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 if $JSON_MODE; then - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM" + if command -v jq >/dev/null 2>&1; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + else + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + fi else echo "BRANCH_NAME: $BRANCH_NAME" echo "SPEC_FILE: $SPEC_FILE" echo "FEATURE_NUM: $FEATURE_NUM" - echo "SPECIFY_FEATURE environment variable set to: $BRANCH_NAME" + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" fi diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index d01c6d6c..60cf372c 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -28,7 +28,9 @@ SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" # Get all paths and variables from common functions -eval $(get_feature_paths) +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output # Check if we're on a proper feature branch (only for git repos) check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 @@ -49,8 +51,18 @@ fi # Output results if $JSON_MODE; then - printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \ - "$FEATURE_SPEC" "$IMPL_PLAN" "$FEATURE_DIR" "$CURRENT_BRANCH" "$HAS_GIT" + if has_jq; then + jq -cn \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg specs_dir "$FEATURE_DIR" \ + --arg branch "$CURRENT_BRANCH" \ + --arg has_git "$HAS_GIT" \ + '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}' + else + printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \ + "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")" + fi else echo "FEATURE_SPEC: $FEATURE_SPEC" echo "IMPL_PLAN: $IMPL_PLAN" diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index b0022fd4..341e4e68 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -53,7 +53,9 @@ SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" # Get all paths and variables from common functions -eval $(get_feature_paths) +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output NEW_PLAN="$IMPL_PLAN" # Alias for compatibility with existing code AGENT_TYPE="${1:-}" @@ -71,12 +73,14 @@ AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md" ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md" CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md" QODER_FILE="$REPO_ROOT/QODER.md" -AMP_FILE="$REPO_ROOT/AGENTS.md" +# AMP, Kiro CLI, and IBM Bob all share AGENTS.md — use AGENTS_FILE to avoid +# updating the same file multiple times. +AMP_FILE="$AGENTS_FILE" SHAI_FILE="$REPO_ROOT/SHAI.md" TABNINE_FILE="$REPO_ROOT/TABNINE.md" -KIRO_FILE="$REPO_ROOT/AGENTS.md" +KIRO_FILE="$AGENTS_FILE" AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md" -BOB_FILE="$REPO_ROOT/AGENTS.md" +BOB_FILE="$AGENTS_FILE" VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md" KIMI_FILE="$REPO_ROOT/KIMI.md" @@ -112,6 +116,8 @@ log_warning() { # Cleanup function for temporary files cleanup() { local exit_code=$? + # Disarm traps to prevent re-entrant loop + trap - EXIT INT TERM rm -f /tmp/agent_update_*_$$ rm -f /tmp/manual_additions_$$ exit $exit_code @@ -607,67 +613,67 @@ update_specific_agent() { case "$agent_type" in claude) - update_agent_file "$CLAUDE_FILE" "Claude Code" + update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 ;; gemini) - update_agent_file "$GEMINI_FILE" "Gemini CLI" + update_agent_file "$GEMINI_FILE" "Gemini CLI" || return 1 ;; copilot) - update_agent_file "$COPILOT_FILE" "GitHub Copilot" + update_agent_file "$COPILOT_FILE" "GitHub Copilot" || return 1 ;; cursor-agent) - update_agent_file "$CURSOR_FILE" "Cursor IDE" + update_agent_file "$CURSOR_FILE" "Cursor IDE" || return 1 ;; qwen) - update_agent_file "$QWEN_FILE" "Qwen Code" + update_agent_file "$QWEN_FILE" "Qwen Code" || return 1 ;; opencode) - update_agent_file "$AGENTS_FILE" "opencode" + update_agent_file "$AGENTS_FILE" "opencode" || return 1 ;; codex) - update_agent_file "$AGENTS_FILE" "Codex CLI" + update_agent_file "$AGENTS_FILE" "Codex CLI" || return 1 ;; windsurf) - update_agent_file "$WINDSURF_FILE" "Windsurf" + update_agent_file "$WINDSURF_FILE" "Windsurf" || return 1 ;; kilocode) - update_agent_file "$KILOCODE_FILE" "Kilo Code" + update_agent_file "$KILOCODE_FILE" "Kilo Code" || return 1 ;; auggie) - update_agent_file "$AUGGIE_FILE" "Auggie CLI" + update_agent_file "$AUGGIE_FILE" "Auggie CLI" || return 1 ;; roo) - update_agent_file "$ROO_FILE" "Roo Code" + update_agent_file "$ROO_FILE" "Roo Code" || return 1 ;; codebuddy) - update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" + update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" || return 1 ;; qodercli) - update_agent_file "$QODER_FILE" "Qoder CLI" + update_agent_file "$QODER_FILE" "Qoder CLI" || return 1 ;; amp) - update_agent_file "$AMP_FILE" "Amp" + update_agent_file "$AMP_FILE" "Amp" || return 1 ;; shai) - update_agent_file "$SHAI_FILE" "SHAI" + update_agent_file "$SHAI_FILE" "SHAI" || return 1 ;; tabnine) - update_agent_file "$TABNINE_FILE" "Tabnine CLI" + update_agent_file "$TABNINE_FILE" "Tabnine CLI" || return 1 ;; kiro-cli) - update_agent_file "$KIRO_FILE" "Kiro CLI" + update_agent_file "$KIRO_FILE" "Kiro CLI" || return 1 ;; agy) - update_agent_file "$AGY_FILE" "Antigravity" + update_agent_file "$AGY_FILE" "Antigravity" || return 1 ;; bob) - update_agent_file "$BOB_FILE" "IBM Bob" + update_agent_file "$BOB_FILE" "IBM Bob" || return 1 ;; vibe) - update_agent_file "$VIBE_FILE" "Mistral Vibe" + update_agent_file "$VIBE_FILE" "Mistral Vibe" || return 1 ;; kimi) - update_agent_file "$KIMI_FILE" "Kimi Code" + update_agent_file "$KIMI_FILE" "Kimi Code" || return 1 ;; generic) log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." @@ -682,106 +688,53 @@ update_specific_agent() { update_all_existing_agents() { local found_agent=false - - # Check each possible agent file and update if it exists - if [[ -f "$CLAUDE_FILE" ]]; then - update_agent_file "$CLAUDE_FILE" "Claude Code" - found_agent=true - fi - - if [[ -f "$GEMINI_FILE" ]]; then - update_agent_file "$GEMINI_FILE" "Gemini CLI" - found_agent=true - fi - - if [[ -f "$COPILOT_FILE" ]]; then - update_agent_file "$COPILOT_FILE" "GitHub Copilot" - found_agent=true - fi - - if [[ -f "$CURSOR_FILE" ]]; then - update_agent_file "$CURSOR_FILE" "Cursor IDE" - found_agent=true - fi - - if [[ -f "$QWEN_FILE" ]]; then - update_agent_file "$QWEN_FILE" "Qwen Code" - found_agent=true - fi - - if [[ -f "$AGENTS_FILE" ]]; then - update_agent_file "$AGENTS_FILE" "Codex/opencode" - found_agent=true - fi - - if [[ -f "$WINDSURF_FILE" ]]; then - update_agent_file "$WINDSURF_FILE" "Windsurf" - found_agent=true - fi - - if [[ -f "$KILOCODE_FILE" ]]; then - update_agent_file "$KILOCODE_FILE" "Kilo Code" - found_agent=true - fi + local _updated_paths=() - if [[ -f "$AUGGIE_FILE" ]]; then - update_agent_file "$AUGGIE_FILE" "Auggie CLI" + # Helper: skip non-existent files and files already updated (dedup by + # realpath so that variables pointing to the same file — e.g. AMP_FILE, + # KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once). + # Uses a linear array instead of associative array for bash 3.2 compatibility. + update_if_new() { + local file="$1" name="$2" + [[ -f "$file" ]] || return 0 + local real_path + real_path=$(realpath "$file" 2>/dev/null || echo "$file") + local p + if [[ ${#_updated_paths[@]} -gt 0 ]]; then + for p in "${_updated_paths[@]}"; do + [[ "$p" == "$real_path" ]] && return 0 + done + fi + update_agent_file "$file" "$name" || return 1 + _updated_paths+=("$real_path") found_agent=true - fi - - if [[ -f "$ROO_FILE" ]]; then - update_agent_file "$ROO_FILE" "Roo Code" - found_agent=true - fi + } - if [[ -f "$CODEBUDDY_FILE" ]]; then - update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" - found_agent=true - fi + update_if_new "$CLAUDE_FILE" "Claude Code" + update_if_new "$GEMINI_FILE" "Gemini CLI" + update_if_new "$COPILOT_FILE" "GitHub Copilot" + update_if_new "$CURSOR_FILE" "Cursor IDE" + update_if_new "$QWEN_FILE" "Qwen Code" + update_if_new "$AGENTS_FILE" "Codex/opencode" + update_if_new "$AMP_FILE" "Amp" + update_if_new "$KIRO_FILE" "Kiro CLI" + update_if_new "$BOB_FILE" "IBM Bob" + update_if_new "$WINDSURF_FILE" "Windsurf" + update_if_new "$KILOCODE_FILE" "Kilo Code" + update_if_new "$AUGGIE_FILE" "Auggie CLI" + update_if_new "$ROO_FILE" "Roo Code" + update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" + update_if_new "$SHAI_FILE" "SHAI" + update_if_new "$TABNINE_FILE" "Tabnine CLI" + update_if_new "$QODER_FILE" "Qoder CLI" + update_if_new "$AGY_FILE" "Antigravity" + update_if_new "$VIBE_FILE" "Mistral Vibe" + update_if_new "$KIMI_FILE" "Kimi Code" - if [[ -f "$SHAI_FILE" ]]; then - update_agent_file "$SHAI_FILE" "SHAI" - found_agent=true - fi - - if [[ -f "$TABNINE_FILE" ]]; then - update_agent_file "$TABNINE_FILE" "Tabnine CLI" - found_agent=true - fi - - if [[ -f "$QODER_FILE" ]]; then - update_agent_file "$QODER_FILE" "Qoder CLI" - found_agent=true - fi - - if [[ -f "$KIRO_FILE" ]]; then - update_agent_file "$KIRO_FILE" "Kiro CLI" - found_agent=true - fi - - if [[ -f "$AGY_FILE" ]]; then - update_agent_file "$AGY_FILE" "Antigravity" - found_agent=true - fi - if [[ -f "$BOB_FILE" ]]; then - update_agent_file "$BOB_FILE" "IBM Bob" - found_agent=true - fi - - if [[ -f "$VIBE_FILE" ]]; then - update_agent_file "$VIBE_FILE" "Mistral Vibe" - found_agent=true - fi - - if [[ -f "$KIMI_FILE" ]]; then - update_agent_file "$KIMI_FILE" "Kimi Code" - found_agent=true - fi - # If no agent files exist, create a default Claude file if [[ "$found_agent" == false ]]; then log_info "No existing agent files found, creating default Claude file..." - update_agent_file "$CLAUDE_FILE" "Claude Code" + update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 fi } print_summary() { From b9c1a1c7bb8f3ff5cd2c575b199e0e676a437e6b Mon Sep 17 00:00:00 2001 From: KhawarHabibKhan <132604863+KhawarHabibKhan@users.noreply.github.com> Date: Fri, 13 Mar 2026 20:50:07 +0500 Subject: [PATCH 07/11] Add specify doctor command for project health diagnostics (#1828) * Add specify doctor command for project health diagnostics * Add tests for specify doctor command * Document specify doctor command in README * Revert "Document specify doctor command in README" This reverts commit c1cfd061293ac5c82acb11d8dcbd07d993ce6b48. * Revert "Add tests for specify doctor command" This reverts commit 65e12fb62b7f3611a6598ec41a59c8bf681fe607. * Revert "Add specify doctor command for project health diagnostics" This reverts commit d5bd93248ae05c31ad2ad012983c0f87956dc417. * Add doctor extension to community catalog * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/README.md | 1 + extensions/catalog.community.json | 33 ++++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/extensions/README.md b/extensions/README.md index e8f1617e..4c3f9d80 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -76,6 +76,7 @@ The following community-contributed extensions are available in [`catalog.commun | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | +| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | | Ralph Loop | Autonomous implementation loop using AI agent CLI | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 759bd10d..f1e0a092 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-03-09T00:00:00Z", + "updated_at": "2026-03-13T12:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "azure-devops": { @@ -74,6 +74,37 @@ "created_at": "2026-02-22T00:00:00Z", "updated_at": "2026-02-22T00:00:00Z" }, + "doctor": { + "name": "Project Health Check", + "id": "doctor", + "description": "Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git.", + "author": "KhawarHabibKhan", + "version": "1.0.0", + "download_url": "https://github.com/KhawarHabibKhan/spec-kit-doctor/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/KhawarHabibKhan/spec-kit-doctor", + "homepage": "https://github.com/KhawarHabibKhan/spec-kit-doctor", + "documentation": "https://github.com/KhawarHabibKhan/spec-kit-doctor/blob/main/README.md", + "changelog": "https://github.com/KhawarHabibKhan/spec-kit-doctor/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "diagnostics", + "health-check", + "validation", + "project-structure" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-13T00:00:00Z", + "updated_at": "2026-03-13T00:00:00Z" + }, "fleet": { "name": "Fleet Orchestrator", "id": "fleet", From c883952b43e0e322e410bb66d86c9826eaf8d245 Mon Sep 17 00:00:00 2001 From: eason <85663565+mango766@users.noreply.github.com> Date: Sat, 14 Mar 2026 00:46:21 +0800 Subject: [PATCH 08/11] fix: match 'Last updated' timestamp with or without bold markers (#1836) The template outputs plain text `Last updated: [DATE]` but both update-agent-context scripts only matched `**Last updated**: [DATE]` (bold Markdown). Make the bold markers optional in the regex so the timestamp is refreshed regardless of formatting. Co-authored-by: easonysliu Co-authored-by: Claude (claude-opus-4-6) --- scripts/bash/update-agent-context.sh | 2 +- scripts/powershell/update-agent-context.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index 341e4e68..e0f28548 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -482,7 +482,7 @@ update_existing_agent_file() { fi # Update timestamp - if [[ "$line" =~ \*\*Last\ updated\*\*:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then + if [[ "$line" =~ (\*\*)?Last\ updated(\*\*)?:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then echo "$line" | sed "s/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/$current_date/" >> "$temp_file" else echo "$line" >> "$temp_file" diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index 95e636ba..30e1e0e6 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -331,7 +331,7 @@ function Update-ExistingAgentFile { if ($existingChanges -lt 2) { $output.Add($line); $existingChanges++ } continue } - if ($line -match '\*\*Last updated\*\*: .*\d{4}-\d{2}-\d{2}') { + if ($line -match '(\*\*)?Last updated(\*\*)?: .*\d{4}-\d{2}-\d{2}') { $output.Add(($line -replace '\d{4}-\d{2}-\d{2}',$Date.ToString('yyyy-MM-dd'))) continue } From 69ee7a836e25f5a732e3099d0c904d9f8b9ba4ac Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:09:14 -0500 Subject: [PATCH 09/11] feat(presets): Pluggable preset system with catalog, resolver, and skills propagation (#1787) * Initial plan * feat(templates): add pluggable template system with packs, catalog, resolver, and CLI commands Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * test(templates): add comprehensive unit tests for template pack system Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * feat(presets): pluggable preset system with template/command overrides, catalog, and resolver - Rename 'template packs' to 'presets' to avoid naming collision with core templates - PresetManifest, PresetRegistry, PresetManager, PresetCatalog, PresetResolver in presets.py - Extract CommandRegistrar to agents.py as shared infrastructure - CLI: specify preset list/add/remove/search/resolve/info - CLI: specify preset catalog list/add/remove - --preset option on specify init - Priority-based preset stacking (--priority, lower = higher precedence) - Command overrides registered into all detected agent directories (17+ agents) - Extension command safety: skip registration if target extension not installed - Multi-catalog support: env var, project config, user config, built-in defaults - resolve_template() / Resolve-Template in bash/PowerShell scripts - Self-test preset: overrides all 6 core templates + 1 command - Scaffold with 4 examples: core/extension template and command overrides - Preset catalog (catalog.json, catalog.community.json) - Documentation: README.md, ARCHITECTURE.md, PUBLISHING.md - 110 preset tests, 253 total tests passing * feat(presets): propagate command overrides to skills via init-options - Add save_init_options() / load_init_options() helpers that persist CLI flags from 'specify init' to .specify/init-options.json - PresetManager._register_skills() overwrites SKILL.md files when --ai-skills was used during init and corresponding skill dirs exist - PresetManager._unregister_skills() restores core template content on preset removal - registered_skills stored in preset registry metadata - 8 new tests covering skill override, skip conditions, and restore * fix: address PR check failures (ruff F541, CodeQL URL substring) - Remove extraneous f-prefix from two f-strings without placeholders - Replace substring URL check in test with startswith/endswith assertions to satisfy CodeQL incomplete URL substring sanitization rule * fix: address Copilot PR review comments - Move save_init_options() before preset install so skills propagation works during 'specify init --preset --ai-skills' - Clean up downloaded ZIP after successful preset install during init - Validate --from URL scheme (require HTTPS, HTTP only for localhost) - Expose unregister_commands() on extensions.py CommandRegistrar wrapper instead of reaching into private _registrar field - Use _get_merged_packs() for search() and get_pack_info() so all active catalogs are searched, not just the highest-priority one - Fix fetch_catalog() cache to verify cached URL matches current URL - Fix PresetResolver: script resolution uses .sh extension, consistent file extensions throughout resolve(), and resolve_with_source() delegates to resolve() to honor template_type parameter - Fix bash common.sh: fall through to directory scan when python3 returns empty preset list - Fix PowerShell Resolve-Template: filter out dot-folders and sort extensions deterministically * fix: narrow empty except blocks and add explanatory comments * fix: address Copilot PR review comments (round 2) - Fix init --preset error masking: distinguish "not found" from real errors - Fix bash resolve_template: skip hidden dirs in extensions (match Python/PS) - Fix temp dir leaks in tests: use temp_dir fixture instead of mkdtemp - Fix self-test catalog entry: add note that it's local-only (no download_url) - Fix Windows path issue in resolve_with_source: use Path.relative_to() - Fix skill restore path: use project's .specify/templates/commands/ not source tree - Add encoding="utf-8" to all file read/write in agents.py - Update test to set up core command templates for skill restoration * fix: remove self-test from catalog.json (local-only preset) * fix: address Copilot PR review comments (round 3) - Fix PS Resolve-Template fallback to skip dot-prefixed dirs (.cache) - Rename _catalog to _catalog_name for consistency with extension system - Enforce install_allowed policy in CLI preset add and download_pack() - Fix shell injection: pass registry path via env var instead of string interpolation * fix: correct PresetError docstring from template to preset * Removed CHANGELOG requirement * Applying review recommendations * Applying review recommendations * Applying review recommendations * Applying review recommendations --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --- AGENTS.md | 4 - CHANGELOG.md | 28 +- presets/ARCHITECTURE.md | 157 ++ presets/PUBLISHING.md | 295 +++ presets/README.md | 115 ++ presets/catalog.community.json | 6 + presets/catalog.json | 6 + presets/scaffold/README.md | 46 + .../commands/speckit.myext.myextcmd.md | 20 + presets/scaffold/commands/speckit.specify.md | 23 + presets/scaffold/preset.yml | 91 + presets/scaffold/templates/myext-template.md | 24 + presets/scaffold/templates/spec-template.md | 18 + presets/self-test/commands/speckit.specify.md | 15 + presets/self-test/preset.yml | 61 + .../templates/agent-file-template.md | 9 + .../self-test/templates/checklist-template.md | 15 + .../templates/constitution-template.md | 15 + presets/self-test/templates/plan-template.md | 22 + presets/self-test/templates/spec-template.md | 23 + presets/self-test/templates/tasks-template.md | 17 + scripts/bash/common.sh | 76 + scripts/bash/create-new-feature.sh | 5 +- scripts/bash/setup-plan.sh | 6 +- scripts/powershell/common.ps1 | 67 + scripts/powershell/create-new-feature.ps1 | 7 +- scripts/powershell/setup-plan.ps1 | 6 +- src/specify_cli/__init__.py | 576 +++++- src/specify_cli/agents.py | 422 ++++ src/specify_cli/extensions.py | 432 +---- src/specify_cli/presets.py | 1530 +++++++++++++++ tests/test_presets.py | 1712 +++++++++++++++++ 32 files changed, 5446 insertions(+), 403 deletions(-) create mode 100644 presets/ARCHITECTURE.md create mode 100644 presets/PUBLISHING.md create mode 100644 presets/README.md create mode 100644 presets/catalog.community.json create mode 100644 presets/catalog.json create mode 100644 presets/scaffold/README.md create mode 100644 presets/scaffold/commands/speckit.myext.myextcmd.md create mode 100644 presets/scaffold/commands/speckit.specify.md create mode 100644 presets/scaffold/preset.yml create mode 100644 presets/scaffold/templates/myext-template.md create mode 100644 presets/scaffold/templates/spec-template.md create mode 100644 presets/self-test/commands/speckit.specify.md create mode 100644 presets/self-test/preset.yml create mode 100644 presets/self-test/templates/agent-file-template.md create mode 100644 presets/self-test/templates/checklist-template.md create mode 100644 presets/self-test/templates/constitution-template.md create mode 100644 presets/self-test/templates/plan-template.md create mode 100644 presets/self-test/templates/spec-template.md create mode 100644 presets/self-test/templates/tasks-template.md create mode 100644 src/specify_cli/agents.py create mode 100644 src/specify_cli/presets.py create mode 100644 tests/test_presets.py diff --git a/AGENTS.md b/AGENTS.md index 82b444b1..8f0742eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,10 +10,6 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their --- -## General practices - -- Any changes to `__init__.py` for the Specify CLI require a version rev in `pyproject.toml` and addition of entries to `CHANGELOG.md`. - ## Adding New Agent Support This section explains how to add support for new AI agents/assistants to the Specify CLI. Use this guide as a reference when integrating new AI tools into the Spec-Driven Development workflow. diff --git a/CHANGELOG.md b/CHANGELOG.md index 536b8084..89918e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ Recent changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- feat(presets): Pluggable preset system with preset catalog and template resolver +- Preset manifest (`preset.yml`) with validation for artifact, command, and script types +- `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py` +- CLI commands: `specify preset search`, `specify preset add`, `specify preset list`, `specify preset remove`, `specify preset resolve`, `specify preset info` +- CLI commands: `specify preset catalog list`, `specify preset catalog add`, `specify preset catalog remove` for multi-catalog management +- `PresetCatalogEntry` dataclass and multi-catalog support mirroring the extension catalog system +- `--preset` option for `specify init` to install presets during initialization +- Priority-based preset resolution: presets with lower priority number win (`--priority` flag) +- `resolve_template()` / `Resolve-Template` helpers in bash and PowerShell common scripts +- Template resolution priority stack: overrides → presets → extensions → core +- Preset catalog files (`presets/catalog.json`, `presets/catalog.community.json`) +- Preset scaffold directory (`presets/scaffold/`) +- Scripts updated to use template resolution instead of hardcoded paths +- feat(presets): Preset command overrides now propagate to agent skills when `--ai-skills` was used during init +- feat: `specify init` persists CLI options to `.specify/init-options.json` for downstream operations +- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781) + ## [0.2.1] - 2026-03-11 ### Changed @@ -51,13 +72,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - fix: release-trigger uses release branch + PR instead of direct push to main (#1733) - fix: Split release process to sync pyproject.toml version with git tags (#1732) - -## [Unreleased] - -### Added - -- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781) - ## [0.2.0] - 2026-03-09 ### Changed diff --git a/presets/ARCHITECTURE.md b/presets/ARCHITECTURE.md new file mode 100644 index 00000000..d0e65478 --- /dev/null +++ b/presets/ARCHITECTURE.md @@ -0,0 +1,157 @@ +# Preset System Architecture + +This document describes the internal architecture of the preset system — how template resolution, command registration, and catalog management work under the hood. + +For usage instructions, see [README.md](README.md). + +## Template Resolution + +When Spec Kit needs a template (e.g. `spec-template`), the `PresetResolver` walks a priority stack and returns the first match: + +```mermaid +flowchart TD + A["resolve_template('spec-template')"] --> B{Override exists?} + B -- Yes --> C[".specify/templates/overrides/spec-template.md"] + B -- No --> D{Preset provides it?} + D -- Yes --> E[".specify/presets/‹preset-id›/templates/spec-template.md"] + D -- No --> F{Extension provides it?} + F -- Yes --> G[".specify/extensions/‹ext-id›/templates/spec-template.md"] + F -- No --> H[".specify/templates/spec-template.md"] + + E -- "multiple presets?" --> I["lowest priority number wins"] + I --> E + + style C fill:#4caf50,color:#fff + style E fill:#2196f3,color:#fff + style G fill:#ff9800,color:#fff + style H fill:#9e9e9e,color:#fff +``` + +| Priority | Source | Path | Use case | +|----------|--------|------|----------| +| 1 (highest) | Override | `.specify/templates/overrides/` | One-off project-local tweaks | +| 2 | Preset | `.specify/presets//templates/` | Shareable, stackable customizations | +| 3 | Extension | `.specify/extensions//templates/` | Extension-provided templates | +| 4 (lowest) | Core | `.specify/templates/` | Shipped defaults | + +When multiple presets are installed, they're sorted by their `priority` field (lower number = higher precedence). This is set via `--priority` on `specify preset add`. + +The resolution is implemented three times to ensure consistency: +- **Python**: `PresetResolver` in `src/specify_cli/presets.py` +- **Bash**: `resolve_template()` in `scripts/bash/common.sh` +- **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1` + +## Command Registration + +When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`. + +```mermaid +flowchart TD + A["specify preset add my-preset"] --> B{Preset has type: command?} + B -- No --> Z["done (templates only)"] + B -- Yes --> C{Extension command?} + C -- "speckit.myext.cmd\n(3+ dot segments)" --> D{Extension installed?} + D -- No --> E["skip (extension not active)"] + D -- Yes --> F["register command"] + C -- "speckit.specify\n(core command)" --> F + F --> G["detect agent directories"] + G --> H[".claude/commands/"] + G --> I[".gemini/commands/"] + G --> J[".github/agents/"] + G --> K["... (17+ agents)"] + H --> L["write .md (Markdown format)"] + I --> M["write .toml (TOML format)"] + J --> N["write .agent.md + .prompt.md"] + + style E fill:#ff5722,color:#fff + style L fill:#4caf50,color:#fff + style M fill:#4caf50,color:#fff + style N fill:#4caf50,color:#fff +``` + +### Extension safety check + +Command names follow the pattern `speckit..`. When a command has 3+ dot segments, the system extracts the extension ID and checks if `.specify/extensions//` exists. If the extension isn't installed, the command is skipped — preventing orphan files referencing non-existent extensions. + +Core commands (e.g. `speckit.specify`, with only 2 segments) are always registered. + +### Agent format rendering + +The `CommandRegistrar` renders commands differently per agent: + +| Agent | Format | Extension | Arg placeholder | +|-------|--------|-----------|-----------------| +| Claude, Cursor, opencode, Windsurf, etc. | Markdown | `.md` | `$ARGUMENTS` | +| Copilot | Markdown | `.agent.md` + `.prompt.md` | `$ARGUMENTS` | +| Gemini, Qwen, Tabnine | TOML | `.toml` | `{{args}}` | + +### Cleanup on removal + +When `specify preset remove` is called, the registered commands are read from the registry metadata and the corresponding files are deleted from each agent directory, including Copilot companion `.prompt.md` files. + +## Catalog System + +```mermaid +flowchart TD + A["specify preset search"] --> B["PresetCatalog.get_active_catalogs()"] + B --> C{SPECKIT_PRESET_CATALOG_URL set?} + C -- Yes --> D["single custom catalog"] + C -- No --> E{.specify/preset-catalogs.yml exists?} + E -- Yes --> F["project-level catalog stack"] + E -- No --> G{"~/.specify/preset-catalogs.yml exists?"} + G -- Yes --> H["user-level catalog stack"] + G -- No --> I["built-in defaults"] + I --> J["default (install allowed)"] + I --> K["community (discovery only)"] + + style D fill:#ff9800,color:#fff + style F fill:#2196f3,color:#fff + style H fill:#2196f3,color:#fff + style J fill:#4caf50,color:#fff + style K fill:#9e9e9e,color:#fff +``` + +Catalogs are fetched with a 1-hour cache (per-URL, SHA256-hashed cache files). Each catalog entry has a `priority` (for merge ordering) and `install_allowed` flag. + +## Repository Layout + +``` +presets/ +├── ARCHITECTURE.md # This file +├── PUBLISHING.md # Guide for submitting presets to the catalog +├── README.md # User guide +├── catalog.json # Official preset catalog +├── catalog.community.json # Community preset catalog +├── scaffold/ # Scaffold for creating new presets +│ ├── preset.yml # Example manifest +│ ├── README.md # Guide for customizing the scaffold +│ ├── commands/ +│ │ ├── speckit.specify.md # Core command override example +│ │ └── speckit.myext.myextcmd.md # Extension command override example +│ └── templates/ +│ ├── spec-template.md # Core template override example +│ └── myext-template.md # Extension template override example +└── self-test/ # Self-test preset (overrides all core templates) + ├── preset.yml + ├── commands/ + │ └── speckit.specify.md + └── templates/ + ├── spec-template.md + ├── plan-template.md + ├── tasks-template.md + ├── checklist-template.md + ├── constitution-template.md + └── agent-file-template.md +``` + +## Module Structure + +``` +src/specify_cli/ +├── agents.py # CommandRegistrar — shared infrastructure for writing +│ # command files to agent directories +├── presets.py # PresetManifest, PresetRegistry, PresetManager, +│ # PresetCatalog, PresetCatalogEntry, PresetResolver +└── __init__.py # CLI commands: specify preset list/add/remove/search/ + # resolve/info, specify preset catalog list/add/remove +``` diff --git a/presets/PUBLISHING.md b/presets/PUBLISHING.md new file mode 100644 index 00000000..5e91c4b7 --- /dev/null +++ b/presets/PUBLISHING.md @@ -0,0 +1,295 @@ +# Preset Publishing Guide + +This guide explains how to publish your preset to the Spec Kit preset catalog, making it discoverable by `specify preset search`. + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Prepare Your Preset](#prepare-your-preset) +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 a preset, ensure you have: + +1. **Valid Preset**: A working preset with a valid `preset.yml` manifest +2. **Git Repository**: Preset hosted on GitHub (or other public git hosting) +3. **Documentation**: README.md with description 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**: Preset tested on real projects with `specify preset add --dev` + +--- + +## Prepare Your Preset + +### 1. Preset Structure + +Ensure your preset follows the standard structure: + +```text +your-preset/ +├── preset.yml # Required: Preset manifest +├── README.md # Required: Documentation +├── LICENSE # Required: License file +├── CHANGELOG.md # Recommended: Version history +│ +├── templates/ # Template overrides +│ ├── spec-template.md +│ ├── plan-template.md +│ └── ... +│ +└── commands/ # Command overrides (optional) + └── speckit.specify.md +``` + +Start from the [scaffold](scaffold/) if you're creating a new preset. + +### 2. preset.yml Validation + +Verify your manifest is valid: + +```yaml +schema_version: "1.0" + +preset: + id: "your-preset" # Unique lowercase-hyphenated ID + name: "Your Preset 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-preset-your-preset" + license: "MIT" + +requires: + speckit_version: ">=0.1.0" # Required spec-kit version + +provides: + templates: + - type: "template" + name: "spec-template" + file: "templates/spec-template.md" + description: "Custom spec template" + replaces: "spec-template" + +tags: # 2-5 relevant tags + - "category" + - "workflow" +``` + +**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 200 characters) +- ✅ `repository` URL is valid and public +- ✅ All template and command files exist in the preset directory +- ✅ Template names are lowercase with hyphens only +- ✅ Command names use dot notation (e.g. `speckit.specify`) +- ✅ Tags are lowercase and descriptive + +### 3. Test Locally + +```bash +# Install from local directory +specify preset add --dev /path/to/your-preset + +# Verify templates resolve from your preset +specify preset resolve spec-template + +# Verify preset info +specify preset info your-preset + +# List installed presets +specify preset list + +# Remove when done testing +specify preset remove your-preset +``` + +If your preset includes command overrides, verify they appear in the agent directories: + +```bash +# Check Claude commands (if using Claude) +ls .claude/commands/speckit.*.md + +# Check Copilot commands (if using Copilot) +ls .github/agents/speckit.*.agent.md + +# Check Gemini commands (if using Gemini) +ls .gemini/commands/speckit.*.toml +``` + +### 4. Create GitHub Release + +Create a GitHub release for your preset version: + +```bash +# Tag the release +git tag v1.0.0 +git push origin v1.0.0 +``` + +The release archive URL will be: + +```text +https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip +``` + +### 5. Test Installation from Archive + +```bash +specify preset add --from https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip +``` + +--- + +## Submit to Catalog + +### Understanding the Catalogs + +Spec Kit uses a dual-catalog system: + +- **`catalog.json`** — Official, verified presets (install allowed by default) +- **`catalog.community.json`** — Community-contributed presets (discovery only by default) + +All community presets should be submitted to `catalog.community.json`. + +### 1. Fork the spec-kit Repository + +```bash +git clone https://github.com/YOUR-USERNAME/spec-kit.git +cd spec-kit +``` + +### 2. Add Preset to Community Catalog + +Edit `presets/catalog.community.json` and add your preset. + +> **⚠️ Entries must be sorted alphabetically by preset ID.** Insert your preset in the correct position within the `"presets"` object. + +```json +{ + "schema_version": "1.0", + "updated_at": "2026-03-10T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", + "presets": { + "your-preset": { + "name": "Your Preset Name", + "description": "Brief description of what your preset provides", + "author": "Your Name", + "version": "1.0.0", + "download_url": "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/your-org/spec-kit-preset-your-preset", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "templates": 3, + "commands": 1 + }, + "tags": [ + "category", + "workflow" + ], + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-10T00:00:00Z" + } + } +} +``` + +### 3. Submit Pull Request + +```bash +git checkout -b add-your-preset +git add presets/catalog.community.json +git commit -m "Add your-preset to community catalog + +- Preset ID: your-preset +- Version: 1.0.0 +- Author: Your Name +- Description: Brief description +" +git push origin add-your-preset +``` + +**Pull Request Checklist**: + +```markdown +## Preset Submission + +**Preset Name**: Your Preset Name +**Preset ID**: your-preset +**Version**: 1.0.0 +**Repository**: https://github.com/your-org/spec-kit-preset-your-preset + +### Checklist +- [ ] Valid preset.yml manifest +- [ ] README.md with description and usage +- [ ] LICENSE file included +- [ ] GitHub release created +- [ ] Preset tested with `specify preset add --dev` +- [ ] Templates resolve correctly (`specify preset resolve`) +- [ ] Commands register to agent directories (if applicable) +- [ ] Commands match template sections (command + template are coherent) +- [ ] Added to presets/catalog.community.json +``` + +--- + +## Verification Process + +After submission, maintainers will review: + +1. **Manifest validation** — valid `preset.yml`, all files exist +2. **Template quality** — templates are useful and well-structured +3. **Command coherence** — commands reference sections that exist in templates +4. **Security** — no malicious content, safe file operations +5. **Documentation** — clear README explaining what the preset does + +Once verified, `verified: true` is set and the preset appears in `specify preset search`. + +--- + +## Release Workflow + +When releasing a new version: + +1. Update `version` in `preset.yml` +2. Update CHANGELOG.md +3. Tag and push: `git tag v1.1.0 && git push origin v1.1.0` +4. Submit PR to update `version` and `download_url` in `presets/catalog.community.json` + +--- + +## Best Practices + +### Template Design + +- **Keep sections clear** — use headings and placeholder text the LLM can replace +- **Match commands to templates** — if your preset overrides a command, make sure it references the sections in your template +- **Document customization points** — use HTML comments to guide users on what to change + +### Naming + +- Preset IDs should be descriptive: `healthcare-compliance`, `enterprise-safe`, `startup-lean` +- Avoid generic names: `my-preset`, `custom`, `test` + +### Stacking + +- Design presets to work well when stacked with others +- Only override templates you need to change +- Document which templates and commands your preset modifies + +### Command Overrides + +- Only override commands when the workflow needs to change, not just the output format +- If you only need different template sections, a template override is sufficient +- Test command overrides with multiple agents (Claude, Gemini, Copilot) diff --git a/presets/README.md b/presets/README.md new file mode 100644 index 00000000..2fb22a71 --- /dev/null +++ b/presets/README.md @@ -0,0 +1,115 @@ +# Presets + +Presets are stackable, priority-ordered collections of template and command overrides for Spec Kit. They let you customize both the artifacts produced by the Spec-Driven Development workflow (specs, plans, tasks, checklists, constitutions) and the commands that guide the LLM in creating them — without forking or modifying core files. + +## How It Works + +When Spec Kit needs a template (e.g. `spec-template`), it walks a resolution stack: + +1. `.specify/templates/overrides/` — project-local one-off overrides +2. `.specify/presets//templates/` — installed presets (sorted by priority) +3. `.specify/extensions//templates/` — extension-provided templates +4. `.specify/templates/` — core templates shipped with Spec Kit + +If no preset is installed, core templates are used — exactly the same behavior as before presets existed. + +For detailed resolution and command registration flows, see [ARCHITECTURE.md](ARCHITECTURE.md). + +## Command Overrides + +Presets can also override the commands that guide the SDD workflow. Templates define *what* gets produced (specs, plans, constitutions); commands define *how* the LLM produces them (the step-by-step instructions). + +When a preset includes `type: "command"` entries, the commands are automatically registered into all detected agent directories (`.claude/commands/`, `.gemini/commands/`, etc.) in the correct format (Markdown or TOML with appropriate argument placeholders). When the preset is removed, the registered commands are cleaned up. + +## Quick Start + +```bash +# Search available presets +specify preset search + +# Install a preset from the catalog +specify preset add healthcare-compliance + +# Install from a local directory (for development) +specify preset add --dev ./my-preset + +# Install with a specific priority (lower = higher precedence) +specify preset add healthcare-compliance --priority 5 + +# List installed presets +specify preset list + +# See which template a name resolves to +specify preset resolve spec-template + +# Get detailed info about a preset +specify preset info healthcare-compliance + +# Remove a preset +specify preset remove healthcare-compliance +``` + +## Stacking Presets + +Multiple presets can be installed simultaneously. The `--priority` flag controls which one wins when two presets provide the same template (lower number = higher precedence): + +```bash +specify preset add enterprise-safe --priority 10 # base layer +specify preset add healthcare-compliance --priority 5 # overrides enterprise-safe +specify preset add pm-workflow --priority 1 # overrides everything +``` + +Presets **override**, they don't merge. If two presets both provide `spec-template`, the one with the lowest priority number wins entirely. + +## Catalog Management + +Presets are discovered through catalogs. By default, Spec Kit uses the official and community catalogs: + +```bash +# List active catalogs +specify preset catalog list + +# Add a custom catalog +specify preset catalog add https://example.com/catalog.json --name my-org --install-allowed + +# Remove a catalog +specify preset catalog remove my-org +``` + +## Creating a Preset + +See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset. + +1. Copy `scaffold/` to a new directory +2. Edit `preset.yml` with your preset's metadata +3. Add or replace templates in `templates/` +4. Test locally with `specify preset add --dev .` +5. Verify with `specify preset resolve spec-template` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `SPECKIT_PRESET_CATALOG_URL` | Override the catalog URL (replaces all defaults) | + +## Configuration Files + +| File | Scope | Description | +|------|-------|-------------| +| `.specify/preset-catalogs.yml` | Project | Custom catalog stack for this project | +| `~/.specify/preset-catalogs.yml` | User | Custom catalog stack for all projects | + +## Future Considerations + +The following enhancements are under consideration for future releases: + +- **Composition strategies** — Allow presets to declare a `strategy` per template instead of the default `replace`: + + | Type | `replace` | `prepend` | `append` | `wrap` | + |------|-----------|-----------|----------|--------| + | **template** | ✓ (default) | ✓ | ✓ | ✓ | + | **command** | ✓ (default) | ✓ | ✓ | ✓ | + | **script** | ✓ (default) | — | — | ✓ | + + For artifacts and commands (which are LLM directives), `wrap` would inject preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder. For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable. +- **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: "wrap"` option could allow presets to run custom logic before/after the core script without fully replacing it. diff --git a/presets/catalog.community.json b/presets/catalog.community.json new file mode 100644 index 00000000..368f208b --- /dev/null +++ b/presets/catalog.community.json @@ -0,0 +1,6 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-03-09T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", + "presets": {} +} diff --git a/presets/catalog.json b/presets/catalog.json new file mode 100644 index 00000000..ca40f852 --- /dev/null +++ b/presets/catalog.json @@ -0,0 +1,6 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-03-10T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json", + "presets": {} +} diff --git a/presets/scaffold/README.md b/presets/scaffold/README.md new file mode 100644 index 00000000..b30a1ab6 --- /dev/null +++ b/presets/scaffold/README.md @@ -0,0 +1,46 @@ +# My Preset + +A custom preset for Spec Kit. Copy this directory and customize it to create your own. + +## Templates Included + +| Template | Type | Description | +|----------|------|-------------| +| `spec-template` | template | Custom feature specification template (overrides core and extensions) | +| `myext-template` | template | Override of the myext extension's report template | +| `speckit.specify` | command | Custom specification command (overrides core) | +| `speckit.myext.myextcmd` | command | Override of the myext extension's myextcmd command | + +## Development + +1. Copy this directory: `cp -r presets/scaffold my-preset` +2. Edit `preset.yml` — set your preset's ID, name, description, and templates +3. Add or modify templates in `templates/` +4. Test locally: `specify preset add --dev ./my-preset` +5. Verify resolution: `specify preset resolve spec-template` +6. Remove when done testing: `specify preset remove my-preset` + +## Manifest Reference (`preset.yml`) + +Required fields: +- `schema_version` — always `"1.0"` +- `preset.id` — lowercase alphanumeric with hyphens +- `preset.name` — human-readable name +- `preset.version` — semantic version (e.g. `1.0.0`) +- `preset.description` — brief description +- `requires.speckit_version` — version constraint (e.g. `>=0.1.0`) +- `provides.templates` — list of templates with `type`, `name`, and `file` + +## Template Types + +- **template** — Document scaffolds (spec-template.md, plan-template.md, tasks-template.md, etc.) +- **command** — AI agent workflow prompts (e.g. speckit.specify, speckit.plan) +- **script** — Custom scripts (reserved for future use) + +## Publishing + +See the [Preset Publishing Guide](../PUBLISHING.md) for details on submitting to the catalog. + +## License + +MIT diff --git a/presets/scaffold/commands/speckit.myext.myextcmd.md b/presets/scaffold/commands/speckit.myext.myextcmd.md new file mode 100644 index 00000000..5adef240 --- /dev/null +++ b/presets/scaffold/commands/speckit.myext.myextcmd.md @@ -0,0 +1,20 @@ +--- +description: "Override of the myext extension's myextcmd command" +--- + + + +You are following a customized version of the myext extension's myextcmd command. + +When executing this command: + +1. Read the user's input from $ARGUMENTS +2. Follow the standard myextcmd workflow +3. Additionally, apply the following customizations from this preset: + - Add compliance checks before proceeding + - Include audit trail entries in the output + +> CUSTOMIZE: Replace the instructions above with your own. +> This file overrides the command that the "myext" extension provides. +> When this preset is installed, all agents (Claude, Gemini, Copilot, etc.) +> will use this version instead of the extension's original. diff --git a/presets/scaffold/commands/speckit.specify.md b/presets/scaffold/commands/speckit.specify.md new file mode 100644 index 00000000..7926cc13 --- /dev/null +++ b/presets/scaffold/commands/speckit.specify.md @@ -0,0 +1,23 @@ +--- +description: "Create a feature specification (preset override)" +scripts: + sh: scripts/bash/create-new-feature.sh "{ARGS}" + ps: scripts/powershell/create-new-feature.ps1 "{ARGS}" +--- + +## User Input + +```text +$ARGUMENTS +``` + +Given the feature description above: + +1. **Create the feature branch** by running the script: + - Bash: `{SCRIPT} --json --short-name "" ""` + - The JSON output contains BRANCH_NAME and SPEC_FILE paths. + +2. **Read the spec-template** to see the sections you need to fill. + +3. **Write the specification** to SPEC_FILE, replacing the placeholders in each section + (Overview, Requirements, Acceptance Criteria) with details from the user's description. diff --git a/presets/scaffold/preset.yml b/presets/scaffold/preset.yml new file mode 100644 index 00000000..975a92a4 --- /dev/null +++ b/presets/scaffold/preset.yml @@ -0,0 +1,91 @@ +schema_version: "1.0" + +preset: + # CUSTOMIZE: Change 'my-preset' to your preset ID (lowercase, hyphen-separated) + id: "my-preset" + + # CUSTOMIZE: Human-readable name for your preset + name: "My Preset" + + # 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 preset provides" + + # CUSTOMIZE: Your name or organization name + author: "Your Name" + + # CUSTOMIZE: GitHub repository URL (create before publishing) + repository: "https://github.com/your-org/spec-kit-preset-my-preset" + + # REVIEW: License (MIT is recommended for open source) + license: "MIT" + +# Requirements for this preset +requires: + # CUSTOMIZE: Minimum spec-kit version required + speckit_version: ">=0.1.0" + +# Templates provided by this preset +provides: + templates: + # CUSTOMIZE: Define your template overrides + # Templates are document scaffolds (spec-template.md, plan-template.md, etc.) + - type: "template" + name: "spec-template" + file: "templates/spec-template.md" + description: "Custom feature specification template" + replaces: "spec-template" # Which core template this overrides (optional) + + # ADD MORE TEMPLATES: Copy this block for each template + # - type: "template" + # name: "plan-template" + # file: "templates/plan-template.md" + # description: "Custom plan template" + # replaces: "plan-template" + + # OVERRIDE EXTENSION TEMPLATES: + # Presets sit above extensions in the resolution stack, so you can + # override templates provided by any installed extension. + # For example, if the "myext" extension provides a spec-template, + # the preset's version above will take priority automatically. + + # Override a template provided by the "myext" extension: + - type: "template" + name: "myext-template" + file: "templates/myext-template.md" + description: "Override myext's report template" + replaces: "myext-template" + + # Command overrides (AI agent workflow prompts) + # Presets can override both core and extension commands. + # Commands are automatically registered into all detected agent + # directories (.claude/commands/, .gemini/commands/, etc.) + + # Override a core command: + - type: "command" + name: "speckit.specify" + file: "commands/speckit.specify.md" + description: "Custom specification command" + replaces: "speckit.specify" + + # Override an extension command (e.g. from the "myext" extension): + - type: "command" + name: "speckit.myext.myextcmd" + file: "commands/speckit.myext.myextcmd.md" + description: "Override myext's myextcmd command with custom workflow" + replaces: "speckit.myext.myextcmd" + + # Script templates (reserved for future use) + # - type: "script" + # name: "create-new-feature" + # file: "scripts/bash/create-new-feature.sh" + # description: "Custom feature creation script" + # replaces: "create-new-feature" + +# CUSTOMIZE: Add relevant tags (2-5 recommended) +# Used for discovery in catalog +tags: + - "example" + - "preset" diff --git a/presets/scaffold/templates/myext-template.md b/presets/scaffold/templates/myext-template.md new file mode 100644 index 00000000..2b4f5a3f --- /dev/null +++ b/presets/scaffold/templates/myext-template.md @@ -0,0 +1,24 @@ +# MyExt Report + +> This template overrides the one provided by the "myext" extension. +> Customize it to match your needs. + +## Summary + +Brief summary of the report. + +## Details + +- Detail 1 +- Detail 2 + +## Actions + +- [ ] Action 1 +- [ ] Action 2 + + diff --git a/presets/scaffold/templates/spec-template.md b/presets/scaffold/templates/spec-template.md new file mode 100644 index 00000000..432bca3c --- /dev/null +++ b/presets/scaffold/templates/spec-template.md @@ -0,0 +1,18 @@ +# Feature Specification: [FEATURE NAME] + +**Created**: [DATE] +**Status**: Draft + +## Overview + +[Brief description of the feature] + +## Requirements + +- [ ] Requirement 1 +- [ ] Requirement 2 + +## Acceptance Criteria + +- [ ] Criterion 1 +- [ ] Criterion 2 diff --git a/presets/self-test/commands/speckit.specify.md b/presets/self-test/commands/speckit.specify.md new file mode 100644 index 00000000..d5e2c748 --- /dev/null +++ b/presets/self-test/commands/speckit.specify.md @@ -0,0 +1,15 @@ +--- +description: "Self-test override of the specify command" +--- + + + +You are following the self-test preset's version of the specify command. + +When creating a specification, follow this process: + +1. Read the user's requirements from $ARGUMENTS +2. Create a specification document using the spec-template +3. Include all standard sections plus the self-test marker + +> This command is provided by the self-test preset. diff --git a/presets/self-test/preset.yml b/presets/self-test/preset.yml new file mode 100644 index 00000000..82c7b068 --- /dev/null +++ b/presets/self-test/preset.yml @@ -0,0 +1,61 @@ +schema_version: "1.0" + +preset: + id: "self-test" + name: "Self-Test Preset" + version: "1.0.0" + description: "A preset that overrides all core templates for testing purposes" + author: "github" + repository: "https://github.com/github/spec-kit" + license: "MIT" + +requires: + speckit_version: ">=0.1.0" + +provides: + templates: + - type: "template" + name: "spec-template" + file: "templates/spec-template.md" + description: "Self-test spec template" + replaces: "spec-template" + + - type: "template" + name: "plan-template" + file: "templates/plan-template.md" + description: "Self-test plan template" + replaces: "plan-template" + + - type: "template" + name: "tasks-template" + file: "templates/tasks-template.md" + description: "Self-test tasks template" + replaces: "tasks-template" + + - type: "template" + name: "checklist-template" + file: "templates/checklist-template.md" + description: "Self-test checklist template" + replaces: "checklist-template" + + - type: "template" + name: "constitution-template" + file: "templates/constitution-template.md" + description: "Self-test constitution template" + replaces: "constitution-template" + + - type: "template" + name: "agent-file-template" + file: "templates/agent-file-template.md" + description: "Self-test agent file template" + replaces: "agent-file-template" + + - type: "command" + name: "speckit.specify" + file: "commands/speckit.specify.md" + description: "Self-test override of the specify command" + replaces: "speckit.specify" + +tags: + - "testing" + - "self-test" diff --git a/presets/self-test/templates/agent-file-template.md b/presets/self-test/templates/agent-file-template.md new file mode 100644 index 00000000..7b9267ba --- /dev/null +++ b/presets/self-test/templates/agent-file-template.md @@ -0,0 +1,9 @@ +# Agent File (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Agent Instructions + +Follow these guidelines when working on this project. diff --git a/presets/self-test/templates/checklist-template.md b/presets/self-test/templates/checklist-template.md new file mode 100644 index 00000000..c761eb02 --- /dev/null +++ b/presets/self-test/templates/checklist-template.md @@ -0,0 +1,15 @@ +# Checklist (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Pre-Implementation + +- [ ] Spec reviewed +- [ ] Plan approved + +## Post-Implementation + +- [ ] Tests passing +- [ ] Documentation updated diff --git a/presets/self-test/templates/constitution-template.md b/presets/self-test/templates/constitution-template.md new file mode 100644 index 00000000..0c53211f --- /dev/null +++ b/presets/self-test/templates/constitution-template.md @@ -0,0 +1,15 @@ +# Constitution (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Principles + +1. Principle 1 +2. Principle 2 + +## Guidelines + +- Guideline 1 +- Guideline 2 diff --git a/presets/self-test/templates/plan-template.md b/presets/self-test/templates/plan-template.md new file mode 100644 index 00000000..5cdaa0a4 --- /dev/null +++ b/presets/self-test/templates/plan-template.md @@ -0,0 +1,22 @@ +# Implementation Plan (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Approach + +Describe the implementation approach. + +## Steps + +1. Step 1 +2. Step 2 + +## Dependencies + +- Dependency 1 + +## Risks + +- Risk 1 diff --git a/presets/self-test/templates/spec-template.md b/presets/self-test/templates/spec-template.md new file mode 100644 index 00000000..a54956f1 --- /dev/null +++ b/presets/self-test/templates/spec-template.md @@ -0,0 +1,23 @@ +# Feature Specification (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Overview + +Brief description of the feature. + +## Requirements + +- Requirement 1 +- Requirement 2 + +## Design + +Describe the design approach. + +## Acceptance Criteria + +- [ ] Criterion 1 +- [ ] Criterion 2 diff --git a/presets/self-test/templates/tasks-template.md b/presets/self-test/templates/tasks-template.md new file mode 100644 index 00000000..80fa4c5f --- /dev/null +++ b/presets/self-test/templates/tasks-template.md @@ -0,0 +1,17 @@ +# Tasks (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Task List + +- [ ] Task 1 +- [ ] Task 2 + +## Estimation + +| Task | Estimate | +|------|----------| +| Task 1 | TBD | +| Task 2 | TBD | diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 7161f43b..52e363e6 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -175,3 +175,79 @@ json_escape() { check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } +# Resolve a template name to a file path using the priority stack: +# 1. .specify/templates/overrides/ +# 2. .specify/presets//templates/ (sorted by priority from .registry) +# 3. .specify/extensions//templates/ +# 4. .specify/templates/ (core) +resolve_template() { + local template_name="$1" + local repo_root="$2" + local base="$repo_root/.specify/templates" + + # Priority 1: Project overrides + local override="$base/overrides/${template_name}.md" + [ -f "$override" ] && echo "$override" && return 0 + + # Priority 2: Installed presets (sorted by priority from .registry) + local presets_dir="$repo_root/.specify/presets" + if [ -d "$presets_dir" ]; then + local registry_file="$presets_dir/.registry" + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + # Read preset IDs sorted by priority (lower number = higher precedence) + local sorted_presets + sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " +import json, sys, os +try: + with open(os.environ['SPECKIT_REGISTRY']) as f: + data = json.load(f) + presets = data.get('presets', {}) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)): + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null) + if [ $? -eq 0 ] && [ -n "$sorted_presets" ]; then + while IFS= read -r preset_id; do + local candidate="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done <<< "$sorted_presets" + else + # python3 returned empty list — fall through to directory scan + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + else + # Fallback: alphabetical directory order (no python3 available) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + fi + + # Priority 3: Extension-provided templates + local ext_dir="$repo_root/.specify/extensions" + if [ -d "$ext_dir" ]; then + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + # Skip hidden directories (e.g. .backup, .cache) + case "$(basename "$ext")" in .*) continue;; esac + local candidate="$ext/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + + # Priority 4: Core templates + local core="$base/${template_name}.md" + [ -f "$core" ] && echo "$core" && return 0 + + # Return success with empty output so callers using set -e don't abort; + # callers check [ -n "$TEMPLATE" ] to detect "not found". + return 0 +} + diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 725f84c8..0823cca2 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -177,6 +177,7 @@ json_escape() { # to searching for repository markers so the workflow still functions in repositories that # were initialised with --no-git. SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" if git rev-parse --show-toplevel >/dev/null 2>&1; then REPO_ROOT=$(git rev-parse --show-toplevel) @@ -307,9 +308,9 @@ fi FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" mkdir -p "$FEATURE_DIR" -TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md" +TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") SPEC_FILE="$FEATURE_DIR/spec.md" -if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi +if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi # Inform the user how to persist the feature variable in their own shell printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index 60cf372c..2a044c67 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -39,12 +39,12 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 mkdir -p "$FEATURE_DIR" # Copy plan template if it exists -TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md" -if [[ -f "$TEMPLATE" ]]; then +TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") +if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then cp "$TEMPLATE" "$IMPL_PLAN" echo "Copied plan template to $IMPL_PLAN" else - echo "Warning: Plan template not found at $TEMPLATE" + echo "Warning: Plan template not found" # Create a basic plan file if template doesn't exist touch "$IMPL_PLAN" fi diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index b0be2735..3d6a77f2 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -135,3 +135,70 @@ function Test-DirHasFiles { } } +# Resolve a template name to a file path using the priority stack: +# 1. .specify/templates/overrides/ +# 2. .specify/presets//templates/ (sorted by priority from .registry) +# 3. .specify/extensions//templates/ +# 4. .specify/templates/ (core) +function Resolve-Template { + param( + [Parameter(Mandatory=$true)][string]$TemplateName, + [Parameter(Mandatory=$true)][string]$RepoRoot + ) + + $base = Join-Path $RepoRoot '.specify/templates' + + # Priority 1: Project overrides + $override = Join-Path $base "overrides/$TemplateName.md" + if (Test-Path $override) { return $override } + + # Priority 2: Installed presets (sorted by priority from .registry) + $presetsDir = Join-Path $RepoRoot '.specify/presets' + if (Test-Path $presetsDir) { + $registryFile = Join-Path $presetsDir '.registry' + $sortedPresets = @() + if (Test-Path $registryFile) { + try { + $registryData = Get-Content $registryFile -Raw | ConvertFrom-Json + $presets = $registryData.presets + if ($presets) { + $sortedPresets = $presets.PSObject.Properties | + Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } | + ForEach-Object { $_.Name } + } + } catch { + # Fallback: alphabetical directory order + $sortedPresets = @() + } + } + + if ($sortedPresets.Count -gt 0) { + foreach ($presetId in $sortedPresets) { + $candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md" + if (Test-Path $candidate) { return $candidate } + } + } else { + # Fallback: alphabetical directory order + foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) { + $candidate = Join-Path $preset.FullName "templates/$TemplateName.md" + if (Test-Path $candidate) { return $candidate } + } + } + } + + # Priority 3: Extension-provided templates + $extDir = Join-Path $RepoRoot '.specify/extensions' + if (Test-Path $extDir) { + foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) { + $candidate = Join-Path $ext.FullName "templates/$TemplateName.md" + if (Test-Path $candidate) { return $candidate } + } + } + + # Priority 4: Core templates + $core = Join-Path $base "$TemplateName.md" + if (Test-Path $core) { return $core } + + return $null +} + diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 172b5bc7..31acbe29 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -141,6 +141,9 @@ if (-not $fallbackRoot) { exit 1 } +# Load common functions (includes Resolve-Template) +. "$PSScriptRoot/common.ps1" + try { $repoRoot = git rev-parse --show-toplevel 2>$null if ($LASTEXITCODE -eq 0) { @@ -276,9 +279,9 @@ if ($hasGit) { $featureDir = Join-Path $specsDir $branchName New-Item -ItemType Directory -Path $featureDir -Force | Out-Null -$template = Join-Path $repoRoot '.specify/templates/spec-template.md' +$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot $specFile = Join-Path $featureDir 'spec.md' -if (Test-Path $template) { +if ($template -and (Test-Path $template)) { Copy-Item $template $specFile -Force } else { New-Item -ItemType File -Path $specFile | Out-Null diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index d0ed582f..ee09094b 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -32,12 +32,12 @@ if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GI New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null # Copy plan template if it exists, otherwise note it or create empty file -$template = Join-Path $paths.REPO_ROOT '.specify/templates/plan-template.md' -if (Test-Path $template) { +$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT +if ($template -and (Test-Path $template)) { Copy-Item $template $paths.IMPL_PLAN -Force Write-Output "Copied plan template to $($paths.IMPL_PLAN)" } else { - Write-Warning "Plan template not found at $template" + Write-Warning "Plan template not found" # Create a basic plan file if template doesn't exist New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null } diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e7781b2b..a45535ae 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -34,7 +34,7 @@ import shlex import json import yaml from pathlib import Path -from typing import Optional, Tuple +from typing import Any, Optional, Tuple import typer import httpx @@ -1067,6 +1067,36 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | else: console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") + +INIT_OPTIONS_FILE = ".specify/init-options.json" + + +def save_init_options(project_path: Path, options: dict[str, Any]) -> None: + """Persist the CLI options used during ``specify init``. + + Writes a small JSON file to ``.specify/init-options.json`` so that + later operations (e.g. preset install) can adapt their behaviour + without scanning the filesystem. + """ + dest = project_path / INIT_OPTIONS_FILE + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps(options, indent=2, sort_keys=True)) + + +def load_init_options(project_path: Path) -> dict[str, Any]: + """Load the init options previously saved by ``specify init``. + + Returns an empty dict if the file does not exist or cannot be parsed. + """ + path = project_path / INIT_OPTIONS_FILE + if not path.exists(): + return {} + try: + return json.loads(path.read_text()) + except (json.JSONDecodeError, OSError): + return {} + + # Agent-specific skill directory overrides for agents whose skills directory # doesn't follow the standard /skills/ pattern AGENT_SKILLS_DIR_OVERRIDES = { @@ -1300,6 +1330,7 @@ def init( debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), + preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), ): """ Initialize a new Specify project from the latest template. @@ -1328,6 +1359,7 @@ def init( specify init my-project --ai claude --ai-skills # Install agent skills specify init --here --ai gemini --ai-skills specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent + specify init my-project --ai claude --preset healthcare-compliance # With preset """ show_banner() @@ -1589,6 +1621,50 @@ def init( else: tracker.skip("git", "--no-git flag") + # Persist the CLI options so later operations (e.g. preset add) + # can adapt their behaviour without re-scanning the filesystem. + # Must be saved BEFORE preset install so _get_skills_dir() works. + save_init_options(project_path, { + "ai": selected_ai, + "ai_skills": ai_skills, + "ai_commands_dir": ai_commands_dir, + "here": here, + "preset": preset, + "script": selected_script, + "speckit_version": get_speckit_version(), + }) + + # Install preset if specified + if preset: + try: + from .presets import PresetManager, PresetCatalog, PresetError + preset_manager = PresetManager(project_path) + speckit_ver = get_speckit_version() + + # Try local directory first, then catalog + local_path = Path(preset).resolve() + if local_path.is_dir() and (local_path / "preset.yml").exists(): + preset_manager.install_from_directory(local_path, speckit_ver) + else: + preset_catalog = PresetCatalog(project_path) + pack_info = preset_catalog.get_pack_info(preset) + if not pack_info: + console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.") + else: + try: + zip_path = preset_catalog.download_pack(preset) + preset_manager.install_from_zip(zip_path, speckit_ver) + # Clean up downloaded ZIP to avoid cache accumulation + try: + zip_path.unlink(missing_ok=True) + except OSError: + # Best-effort cleanup; failure to delete is non-fatal + pass + except PresetError as preset_err: + console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}") + except Exception as preset_err: + console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}") + tracker.complete("final", "project ready") except Exception as e: tracker.error("final", str(e)) @@ -1826,6 +1902,20 @@ catalog_app = typer.Typer( ) extension_app.add_typer(catalog_app, name="catalog") +preset_app = typer.Typer( + name="preset", + help="Manage spec-kit presets", + add_completion=False, +) +app.add_typer(preset_app, name="preset") + +preset_catalog_app = typer.Typer( + name="catalog", + help="Manage preset catalogs", + add_completion=False, +) +preset_app.add_typer(preset_catalog_app, name="catalog") + def get_speckit_version() -> str: """Get current spec-kit version.""" @@ -1848,6 +1938,490 @@ def get_speckit_version() -> str: return "unknown" +# ===== Preset Commands ===== + + +@preset_app.command("list") +def preset_list(): + """List installed presets.""" + from .presets import PresetManager + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = PresetManager(project_root) + installed = manager.list_installed() + + if not installed: + console.print("[yellow]No presets installed.[/yellow]") + console.print("\nInstall a preset with:") + console.print(" [cyan]specify preset add [/cyan]") + return + + console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n") + for pack in installed: + status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]" + pri = pack.get('priority', 10) + console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}") + console.print(f" {pack['description']}") + if pack.get("tags"): + tags_str = ", ".join(pack["tags"]) + console.print(f" [dim]Tags: {tags_str}[/dim]") + console.print(f" [dim]Templates: {pack['template_count']}[/dim]") + console.print() + + +@preset_app.command("add") +def preset_add( + pack_id: str = typer.Argument(None, help="Preset ID to install from catalog"), + from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"), + dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"), + priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), +): + """Install a preset.""" + from .presets import ( + PresetManager, + PresetCatalog, + PresetError, + PresetValidationError, + PresetCompatibilityError, + ) + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = PresetManager(project_root) + speckit_version = get_speckit_version() + + try: + if dev: + dev_path = Path(dev).resolve() + if not dev_path.exists(): + console.print(f"[red]Error:[/red] Directory not found: {dev}") + raise typer.Exit(1) + + console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...") + manifest = manager.install_from_directory(dev_path, speckit_version, priority) + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + + elif from_url: + # Validate URL scheme before downloading + from urllib.parse import urlparse as _urlparse + _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(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.") + raise typer.Exit(1) + + console.print(f"Installing preset from [cyan]{from_url}[/cyan]...") + import urllib.request + import urllib.error + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + zip_path = Path(tmpdir) / "preset.zip" + try: + with urllib.request.urlopen(from_url, timeout=60) as response: + zip_path.write_bytes(response.read()) + except urllib.error.URLError as e: + console.print(f"[red]Error:[/red] Failed to download: {e}") + raise typer.Exit(1) + + manifest = manager.install_from_zip(zip_path, speckit_version, priority) + + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + + elif pack_id: + catalog = PresetCatalog(project_root) + pack_info = catalog.get_pack_info(pack_id) + + if not pack_info: + console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog") + raise typer.Exit(1) + + if not pack_info.get("_install_allowed", True): + catalog_name = pack_info.get("_catalog_name", "unknown") + console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") + console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.") + raise typer.Exit(1) + + console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...") + + try: + zip_path = catalog.download_pack(pack_id) + manifest = manager.install_from_zip(zip_path, speckit_version, priority) + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + finally: + if 'zip_path' in locals() and zip_path.exists(): + zip_path.unlink(missing_ok=True) + else: + console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path") + raise typer.Exit(1) + + except PresetCompatibilityError as e: + console.print(f"[red]Compatibility Error:[/red] {e}") + raise typer.Exit(1) + except PresetValidationError as e: + console.print(f"[red]Validation Error:[/red] {e}") + raise typer.Exit(1) + except PresetError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + +@preset_app.command("remove") +def preset_remove( + pack_id: str = typer.Argument(..., help="Preset ID to remove"), +): + """Remove an installed preset.""" + from .presets import PresetManager + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = PresetManager(project_root) + + if not manager.registry.is_installed(pack_id): + console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed") + raise typer.Exit(1) + + if manager.remove(pack_id): + console.print(f"[green]✓[/green] Preset '{pack_id}' removed successfully") + else: + console.print(f"[red]Error:[/red] Failed to remove preset '{pack_id}'") + raise typer.Exit(1) + + +@preset_app.command("search") +def preset_search( + query: str = typer.Argument(None, help="Search query"), + tag: str = typer.Option(None, "--tag", help="Filter by tag"), + author: str = typer.Option(None, "--author", help="Filter by author"), +): + """Search for presets in the catalog.""" + from .presets import PresetCatalog, PresetError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + catalog = PresetCatalog(project_root) + + try: + results = catalog.search(query=query, tag=tag, author=author) + except PresetError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + if not results: + console.print("[yellow]No presets found matching your criteria.[/yellow]") + return + + console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n") + for pack in results: + console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}") + console.print(f" {pack.get('description', '')}") + if pack.get("tags"): + tags_str = ", ".join(pack["tags"]) + console.print(f" [dim]Tags: {tags_str}[/dim]") + console.print() + + +@preset_app.command("resolve") +def preset_resolve( + template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"), +): + """Show which template will be resolved for a given name.""" + from .presets import PresetResolver + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + resolver = PresetResolver(project_root) + result = resolver.resolve_with_source(template_name) + + if result: + console.print(f" [bold]{template_name}[/bold]: {result['path']}") + console.print(f" [dim](from: {result['source']})[/dim]") + else: + console.print(f" [yellow]{template_name}[/yellow]: not found") + console.print(" [dim]No template with this name exists in the resolution stack[/dim]") + + +@preset_app.command("info") +def preset_info( + pack_id: str = typer.Argument(..., help="Preset ID to get info about"), +): + """Show detailed information about a preset.""" + from .presets import PresetCatalog, PresetManager, PresetError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Check if installed locally first + manager = PresetManager(project_root) + local_pack = manager.get_pack(pack_id) + + if local_pack: + console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n") + console.print(f" ID: {local_pack.id}") + console.print(f" Version: {local_pack.version}") + console.print(f" Description: {local_pack.description}") + if local_pack.author: + console.print(f" Author: {local_pack.author}") + if local_pack.tags: + console.print(f" Tags: {', '.join(local_pack.tags)}") + console.print(f" Templates: {len(local_pack.templates)}") + for tmpl in local_pack.templates: + console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}") + repo = local_pack.data.get("preset", {}).get("repository") + if repo: + console.print(f" Repository: {repo}") + license_val = local_pack.data.get("preset", {}).get("license") + if license_val: + console.print(f" License: {license_val}") + console.print("\n [green]Status: installed[/green]") + console.print() + return + + # Fall back to catalog + catalog = PresetCatalog(project_root) + try: + pack_info = catalog.get_pack_info(pack_id) + except PresetError: + pack_info = None + + if not pack_info: + console.print(f"[red]Error:[/red] Preset '{pack_id}' not found (not installed and not in catalog)") + raise typer.Exit(1) + + console.print(f"\n[bold cyan]Preset: {pack_info.get('name', pack_id)}[/bold cyan]\n") + console.print(f" ID: {pack_info['id']}") + console.print(f" Version: {pack_info.get('version', '?')}") + console.print(f" Description: {pack_info.get('description', '')}") + if pack_info.get("author"): + console.print(f" Author: {pack_info['author']}") + if pack_info.get("tags"): + console.print(f" Tags: {', '.join(pack_info['tags'])}") + if pack_info.get("repository"): + console.print(f" Repository: {pack_info['repository']}") + if pack_info.get("license"): + console.print(f" License: {pack_info['license']}") + console.print("\n [yellow]Status: not installed[/yellow]") + console.print(f" Install with: [cyan]specify preset add {pack_id}[/cyan]") + console.print() + + +# ===== Preset Catalog Commands ===== + + +@preset_catalog_app.command("list") +def preset_catalog_list(): + """List all active preset catalogs.""" + from .presets import PresetCatalog, PresetValidationError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + catalog = PresetCatalog(project_root) + + try: + active_catalogs = catalog.get_active_catalogs() + except PresetValidationError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n") + for entry in active_catalogs: + install_str = ( + "[green]install allowed[/green]" + if entry.install_allowed + else "[yellow]discovery only[/yellow]" + ) + console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})") + if entry.description: + console.print(f" {entry.description}") + console.print(f" URL: {entry.url}") + console.print(f" Install: {install_str}") + console.print() + + config_path = project_root / ".specify" / "preset-catalogs.yml" + user_config_path = Path.home() / ".specify" / "preset-catalogs.yml" + if os.environ.get("SPECKIT_PRESET_CATALOG_URL"): + console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]") + else: + try: + proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None + except PresetValidationError: + proj_loaded = False + if proj_loaded: + console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]") + else: + try: + user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None + except PresetValidationError: + user_loaded = False + if user_loaded: + console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]") + else: + console.print("[dim]Using built-in default catalog stack.[/dim]") + console.print( + "[dim]Add .specify/preset-catalogs.yml to customize.[/dim]" + ) + + +@preset_catalog_app.command("add") +def preset_catalog_add( + url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), + name: str = typer.Option(..., "--name", help="Catalog name"), + priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), + install_allowed: bool = typer.Option( + False, "--install-allowed/--no-install-allowed", + help="Allow presets from this catalog to be installed", + ), + description: str = typer.Option("", "--description", help="Description of the catalog"), +): + """Add a catalog to .specify/preset-catalogs.yml.""" + from .presets import PresetCatalog, PresetValidationError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Validate URL + tmp_catalog = PresetCatalog(project_root) + try: + tmp_catalog._validate_catalog_url(url) + except PresetValidationError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + config_path = specify_dir / "preset-catalogs.yml" + + # Load existing config + if config_path.exists(): + try: + config = yaml.safe_load(config_path.read_text()) or {} + except Exception as e: + console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}") + raise typer.Exit(1) + else: + config = {} + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") + raise typer.Exit(1) + + # Check for duplicate name + for existing in catalogs: + if isinstance(existing, dict) and existing.get("name") == name: + console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.") + console.print("Use 'specify preset catalog remove' first, or choose a different name.") + raise typer.Exit(1) + + catalogs.append({ + "name": name, + "url": url, + "priority": priority, + "install_allowed": install_allowed, + "description": description, + }) + + config["catalogs"] = catalogs + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) + + install_label = "install allowed" if install_allowed else "discovery only" + console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") + console.print(f" URL: {url}") + console.print(f" Priority: {priority}") + console.print(f"\nConfig saved to {config_path.relative_to(project_root)}") + + +@preset_catalog_app.command("remove") +def preset_catalog_remove( + name: str = typer.Argument(help="Catalog name to remove"), +): + """Remove a catalog from .specify/preset-catalogs.yml.""" + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + config_path = specify_dir / "preset-catalogs.yml" + if not config_path.exists(): + console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.") + raise typer.Exit(1) + + try: + config = yaml.safe_load(config_path.read_text()) or {} + except Exception: + console.print("[red]Error:[/red] Failed to read preset catalog config.") + raise typer.Exit(1) + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") + raise typer.Exit(1) + original_count = len(catalogs) + catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name] + + if len(catalogs) == original_count: + console.print(f"[red]Error:[/red] Catalog '{name}' not found.") + raise typer.Exit(1) + + config["catalogs"] = catalogs + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) + + console.print(f"[green]✓[/green] Removed catalog '{name}'") + if not catalogs: + console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]") + + +# ===== Extension Commands ===== + + def _resolve_installed_extension( argument: str, installed_extensions: list, diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py new file mode 100644 index 00000000..9927daee --- /dev/null +++ b/src/specify_cli/agents.py @@ -0,0 +1,422 @@ +""" +Agent Command Registrar for Spec Kit + +Shared infrastructure for registering commands with AI agents. +Used by both the extension system and the preset system to write +command files into agent-specific directories in the correct format. +""" + +from pathlib import Path +from typing import Dict, List, Any + +import yaml + + +class CommandRegistrar: + """Handles registration of commands with AI agents. + + Supports writing command files in Markdown or TOML format to the + appropriate agent directory, with correct argument placeholders + and companion files (e.g. Copilot .prompt.md). + """ + + # 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": ".agent.md" + }, + "cursor": { + "dir": ".cursor/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "qwen": { + "dir": ".qwen/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "opencode": { + "dir": ".opencode/command", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "codex": { + "dir": ".codex/prompts", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "windsurf": { + "dir": ".windsurf/workflows", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "kilocode": { + "dir": ".kilocode/workflows", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "auggie": { + "dir": ".augment/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "roo": { + "dir": ".roo/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "codebuddy": { + "dir": ".codebuddy/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "qodercli": { + "dir": ".qoder/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "kiro-cli": { + "dir": ".kiro/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" + }, + "tabnine": { + "dir": ".tabnine/agent/commands", + "format": "toml", + "args": "{{args}}", + "extension": ".toml" + }, + "bob": { + "dir": ".bob/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md" + }, + "kimi": { + "dir": ".kimi/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.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, + source_id: str, + context_note: str = None + ) -> str: + """Render command in Markdown format. + + Args: + frontmatter: Command frontmatter + body: Command body content + source_id: Source identifier (extension or preset ID) + context_note: Custom context comment (default: ) + + Returns: + Formatted Markdown command file content + """ + if context_note is None: + context_note = f"\n\n" + return self.render_frontmatter(frontmatter) + "\n" + context_note + body + + def render_toml_command( + self, + frontmatter: dict, + body: str, + source_id: str + ) -> str: + """Render command in TOML format. + + Args: + frontmatter: Command frontmatter + body: Command body content + source_id: Source identifier (extension or preset ID) + + Returns: + Formatted TOML command file content + """ + toml_lines = [] + + if "description" in frontmatter: + desc = frontmatter["description"].replace('"', '\\"') + toml_lines.append(f'description = "{desc}"') + toml_lines.append("") + + toml_lines.append(f"# Source: {source_id}") + toml_lines.append("") + + 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( + self, + agent_name: str, + commands: List[Dict[str, Any]], + source_id: str, + source_dir: Path, + project_root: Path, + context_note: str = None + ) -> List[str]: + """Register commands for a specific agent. + + Args: + agent_name: Agent name (claude, gemini, copilot, etc.) + commands: List of command info dicts with 'name', 'file', and optional 'aliases' + source_id: Identifier of the source (extension or preset ID) + source_dir: Directory containing command source files + project_root: Path to project root + context_note: Custom context comment for markdown output + + Returns: + List of registered command names + + Raises: + ValueError: If agent is not supported + """ + if agent_name not in self.AGENT_CONFIGS: + raise ValueError(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 commands: + cmd_name = cmd_info["name"] + cmd_file = cmd_info["file"] + + source_file = source_dir / cmd_file + if not source_file.exists(): + continue + + content = source_file.read_text(encoding="utf-8") + frontmatter, body = self.parse_frontmatter(content) + + frontmatter = self._adjust_script_paths(frontmatter) + + body = self._convert_argument_placeholder( + body, "$ARGUMENTS", agent_config["args"] + ) + + if agent_config["format"] == "markdown": + output = self.render_markdown_command(frontmatter, body, source_id, context_note) + elif agent_config["format"] == "toml": + output = self.render_toml_command(frontmatter, body, source_id) + else: + raise ValueError(f"Unsupported format: {agent_config['format']}") + + dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}" + dest_file.parent.mkdir(parents=True, exist_ok=True) + dest_file.write_text(output, encoding="utf-8") + + if agent_name == "copilot": + self.write_copilot_prompt(project_root, cmd_name) + + registered.append(cmd_name) + + for alias in cmd_info.get("aliases", []): + alias_file = commands_dir / f"{alias}{agent_config['extension']}" + alias_file.parent.mkdir(parents=True, exist_ok=True) + alias_file.write_text(output, encoding="utf-8") + if agent_name == "copilot": + self.write_copilot_prompt(project_root, alias) + registered.append(alias) + + return registered + + @staticmethod + def write_copilot_prompt(project_root: Path, cmd_name: str) -> None: + """Generate a companion .prompt.md file for a Copilot agent command. + + Args: + project_root: Path to project root + cmd_name: Command name (e.g. 'speckit.my-ext.example') + """ + prompts_dir = project_root / ".github" / "prompts" + prompts_dir.mkdir(parents=True, exist_ok=True) + prompt_file = prompts_dir / f"{cmd_name}.prompt.md" + prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8") + + def register_commands_for_all_agents( + self, + commands: List[Dict[str, Any]], + source_id: str, + source_dir: Path, + project_root: Path, + context_note: str = None + ) -> Dict[str, List[str]]: + """Register commands for all detected agents in the project. + + Args: + commands: List of command info dicts + source_id: Identifier of the source (extension or preset ID) + source_dir: Directory containing command source files + project_root: Path to project root + context_note: Custom context comment for markdown output + + Returns: + Dictionary mapping agent names to list of registered commands + """ + results = {} + + for agent_name, agent_config in self.AGENT_CONFIGS.items(): + agent_dir = project_root / agent_config["dir"].split("/")[0] + + if agent_dir.exists(): + try: + registered = self.register_commands( + agent_name, commands, source_id, source_dir, project_root, + context_note=context_note + ) + if registered: + results[agent_name] = registered + except ValueError: + continue + + return results + + def unregister_commands( + self, + registered_commands: Dict[str, List[str]], + project_root: Path + ) -> None: + """Remove previously registered command files from agent directories. + + Args: + registered_commands: Dict mapping agent names to command name lists + project_root: Path to project root + """ + for agent_name, cmd_names in registered_commands.items(): + if agent_name not in self.AGENT_CONFIGS: + continue + + agent_config = self.AGENT_CONFIGS[agent_name] + commands_dir = 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 agent_name == "copilot": + prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + if prompt_file.exists(): + prompt_file.unlink() diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 156daff6..0dfd40b7 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -578,23 +578,7 @@ class ExtensionManager: # 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() - - # Also remove companion .prompt.md for Copilot - if agent_name == "copilot": - prompt_file = self.project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" - if prompt_file.exists(): - prompt_file.unlink() + registrar.unregister_commands(registered_commands, self.project_root) if keep_config: # Preserve config files, only remove non-config files @@ -718,255 +702,47 @@ def version_satisfies(current: str, required: str) -> bool: class CommandRegistrar: - """Handles registration of extension commands with AI agents.""" + """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": ".agent.md" - }, - "cursor": { - "dir": ".cursor/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "qwen": { - "dir": ".qwen/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "opencode": { - "dir": ".opencode/command", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "codex": { - "dir": ".codex/prompts", - "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/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "codebuddy": { - "dir": ".codebuddy/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "qodercli": { - "dir": ".qoder/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "kiro-cli": { - "dir": ".kiro/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" - }, - "tabnine": { - "dir": ".tabnine/agent/commands", - "format": "toml", - "args": "{{args}}", - "extension": ".toml" - }, - "bob": { - "dir": ".bob/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "kimi": { - "dir": ".kimi/skills", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": "/SKILL.md" - } - } + This is a backward-compatible wrapper around the shared CommandRegistrar + in agents.py. Extension-specific methods accept ExtensionManifest objects + and delegate to the generic API. + """ + # Re-export AGENT_CONFIGS at class level for direct attribute access + from .agents import CommandRegistrar as _AgentRegistrar + AGENT_CONFIGS = _AgentRegistrar.AGENT_CONFIGS + + def __init__(self): + from .agents import CommandRegistrar as _Registrar + self._registrar = _Registrar() + + # Delegate static/utility methods @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 + from .agents import CommandRegistrar as _Registrar + return _Registrar.parse_frontmatter(content) @staticmethod def render_frontmatter(fm: dict) -> str: - """Render frontmatter dictionary as YAML. + from .agents import CommandRegistrar as _Registrar + return _Registrar.render_frontmatter(fm) - Args: - fm: Frontmatter dictionary + @staticmethod + def _write_copilot_prompt(project_root, cmd_name: str) -> None: + from .agents import CommandRegistrar as _Registrar + _Registrar.write_copilot_prompt(project_root, cmd_name) - 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 - """ + def _render_markdown_command(self, frontmatter, body, ext_id): + # Preserve extension-specific comment format for backward compatibility context_note = f"\n\n\n" - return self.render_frontmatter(frontmatter) + "\n" + context_note + body + return self._registrar.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 _render_toml_command(self, frontmatter, body, ext_id): + # Preserve extension-specific context comments for backward compatibility + base = self._registrar.render_toml_command(frontmatter, body, ext_id) + context_lines = f"# Extension: {ext_id}\n# Config: .specify/extensions/{ext_id}/\n" + return base.rstrip("\n") + "\n" + context_lines def register_commands_for_agent( self, @@ -975,96 +751,14 @@ class CommandRegistrar: 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 - """ + """Register extension commands for a specific agent.""" 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.parent.mkdir(parents=True, exist_ok=True) - dest_file.write_text(output) - - # Generate companion .prompt.md for Copilot agents - if agent_name == "copilot": - self._write_copilot_prompt(project_root, cmd_name) - - registered.append(cmd_name) - - # Register aliases - for alias in cmd_info.get("aliases", []): - alias_file = commands_dir / f"{alias}{agent_config['extension']}" - alias_file.parent.mkdir(parents=True, exist_ok=True) - alias_file.write_text(output) - # Generate companion .prompt.md for alias too - if agent_name == "copilot": - self._write_copilot_prompt(project_root, alias) - registered.append(alias) - - return registered - - @staticmethod - def _write_copilot_prompt(project_root: Path, cmd_name: str) -> None: - """Generate a companion .prompt.md file for a Copilot agent command. - - Copilot requires a .prompt.md file in .github/prompts/ that references - the corresponding .agent.md file in .github/agents/ via an ``agent:`` - frontmatter field. - - Args: - project_root: Path to project root - cmd_name: Command name (used as the file stem, e.g. 'speckit.my-ext.example') - """ - prompts_dir = project_root / ".github" / "prompts" - prompts_dir.mkdir(parents=True, exist_ok=True) - prompt_file = prompts_dir / f"{cmd_name}.prompt.md" - prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n") + context_note = f"\n\n\n" + return self._registrar.register_commands( + agent_name, manifest.commands, manifest.id, extension_dir, project_root, + context_note=context_note + ) def register_commands_for_all_agents( self, @@ -1072,35 +766,20 @@ class CommandRegistrar: extension_dir: Path, project_root: Path ) -> Dict[str, List[str]]: - """Register extension commands for all detected agents. + """Register extension commands for all detected agents.""" + context_note = f"\n\n\n" + return self._registrar.register_commands_for_all_agents( + manifest.commands, manifest.id, extension_dir, project_root, + context_note=context_note + ) - 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 unregister_commands( + self, + registered_commands: Dict[str, List[str]], + project_root: Path + ) -> None: + """Remove previously registered command files from agent directories.""" + self._registrar.unregister_commands(registered_commands, project_root) def register_commands_for_claude( self, @@ -1108,16 +787,7 @@ class CommandRegistrar: 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 - """ + """Register extension commands for Claude Code agent.""" return self.register_commands_for_agent("claude", manifest, extension_dir, project_root) diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py new file mode 100644 index 00000000..15196337 --- /dev/null +++ b/src/specify_cli/presets.py @@ -0,0 +1,1530 @@ +""" +Preset Manager for Spec Kit + +Handles installation, removal, and management of Spec Kit presets. +Presets are self-contained, versioned collections of templates +(artifact, command, and script templates) that can be installed to +customize the Spec-Driven Development workflow. +""" + +import json +import hashlib +import os +import tempfile +import zipfile +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Dict, List, Any +from datetime import datetime, timezone +import re + +import yaml +from packaging import version as pkg_version +from packaging.specifiers import SpecifierSet, InvalidSpecifier + + +@dataclass +class PresetCatalogEntry: + """Represents a single entry in the preset catalog stack.""" + url: str + name: str + priority: int + install_allowed: bool + description: str = "" + + +class PresetError(Exception): + """Base exception for preset-related errors.""" + pass + + +class PresetValidationError(PresetError): + """Raised when preset manifest validation fails.""" + pass + + +class PresetCompatibilityError(PresetError): + """Raised when preset is incompatible with current environment.""" + pass + + +VALID_PRESET_TEMPLATE_TYPES = {"template", "command", "script"} + + +class PresetManifest: + """Represents and validates a preset manifest (preset.yml).""" + + SCHEMA_VERSION = "1.0" + REQUIRED_FIELDS = ["schema_version", "preset", "requires", "provides"] + + def __init__(self, manifest_path: Path): + """Load and validate preset manifest. + + Args: + manifest_path: Path to preset.yml file + + Raises: + PresetValidationError: 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 PresetValidationError(f"Invalid YAML in {path}: {e}") + except FileNotFoundError: + raise PresetValidationError(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 PresetValidationError(f"Missing required field: {field}") + + # Validate schema version + if self.data["schema_version"] != self.SCHEMA_VERSION: + raise PresetValidationError( + f"Unsupported schema version: {self.data['schema_version']} " + f"(expected {self.SCHEMA_VERSION})" + ) + + # Validate preset metadata + pack = self.data["preset"] + for field in ["id", "name", "version", "description"]: + if field not in pack: + raise PresetValidationError(f"Missing preset.{field}") + + # Validate pack ID format + if not re.match(r'^[a-z0-9-]+$', pack["id"]): + raise PresetValidationError( + f"Invalid preset ID '{pack['id']}': " + "must be lowercase alphanumeric with hyphens only" + ) + + # Validate semantic version + try: + pkg_version.Version(pack["version"]) + except pkg_version.InvalidVersion: + raise PresetValidationError(f"Invalid version: {pack['version']}") + + # Validate requires section + requires = self.data["requires"] + if "speckit_version" not in requires: + raise PresetValidationError("Missing requires.speckit_version") + + # Validate provides section + provides = self.data["provides"] + if "templates" not in provides or not provides["templates"]: + raise PresetValidationError( + "Preset must provide at least one template" + ) + + # Validate templates + for tmpl in provides["templates"]: + if "type" not in tmpl or "name" not in tmpl or "file" not in tmpl: + raise PresetValidationError( + "Template missing 'type', 'name', or 'file'" + ) + + if tmpl["type"] not in VALID_PRESET_TEMPLATE_TYPES: + raise PresetValidationError( + f"Invalid template type '{tmpl['type']}': " + f"must be one of {sorted(VALID_PRESET_TEMPLATE_TYPES)}" + ) + + # Validate file path safety: must be relative, no parent traversal + file_path = tmpl["file"] + normalized = os.path.normpath(file_path) + if os.path.isabs(normalized) or normalized.startswith(".."): + raise PresetValidationError( + f"Invalid template file path '{file_path}': " + "must be a relative path within the preset directory" + ) + + # Validate template name format + if tmpl["type"] == "command": + # Commands use dot notation (e.g. speckit.specify) + if not re.match(r'^[a-z0-9.-]+$', tmpl["name"]): + raise PresetValidationError( + f"Invalid command name '{tmpl['name']}': " + "must be lowercase alphanumeric with hyphens and dots only" + ) + else: + if not re.match(r'^[a-z0-9-]+$', tmpl["name"]): + raise PresetValidationError( + f"Invalid template name '{tmpl['name']}': " + "must be lowercase alphanumeric with hyphens only" + ) + + @property + def id(self) -> str: + """Get preset ID.""" + return self.data["preset"]["id"] + + @property + def name(self) -> str: + """Get preset name.""" + return self.data["preset"]["name"] + + @property + def version(self) -> str: + """Get preset version.""" + return self.data["preset"]["version"] + + @property + def description(self) -> str: + """Get preset description.""" + return self.data["preset"]["description"] + + @property + def author(self) -> str: + """Get preset author.""" + return self.data["preset"].get("author", "") + + @property + def requires_speckit_version(self) -> str: + """Get required spec-kit version range.""" + return self.data["requires"]["speckit_version"] + + @property + def templates(self) -> List[Dict[str, Any]]: + """Get list of provided templates.""" + return self.data["provides"]["templates"] + + @property + def tags(self) -> List[str]: + """Get preset tags.""" + return self.data.get("tags", []) + + 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 PresetRegistry: + """Manages the registry of installed presets.""" + + REGISTRY_FILE = ".registry" + SCHEMA_VERSION = "1.0" + + def __init__(self, packs_dir: Path): + """Initialize registry. + + Args: + packs_dir: Path to .specify/presets/ directory + """ + self.packs_dir = packs_dir + self.registry_path = packs_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, + "presets": {} + } + + try: + with open(self.registry_path, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + return { + "schema_version": self.SCHEMA_VERSION, + "presets": {} + } + + def _save(self): + """Save registry to disk.""" + self.packs_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, pack_id: str, metadata: dict): + """Add preset to registry. + + Args: + pack_id: Preset ID + metadata: Pack metadata (version, source, etc.) + """ + self.data["presets"][pack_id] = { + **metadata, + "installed_at": datetime.now(timezone.utc).isoformat() + } + self._save() + + def remove(self, pack_id: str): + """Remove preset from registry. + + Args: + pack_id: Preset ID + """ + if pack_id in self.data["presets"]: + del self.data["presets"][pack_id] + self._save() + + def get(self, pack_id: str) -> Optional[dict]: + """Get preset metadata from registry. + + Args: + pack_id: Preset ID + + Returns: + Pack metadata or None if not found + """ + return self.data["presets"].get(pack_id) + + def list(self) -> Dict[str, dict]: + """Get all installed presets. + + Returns: + Dictionary of pack_id -> metadata + """ + return self.data["presets"] + + def list_by_priority(self) -> List[tuple]: + """Get all installed presets sorted by priority. + + Lower priority number = higher precedence (checked first). + + Returns: + List of (pack_id, metadata) tuples sorted by priority + """ + packs = self.data["presets"] + return sorted( + packs.items(), + key=lambda item: item[1].get("priority", 10), + ) + + def is_installed(self, pack_id: str) -> bool: + """Check if preset is installed. + + Args: + pack_id: Preset ID + + Returns: + True if pack is installed + """ + return pack_id in self.data["presets"] + + +class PresetManager: + """Manages preset lifecycle: installation, removal, updates.""" + + def __init__(self, project_root: Path): + """Initialize preset manager. + + Args: + project_root: Path to project root directory + """ + self.project_root = project_root + self.presets_dir = project_root / ".specify" / "presets" + self.registry = PresetRegistry(self.presets_dir) + + def check_compatibility( + self, + manifest: PresetManifest, + speckit_version: str + ) -> bool: + """Check if preset is compatible with current spec-kit version. + + Args: + manifest: Preset manifest + speckit_version: Current spec-kit version + + Returns: + True if compatible + + Raises: + PresetCompatibilityError: If pack is incompatible + """ + required = manifest.requires_speckit_version + current = pkg_version.Version(speckit_version) + + try: + specifier = SpecifierSet(required) + if current not in specifier: + raise PresetCompatibilityError( + f"Preset 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 PresetCompatibilityError( + f"Invalid version specifier: {required}" + ) + + return True + + def _register_commands( + self, + manifest: PresetManifest, + preset_dir: Path + ) -> Dict[str, List[str]]: + """Register preset command overrides with all detected AI agents. + + Scans the preset's templates for type "command", reads each command + file, and writes it to every detected agent directory using the + CommandRegistrar from the agents module. + + Args: + manifest: Preset manifest + preset_dir: Installed preset directory + + Returns: + Dictionary mapping agent names to lists of registered command names + """ + command_templates = [ + t for t in manifest.templates if t.get("type") == "command" + ] + if not command_templates: + return {} + + # Filter out extension command overrides if the extension isn't installed. + # Command names follow the pattern: speckit.. + # Core commands (e.g. speckit.specify) have only one dot — always register. + extensions_dir = self.project_root / ".specify" / "extensions" + filtered = [] + for cmd in command_templates: + parts = cmd["name"].split(".") + if len(parts) >= 3 and parts[0] == "speckit": + ext_id = parts[1] + if not (extensions_dir / ext_id).is_dir(): + continue + filtered.append(cmd) + + if not filtered: + return {} + + try: + from .agents import CommandRegistrar + except ImportError: + return {} + + registrar = CommandRegistrar() + return registrar.register_commands_for_all_agents( + filtered, manifest.id, preset_dir, self.project_root + ) + + def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> None: + """Remove previously registered command files from agent directories. + + Args: + registered_commands: Dict mapping agent names to command name lists + """ + try: + from .agents import CommandRegistrar + except ImportError: + return + + registrar = CommandRegistrar() + registrar.unregister_commands(registered_commands, self.project_root) + + def _get_skills_dir(self) -> Optional[Path]: + """Return the skills directory if ``--ai-skills`` was used during init. + + Reads ``.specify/init-options.json`` to determine whether skills + are enabled and which agent was selected, then delegates to + the module-level ``_get_skills_dir()`` helper for the concrete path. + + Returns: + The skills directory ``Path``, or ``None`` if skills were not + enabled or the init-options file is missing. + """ + from . import load_init_options, _get_skills_dir + + opts = load_init_options(self.project_root) + if not opts.get("ai_skills"): + return None + + agent = opts.get("ai") + if not agent: + return None + + skills_dir = _get_skills_dir(self.project_root, agent) + if not skills_dir.is_dir(): + return None + + return skills_dir + + def _register_skills( + self, + manifest: "PresetManifest", + preset_dir: Path, + ) -> List[str]: + """Generate SKILL.md files for preset command overrides. + + For every command template in the preset, checks whether a + corresponding skill already exists in any detected skills + directory. If so, the skill is overwritten with content derived + from the preset's command file. This ensures that presets that + override commands also propagate to the agentskills.io skill + layer when ``--ai-skills`` was used during project initialisation. + + Args: + manifest: Preset manifest. + preset_dir: Installed preset directory. + + Returns: + List of skill names that were written (for registry storage). + """ + command_templates = [ + t for t in manifest.templates if t.get("type") == "command" + ] + if not command_templates: + return [] + + # Filter out extension command overrides if the extension isn't installed, + # matching the same logic used by _register_commands(). + extensions_dir = self.project_root / ".specify" / "extensions" + filtered = [] + for cmd in command_templates: + parts = cmd["name"].split(".") + if len(parts) >= 3 and parts[0] == "speckit": + ext_id = parts[1] + if not (extensions_dir / ext_id).is_dir(): + continue + filtered.append(cmd) + + if not filtered: + return [] + + skills_dir = self._get_skills_dir() + if not skills_dir: + return [] + + from . import SKILL_DESCRIPTIONS, load_init_options + + opts = load_init_options(self.project_root) + selected_ai = opts.get("ai", "") + + written: List[str] = [] + + for cmd_tmpl in filtered: + cmd_name = cmd_tmpl["name"] + cmd_file_rel = cmd_tmpl["file"] + source_file = preset_dir / cmd_file_rel + if not source_file.exists(): + continue + + # Derive the short command name (e.g. "specify" from "speckit.specify") + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + # Kimi CLI discovers skills by directory name and invokes them as + # /skill: — use dot separator to match packaging convention. + if selected_ai == "kimi": + skill_name = f"speckit.{short_name}" + else: + skill_name = f"speckit-{short_name}" + + # Only overwrite if the skill already exists (i.e. --ai-skills was used) + skill_subdir = skills_dir / skill_name + if not skill_subdir.exists(): + continue + + # Parse the command file + content = source_file.read_text(encoding="utf-8") + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + frontmatter = yaml.safe_load(parts[1]) + if not isinstance(frontmatter, dict): + frontmatter = {} + body = parts[2].strip() + else: + frontmatter = {} + body = content + else: + frontmatter = {} + body = content + + original_desc = frontmatter.get("description", "") + enhanced_desc = SKILL_DESCRIPTIONS.get( + short_name, + original_desc or f"Spec-kit workflow command: {short_name}", + ) + + frontmatter_data = { + "name": skill_name, + "description": enhanced_desc, + "compatibility": "Requires spec-kit project structure with .specify/ directory", + "metadata": { + "author": "github-spec-kit", + "source": f"preset:{manifest.id}", + }, + } + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# Speckit {short_name.title()} Skill\n\n" + f"{body}\n" + ) + + skill_file = skill_subdir / "SKILL.md" + skill_file.write_text(skill_content, encoding="utf-8") + written.append(skill_name) + + return written + + def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: + """Restore original SKILL.md files after a preset is removed. + + For each skill that was overridden by the preset, attempts to + regenerate the skill from the core command template. If no core + template exists, the skill directory is removed. + + Args: + skill_names: List of skill names written by the preset. + preset_dir: The preset's installed directory (may already be deleted). + """ + if not skill_names: + return + + skills_dir = self._get_skills_dir() + if not skills_dir: + return + + from . import SKILL_DESCRIPTIONS + + # Locate core command templates from the project's installed templates + core_templates_dir = self.project_root / ".specify" / "templates" / "commands" + + for skill_name in skill_names: + # Derive command name from skill name (speckit-specify -> specify) + short_name = skill_name + if short_name.startswith("speckit-"): + short_name = short_name[len("speckit-"):] + elif short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + + skill_subdir = skills_dir / skill_name + skill_file = skill_subdir / "SKILL.md" + if not skill_file.exists(): + continue + + # Try to find the core command template + core_file = core_templates_dir / f"{short_name}.md" if core_templates_dir.exists() else None + if core_file and not core_file.exists(): + core_file = None + + if core_file: + # Restore from core template + content = core_file.read_text(encoding="utf-8") + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + frontmatter = yaml.safe_load(parts[1]) + if not isinstance(frontmatter, dict): + frontmatter = {} + body = parts[2].strip() + else: + frontmatter = {} + body = content + else: + frontmatter = {} + body = content + + original_desc = frontmatter.get("description", "") + enhanced_desc = SKILL_DESCRIPTIONS.get( + short_name, + original_desc or f"Spec-kit workflow command: {short_name}", + ) + + frontmatter_data = { + "name": skill_name, + "description": enhanced_desc, + "compatibility": "Requires spec-kit project structure with .specify/ directory", + "metadata": { + "author": "github-spec-kit", + "source": f"templates/commands/{short_name}.md", + }, + } + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# Speckit {short_name.title()} Skill\n\n" + f"{body}\n" + ) + skill_file.write_text(skill_content, encoding="utf-8") + else: + # No core template — remove the skill entirely + shutil.rmtree(skill_subdir) + + def install_from_directory( + self, + source_dir: Path, + speckit_version: str, + priority: int = 10, + ) -> PresetManifest: + """Install preset from a local directory. + + Args: + source_dir: Path to preset directory + speckit_version: Current spec-kit version + priority: Resolution priority (lower = higher precedence, default 10) + + Returns: + Installed preset manifest + + Raises: + PresetValidationError: If manifest is invalid + PresetCompatibilityError: If pack is incompatible + """ + manifest_path = source_dir / "preset.yml" + manifest = PresetManifest(manifest_path) + + self.check_compatibility(manifest, speckit_version) + + if self.registry.is_installed(manifest.id): + raise PresetError( + f"Preset '{manifest.id}' is already installed. " + f"Use 'specify preset remove {manifest.id}' first." + ) + + dest_dir = self.presets_dir / manifest.id + if dest_dir.exists(): + shutil.rmtree(dest_dir) + + shutil.copytree(source_dir, dest_dir) + + # Register command overrides with AI agents + registered_commands = self._register_commands(manifest, dest_dir) + + # Update corresponding skills when --ai-skills was previously used + registered_skills = self._register_skills(manifest, dest_dir) + + self.registry.add(manifest.id, { + "version": manifest.version, + "source": "local", + "manifest_hash": manifest.get_hash(), + "enabled": True, + "priority": priority, + "registered_commands": registered_commands, + "registered_skills": registered_skills, + }) + + return manifest + + def install_from_zip( + self, + zip_path: Path, + speckit_version: str, + priority: int = 10, + ) -> PresetManifest: + """Install preset from ZIP file. + + Args: + zip_path: Path to preset ZIP file + speckit_version: Current spec-kit version + + Returns: + Installed preset manifest + + Raises: + PresetValidationError: If manifest is invalid + PresetCompatibilityError: If pack is incompatible + """ + with tempfile.TemporaryDirectory() as tmpdir: + temp_path = Path(tmpdir) + + with zipfile.ZipFile(zip_path, 'r') as zf: + temp_path_resolved = temp_path.resolve() + for member in zf.namelist(): + member_path = (temp_path / member).resolve() + try: + member_path.relative_to(temp_path_resolved) + except ValueError: + raise PresetValidationError( + f"Unsafe path in ZIP archive: {member} " + "(potential path traversal)" + ) + zf.extractall(temp_path) + + pack_dir = temp_path + manifest_path = pack_dir / "preset.yml" + + if not manifest_path.exists(): + subdirs = [d for d in temp_path.iterdir() if d.is_dir()] + if len(subdirs) == 1: + pack_dir = subdirs[0] + manifest_path = pack_dir / "preset.yml" + + if not manifest_path.exists(): + raise PresetValidationError( + "No preset.yml found in ZIP file" + ) + + return self.install_from_directory(pack_dir, speckit_version, priority) + + def remove(self, pack_id: str) -> bool: + """Remove an installed preset. + + Args: + pack_id: Preset ID + + Returns: + True if pack was removed + """ + if not self.registry.is_installed(pack_id): + return False + + # Unregister commands from AI agents + metadata = self.registry.get(pack_id) + registered_commands = metadata.get("registered_commands", {}) if metadata else {} + if registered_commands: + self._unregister_commands(registered_commands) + + # Restore original skills when preset is removed + registered_skills = metadata.get("registered_skills", []) if metadata else [] + pack_dir = self.presets_dir / pack_id + if registered_skills: + self._unregister_skills(registered_skills, pack_dir) + + if pack_dir.exists(): + shutil.rmtree(pack_dir) + + self.registry.remove(pack_id) + return True + + def list_installed(self) -> List[Dict[str, Any]]: + """List all installed presets with metadata. + + Returns: + List of preset metadata dictionaries + """ + result = [] + + for pack_id, metadata in self.registry.list().items(): + pack_dir = self.presets_dir / pack_id + manifest_path = pack_dir / "preset.yml" + + try: + manifest = PresetManifest(manifest_path) + result.append({ + "id": pack_id, + "name": manifest.name, + "version": metadata["version"], + "description": manifest.description, + "enabled": metadata.get("enabled", True), + "installed_at": metadata.get("installed_at"), + "template_count": len(manifest.templates), + "tags": manifest.tags, + "priority": metadata.get("priority", 10), + }) + except PresetValidationError: + result.append({ + "id": pack_id, + "name": pack_id, + "version": metadata.get("version", "unknown"), + "description": "⚠️ Corrupted preset", + "enabled": False, + "installed_at": metadata.get("installed_at"), + "template_count": 0, + "tags": [], + "priority": metadata.get("priority", 10), + }) + + return result + + def get_pack(self, pack_id: str) -> Optional[PresetManifest]: + """Get manifest for an installed preset. + + Args: + pack_id: Preset ID + + Returns: + Preset manifest or None if not installed + """ + if not self.registry.is_installed(pack_id): + return None + + pack_dir = self.presets_dir / pack_id + manifest_path = pack_dir / "preset.yml" + + try: + return PresetManifest(manifest_path) + except PresetValidationError: + return None + + +class PresetCatalog: + """Manages preset catalog fetching, caching, and searching. + + Supports multi-catalog stacks with priority-based resolution, + mirroring the extension catalog system. + """ + + DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json" + COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json" + CACHE_DURATION = 3600 # 1 hour in seconds + + def __init__(self, project_root: Path): + """Initialize preset catalog manager. + + Args: + project_root: Root directory of the spec-kit project + """ + self.project_root = project_root + self.presets_dir = project_root / ".specify" / "presets" + self.cache_dir = self.presets_dir / ".cache" + self.cache_file = self.cache_dir / "catalog.json" + self.cache_metadata_file = self.cache_dir / "catalog-metadata.json" + + def _validate_catalog_url(self, url: str) -> None: + """Validate that a catalog URL uses HTTPS (localhost HTTP allowed). + + Args: + url: URL to validate + + Raises: + PresetValidationError: If URL is invalid or uses non-HTTPS scheme + """ + from urllib.parse import urlparse + + parsed = urlparse(url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not ( + parsed.scheme == "http" and is_localhost + ): + raise PresetValidationError( + f"Catalog URL must use HTTPS (got {parsed.scheme}://). " + "HTTP is only allowed for localhost." + ) + if not parsed.netloc: + raise PresetValidationError( + "Catalog URL must be a valid URL with a host." + ) + + def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]: + """Load catalog stack configuration from a YAML file. + + Args: + config_path: Path to preset-catalogs.yml + + Returns: + Ordered list of PresetCatalogEntry objects, or None if file + doesn't exist or contains no valid catalog entries. + + Raises: + PresetValidationError: If any catalog entry has an invalid URL, + the file cannot be parsed, or a priority value is invalid. + """ + if not config_path.exists(): + return None + try: + data = yaml.safe_load(config_path.read_text()) or {} + except (yaml.YAMLError, OSError) as e: + raise PresetValidationError( + f"Failed to read catalog config {config_path}: {e}" + ) + if not isinstance(data, dict): + raise PresetValidationError( + f"Invalid catalog config {config_path}: expected a mapping at root, got {type(data).__name__}" + ) + catalogs_data = data.get("catalogs", []) + if not catalogs_data: + return None + if not isinstance(catalogs_data, list): + raise PresetValidationError( + f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}" + ) + entries: List[PresetCatalogEntry] = [] + for idx, item in enumerate(catalogs_data): + if not isinstance(item, dict): + raise PresetValidationError( + f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}" + ) + url = str(item.get("url", "")).strip() + if not url: + continue + self._validate_catalog_url(url) + try: + priority = int(item.get("priority", idx + 1)) + except (TypeError, ValueError): + raise PresetValidationError( + f"Invalid priority for catalog '{item.get('name', idx + 1)}': " + f"expected integer, got {item.get('priority')!r}" + ) + raw_install = item.get("install_allowed", False) + if isinstance(raw_install, str): + install_allowed = raw_install.strip().lower() in ("true", "yes", "1") + else: + install_allowed = bool(raw_install) + entries.append(PresetCatalogEntry( + url=url, + name=str(item.get("name", f"catalog-{idx + 1}")), + priority=priority, + install_allowed=install_allowed, + description=str(item.get("description", "")), + )) + entries.sort(key=lambda e: e.priority) + return entries if entries else None + + def get_active_catalogs(self) -> List[PresetCatalogEntry]: + """Get the ordered list of active preset catalogs. + + Resolution order: + 1. SPECKIT_PRESET_CATALOG_URL env var — single catalog replacing all defaults + 2. Project-level .specify/preset-catalogs.yml + 3. User-level ~/.specify/preset-catalogs.yml + 4. Built-in default stack (default + community) + + Returns: + List of PresetCatalogEntry objects sorted by priority (ascending) + + Raises: + PresetValidationError: If a catalog URL is invalid + """ + import sys + + # 1. SPECKIT_PRESET_CATALOG_URL env var replaces all defaults + if env_value := os.environ.get("SPECKIT_PRESET_CATALOG_URL"): + catalog_url = env_value.strip() + self._validate_catalog_url(catalog_url) + if catalog_url != self.DEFAULT_CATALOG_URL: + if not getattr(self, "_non_default_catalog_warning_shown", False): + print( + "Warning: Using non-default preset catalog. " + "Only use catalogs from sources you trust.", + file=sys.stderr, + ) + self._non_default_catalog_warning_shown = True + return [PresetCatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_PRESET_CATALOG_URL")] + + # 2. Project-level config overrides all defaults + project_config_path = self.project_root / ".specify" / "preset-catalogs.yml" + catalogs = self._load_catalog_config(project_config_path) + if catalogs is not None: + return catalogs + + # 3. User-level config + user_config_path = Path.home() / ".specify" / "preset-catalogs.yml" + catalogs = self._load_catalog_config(user_config_path) + if catalogs is not None: + return catalogs + + # 4. Built-in default stack + return [ + PresetCatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable presets"), + PresetCatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed presets (discovery only)"), + ] + + def get_catalog_url(self) -> str: + """Get the primary catalog URL. + + Returns the URL of the highest-priority catalog. Kept for backward + compatibility. Use get_active_catalogs() for full multi-catalog support. + + Returns: + URL of the primary catalog + """ + active = self.get_active_catalogs() + return active[0].url if active else self.DEFAULT_CATALOG_URL + + def _get_cache_paths(self, url: str): + """Get cache file paths for a given catalog URL. + + For the DEFAULT_CATALOG_URL, uses legacy cache files for backward + compatibility. For all other URLs, uses URL-hash-based cache files. + + Returns: + Tuple of (cache_file_path, cache_metadata_path) + """ + if url == self.DEFAULT_CATALOG_URL: + return self.cache_file, self.cache_metadata_file + url_hash = hashlib.sha256(url.encode()).hexdigest()[:16] + return ( + self.cache_dir / f"catalog-{url_hash}.json", + self.cache_dir / f"catalog-{url_hash}-metadata.json", + ) + + def _is_url_cache_valid(self, url: str) -> bool: + """Check if cached catalog for a specific URL is still valid.""" + cache_file, metadata_file = self._get_cache_paths(url) + if not cache_file.exists() or not metadata_file.exists(): + return False + try: + metadata = json.loads(metadata_file.read_text()) + cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) + age_seconds = ( + datetime.now(timezone.utc) - cached_at + ).total_seconds() + return age_seconds < self.CACHE_DURATION + except (json.JSONDecodeError, ValueError, KeyError, TypeError): + return False + + def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = False) -> Dict[str, Any]: + """Fetch a single catalog with per-URL caching. + + Args: + entry: PresetCatalogEntry describing the catalog to fetch + force_refresh: If True, bypass cache + + Returns: + Catalog data dictionary + + Raises: + PresetError: If catalog cannot be fetched + """ + cache_file, metadata_file = self._get_cache_paths(entry.url) + + if not force_refresh and self._is_url_cache_valid(entry.url): + try: + return json.loads(cache_file.read_text()) + except json.JSONDecodeError: + pass + + try: + import urllib.request + import urllib.error + + with urllib.request.urlopen(entry.url, timeout=10) as response: + catalog_data = json.loads(response.read()) + + if ( + "schema_version" not in catalog_data + or "presets" not in catalog_data + ): + raise PresetError("Invalid preset catalog format") + + self.cache_dir.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps(catalog_data, indent=2)) + metadata = { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": entry.url, + } + metadata_file.write_text(json.dumps(metadata, indent=2)) + + return catalog_data + + except (ImportError, Exception) as e: + if isinstance(e, PresetError): + raise + raise PresetError( + f"Failed to fetch preset catalog from {entry.url}: {e}" + ) + + def _get_merged_packs(self, force_refresh: bool = False) -> Dict[str, Dict[str, Any]]: + """Fetch and merge presets from all active catalogs. + + Higher-priority catalogs (lower priority number) win on ID conflicts. + + Returns: + Merged dictionary of pack_id -> pack_data + """ + active_catalogs = self.get_active_catalogs() + merged: Dict[str, Dict[str, Any]] = {} + + for entry in reversed(active_catalogs): + try: + data = self._fetch_single_catalog(entry, force_refresh) + for pack_id, pack_data in data.get("presets", {}).items(): + pack_data_with_catalog = {**pack_data, "_catalog_name": entry.name, "_install_allowed": entry.install_allowed} + merged[pack_id] = pack_data_with_catalog + except PresetError: + continue + + return merged + + 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", "")) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) + age_seconds = ( + datetime.now(timezone.utc) - cached_at + ).total_seconds() + return age_seconds < self.CACHE_DURATION + except (json.JSONDecodeError, ValueError, KeyError, TypeError): + return False + + def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: + """Fetch preset catalog from URL or cache. + + Args: + force_refresh: If True, bypass cache and fetch from network + + Returns: + Catalog data dictionary + + Raises: + PresetError: If catalog cannot be fetched + """ + catalog_url = self.get_catalog_url() + + if not force_refresh and self.is_cache_valid(): + try: + metadata = json.loads(self.cache_metadata_file.read_text()) + if metadata.get("catalog_url") == catalog_url: + return json.loads(self.cache_file.read_text()) + except (json.JSONDecodeError, OSError): + # Cache is corrupt or unreadable; fall through to network fetch + pass + + try: + import urllib.request + import urllib.error + + with urllib.request.urlopen(catalog_url, timeout=10) as response: + catalog_data = json.loads(response.read()) + + if ( + "schema_version" not in catalog_data + or "presets" not in catalog_data + ): + raise PresetError("Invalid preset catalog format") + + self.cache_dir.mkdir(parents=True, exist_ok=True) + self.cache_file.write_text(json.dumps(catalog_data, indent=2)) + + 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 (ImportError, Exception) as e: + if isinstance(e, PresetError): + raise + raise PresetError( + f"Failed to fetch preset catalog from {catalog_url}: {e}" + ) + + def search( + self, + query: Optional[str] = None, + tag: Optional[str] = None, + author: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Search catalog for presets. + + Searches across all active catalogs (merged by priority) so that + community and custom catalogs are included in results. + + Args: + query: Search query (searches name, description, tags) + tag: Filter by specific tag + author: Filter by author name + + Returns: + List of matching preset metadata + """ + try: + packs = self._get_merged_packs() + except PresetError: + return [] + + results = [] + + for pack_id, pack_data in packs.items(): + if author and pack_data.get("author", "").lower() != author.lower(): + continue + + if tag and tag.lower() not in [ + t.lower() for t in pack_data.get("tags", []) + ]: + continue + + if query: + query_lower = query.lower() + searchable_text = " ".join( + [ + pack_data.get("name", ""), + pack_data.get("description", ""), + pack_id, + ] + + pack_data.get("tags", []) + ).lower() + + if query_lower not in searchable_text: + continue + + results.append({**pack_data, "id": pack_id}) + + return results + + def get_pack_info( + self, pack_id: str + ) -> Optional[Dict[str, Any]]: + """Get detailed information about a specific preset. + + Searches across all active catalogs (merged by priority). + + Args: + pack_id: ID of the preset + + Returns: + Pack metadata or None if not found + """ + try: + packs = self._get_merged_packs() + except PresetError: + return None + + if pack_id in packs: + return {**packs[pack_id], "id": pack_id} + return None + + def download_pack( + self, pack_id: str, target_dir: Optional[Path] = None + ) -> Path: + """Download preset ZIP from catalog. + + Args: + pack_id: ID of the preset to download + target_dir: Directory to save ZIP file (defaults to cache directory) + + Returns: + Path to downloaded ZIP file + + Raises: + PresetError: If pack not found or download fails + """ + import urllib.request + import urllib.error + + pack_info = self.get_pack_info(pack_id) + if not pack_info: + raise PresetError( + f"Preset '{pack_id}' not found in catalog" + ) + + if not pack_info.get("_install_allowed", True): + catalog_name = pack_info.get("_catalog_name", "unknown") + raise PresetError( + f"Preset '{pack_id}' is from the '{catalog_name}' catalog which does not allow installation. " + f"Use --from with the preset's repository URL instead." + ) + + download_url = pack_info.get("download_url") + if not download_url: + raise PresetError( + f"Preset '{pack_id}' has no download URL" + ) + + 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 PresetError( + f"Preset download URL must use HTTPS: {download_url}" + ) + + if target_dir is None: + target_dir = self.cache_dir / "downloads" + target_dir.mkdir(parents=True, exist_ok=True) + + version = pack_info.get("version", "unknown") + zip_filename = f"{pack_id}-{version}.zip" + zip_path = target_dir / zip_filename + + 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 PresetError( + f"Failed to download preset from {download_url}: {e}" + ) + except IOError as e: + raise PresetError(f"Failed to save preset ZIP: {e}") + + def clear_cache(self): + """Clear all catalog cache files, including per-URL hashed caches.""" + if self.cache_dir.exists(): + for f in self.cache_dir.iterdir(): + if f.is_file() and f.name.startswith("catalog"): + f.unlink(missing_ok=True) + + +class PresetResolver: + """Resolves template names to file paths using a priority stack. + + Resolution order: + 1. .specify/templates/overrides/ - Project-local overrides + 2. .specify/presets// - Installed presets + 3. .specify/extensions//templates/ - Extension-provided templates + 4. .specify/templates/ - Core templates (shipped with Spec Kit) + """ + + def __init__(self, project_root: Path): + """Initialize preset resolver. + + Args: + project_root: Path to project root directory + """ + self.project_root = project_root + self.templates_dir = project_root / ".specify" / "templates" + self.presets_dir = project_root / ".specify" / "presets" + self.overrides_dir = self.templates_dir / "overrides" + self.extensions_dir = project_root / ".specify" / "extensions" + + def resolve( + self, + template_name: str, + template_type: str = "template", + ) -> Optional[Path]: + """Resolve a template name to its file path. + + Walks the priority stack and returns the first match. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + + Returns: + Path to the resolved template file, or None if not found + """ + # Determine subdirectory based on template type + if template_type == "template": + subdirs = ["templates", ""] + elif template_type == "command": + subdirs = ["commands"] + elif template_type == "script": + subdirs = ["scripts"] + else: + subdirs = [""] + + # Determine file extension based on template type + ext = ".md" + if template_type == "script": + ext = ".sh" # scripts use .sh; callers can also check .ps1 + + # Priority 1: Project-local overrides + if template_type == "script": + override = self.overrides_dir / "scripts" / f"{template_name}{ext}" + else: + override = self.overrides_dir / f"{template_name}{ext}" + if override.exists(): + return override + + # Priority 2: Installed presets (sorted by priority — lower number wins) + if self.presets_dir.exists(): + registry = PresetRegistry(self.presets_dir) + for pack_id, _metadata in registry.list_by_priority(): + pack_dir = self.presets_dir / pack_id + for subdir in subdirs: + if subdir: + candidate = pack_dir / subdir / f"{template_name}{ext}" + else: + candidate = pack_dir / f"{template_name}{ext}" + if candidate.exists(): + return candidate + + # Priority 3: Extension-provided templates + if self.extensions_dir.exists(): + for ext_dir in sorted(self.extensions_dir.iterdir()): + if not ext_dir.is_dir() or ext_dir.name.startswith("."): + continue + for subdir in subdirs: + if subdir: + candidate = ext_dir / subdir / f"{template_name}{ext}" + else: + candidate = ext_dir / "templates" / f"{template_name}{ext}" + if candidate.exists(): + return candidate + + # Priority 4: Core templates + if template_type == "template": + core = self.templates_dir / f"{template_name}.md" + if core.exists(): + return core + elif template_type == "command": + core = self.templates_dir / "commands" / f"{template_name}.md" + if core.exists(): + return core + elif template_type == "script": + core = self.templates_dir / "scripts" / f"{template_name}{ext}" + if core.exists(): + return core + + return None + + def resolve_with_source( + self, + template_name: str, + template_type: str = "template", + ) -> Optional[Dict[str, str]]: + """Resolve a template name and return source attribution. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + + Returns: + Dictionary with 'path' and 'source' keys, or None if not found + """ + # Delegate to resolve() for the actual lookup, then determine source + resolved = self.resolve(template_name, template_type) + if resolved is None: + return None + + resolved_str = str(resolved) + + # Determine source attribution + if str(self.overrides_dir) in resolved_str: + return {"path": resolved_str, "source": "project override"} + + if str(self.presets_dir) in resolved_str and self.presets_dir.exists(): + registry = PresetRegistry(self.presets_dir) + for pack_id, _metadata in registry.list_by_priority(): + pack_dir = self.presets_dir / pack_id + try: + resolved.relative_to(pack_dir) + meta = registry.get(pack_id) + version = meta.get("version", "?") if meta else "?" + return { + "path": resolved_str, + "source": f"{pack_id} v{version}", + } + except ValueError: + continue + + if self.extensions_dir.exists(): + for ext_dir in sorted(self.extensions_dir.iterdir()): + if not ext_dir.is_dir() or ext_dir.name.startswith("."): + continue + try: + resolved.relative_to(ext_dir) + return { + "path": resolved_str, + "source": f"extension:{ext_dir.name}", + } + except ValueError: + continue + + return {"path": resolved_str, "source": "core"} diff --git a/tests/test_presets.py b/tests/test_presets.py new file mode 100644 index 00000000..3ad70c6d --- /dev/null +++ b/tests/test_presets.py @@ -0,0 +1,1712 @@ +""" +Unit tests for the preset system. + +Tests cover: +- Preset manifest validation +- Preset registry operations +- Preset manager installation/removal +- Template catalog search +- Template resolver priority stack +- Extension-provided templates +""" + +import pytest +import json +import tempfile +import shutil +import zipfile +from pathlib import Path +from datetime import datetime, timezone + +import yaml + +from specify_cli.presets import ( + PresetManifest, + PresetRegistry, + PresetManager, + PresetCatalog, + PresetCatalogEntry, + PresetResolver, + PresetError, + PresetValidationError, + PresetCompatibilityError, + VALID_PRESET_TEMPLATE_TYPES, +) + + +# ===== 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_pack_data(): + """Valid preset manifest data.""" + return { + "schema_version": "1.0", + "preset": { + "id": "test-pack", + "name": "Test Preset", + "version": "1.0.0", + "description": "A test preset", + "author": "Test Author", + "repository": "https://github.com/test/test-pack", + "license": "MIT", + }, + "requires": { + "speckit_version": ">=0.1.0", + }, + "provides": { + "templates": [ + { + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "description": "Custom spec template", + "replaces": "spec-template", + } + ] + }, + "tags": ["testing", "example"], + } + + +@pytest.fixture +def pack_dir(temp_dir, valid_pack_data): + """Create a complete preset directory structure.""" + p_dir = temp_dir / "test-pack" + p_dir.mkdir() + + # Write manifest + manifest_path = p_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + + # Create templates directory + templates_dir = p_dir / "templates" + templates_dir.mkdir() + + # Write template file + tmpl_file = templates_dir / "spec-template.md" + tmpl_file.write_text("# Custom Spec Template\n\nThis is a custom template.\n") + + return p_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() + + # Create templates directory with core templates + templates_dir = specify_dir / "templates" + templates_dir.mkdir() + + # Create core spec-template + core_spec = templates_dir / "spec-template.md" + core_spec.write_text("# Core Spec Template\n") + + # Create core plan-template + core_plan = templates_dir / "plan-template.md" + core_plan.write_text("# Core Plan Template\n") + + # Create commands subdirectory + commands_dir = templates_dir / "commands" + commands_dir.mkdir() + + return proj_dir + + +# ===== PresetManifest Tests ===== + + +class TestPresetManifest: + """Test PresetManifest validation and parsing.""" + + def test_valid_manifest(self, pack_dir): + """Test loading a valid manifest.""" + manifest = PresetManifest(pack_dir / "preset.yml") + assert manifest.id == "test-pack" + assert manifest.name == "Test Preset" + assert manifest.version == "1.0.0" + assert manifest.description == "A test preset" + assert manifest.author == "Test Author" + assert manifest.requires_speckit_version == ">=0.1.0" + assert len(manifest.templates) == 1 + assert manifest.tags == ["testing", "example"] + + def test_missing_manifest(self, temp_dir): + """Test that missing manifest raises error.""" + with pytest.raises(PresetValidationError, match="Manifest not found"): + PresetManifest(temp_dir / "nonexistent.yml") + + def test_invalid_yaml(self, temp_dir): + """Test that invalid YAML raises error.""" + bad_file = temp_dir / "bad.yml" + bad_file.write_text(": invalid: yaml: {{{") + with pytest.raises(PresetValidationError, match="Invalid YAML"): + PresetManifest(bad_file) + + def test_missing_schema_version(self, temp_dir, valid_pack_data): + """Test missing schema_version field.""" + del valid_pack_data["schema_version"] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Missing required field: schema_version"): + PresetManifest(manifest_path) + + def test_wrong_schema_version(self, temp_dir, valid_pack_data): + """Test unsupported schema version.""" + valid_pack_data["schema_version"] = "2.0" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Unsupported schema version"): + PresetManifest(manifest_path) + + def test_missing_pack_id(self, temp_dir, valid_pack_data): + """Test missing preset.id field.""" + del valid_pack_data["preset"]["id"] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Missing preset.id"): + PresetManifest(manifest_path) + + def test_invalid_pack_id_format(self, temp_dir, valid_pack_data): + """Test invalid pack ID format.""" + valid_pack_data["preset"]["id"] = "Invalid_ID" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid preset ID"): + PresetManifest(manifest_path) + + def test_invalid_version(self, temp_dir, valid_pack_data): + """Test invalid semantic version.""" + valid_pack_data["preset"]["version"] = "not-a-version" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid version"): + PresetManifest(manifest_path) + + def test_missing_speckit_version(self, temp_dir, valid_pack_data): + """Test missing requires.speckit_version.""" + del valid_pack_data["requires"]["speckit_version"] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Missing requires.speckit_version"): + PresetManifest(manifest_path) + + def test_no_templates_provided(self, temp_dir, valid_pack_data): + """Test pack with no templates.""" + valid_pack_data["provides"]["templates"] = [] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="must provide at least one template"): + PresetManifest(manifest_path) + + def test_invalid_template_type(self, temp_dir, valid_pack_data): + """Test template with invalid type.""" + valid_pack_data["provides"]["templates"][0]["type"] = "invalid" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid template type"): + PresetManifest(manifest_path) + + def test_valid_template_types(self): + """Test that all expected template types are valid.""" + assert "template" in VALID_PRESET_TEMPLATE_TYPES + assert "command" in VALID_PRESET_TEMPLATE_TYPES + assert "script" in VALID_PRESET_TEMPLATE_TYPES + + def test_template_missing_required_fields(self, temp_dir, valid_pack_data): + """Test template missing required fields.""" + valid_pack_data["provides"]["templates"] = [{"type": "template"}] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="missing 'type', 'name', or 'file'"): + PresetManifest(manifest_path) + + def test_invalid_template_name_format(self, temp_dir, valid_pack_data): + """Test template with invalid name format.""" + valid_pack_data["provides"]["templates"][0]["name"] = "Invalid Name" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid template name"): + PresetManifest(manifest_path) + + def test_get_hash(self, pack_dir): + """Test manifest hash calculation.""" + manifest = PresetManifest(pack_dir / "preset.yml") + hash_val = manifest.get_hash() + assert hash_val.startswith("sha256:") + assert len(hash_val) > 10 + + def test_multiple_templates(self, temp_dir, valid_pack_data): + """Test pack with multiple templates of different types.""" + valid_pack_data["provides"]["templates"] = [ + {"type": "template", "name": "spec-template", "file": "templates/spec-template.md"}, + {"type": "template", "name": "plan-template", "file": "templates/plan-template.md"}, + {"type": "command", "name": "specify", "file": "commands/specify.md"}, + {"type": "script", "name": "create-new-feature", "file": "scripts/create-new-feature.sh"}, + ] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert len(manifest.templates) == 4 + + +# ===== PresetRegistry Tests ===== + + +class TestPresetRegistry: + """Test PresetRegistry operations.""" + + def test_empty_registry(self, temp_dir): + """Test empty registry initialization.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + assert registry.list() == {} + assert not registry.is_installed("test-pack") + + def test_add_and_get(self, temp_dir): + """Test adding and retrieving a pack.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("test-pack", {"version": "1.0.0", "source": "local"}) + assert registry.is_installed("test-pack") + + metadata = registry.get("test-pack") + assert metadata is not None + assert metadata["version"] == "1.0.0" + assert "installed_at" in metadata + + def test_remove(self, temp_dir): + """Test removing a pack.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("test-pack", {"version": "1.0.0"}) + assert registry.is_installed("test-pack") + + registry.remove("test-pack") + assert not registry.is_installed("test-pack") + + def test_remove_nonexistent(self, temp_dir): + """Test removing a pack that doesn't exist.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + registry.remove("nonexistent") # Should not raise + + def test_list(self, temp_dir): + """Test listing all packs.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("pack-a", {"version": "1.0.0"}) + registry.add("pack-b", {"version": "2.0.0"}) + + all_packs = registry.list() + assert len(all_packs) == 2 + assert "pack-a" in all_packs + assert "pack-b" in all_packs + + def test_persistence(self, temp_dir): + """Test that registry data persists across instances.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + + # Add with first instance + registry1 = PresetRegistry(packs_dir) + registry1.add("test-pack", {"version": "1.0.0"}) + + # Load with second instance + registry2 = PresetRegistry(packs_dir) + assert registry2.is_installed("test-pack") + + def test_corrupted_registry(self, temp_dir): + """Test recovery from corrupted registry file.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + + registry_file = packs_dir / ".registry" + registry_file.write_text("not valid json{{{") + + registry = PresetRegistry(packs_dir) + assert registry.list() == {} + + def test_get_nonexistent(self, temp_dir): + """Test getting a nonexistent pack.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + assert registry.get("nonexistent") is None + + +# ===== PresetManager Tests ===== + + +class TestPresetManager: + """Test PresetManager installation and removal.""" + + def test_install_from_directory(self, project_dir, pack_dir): + """Test installing a preset from a directory.""" + manager = PresetManager(project_dir) + manifest = manager.install_from_directory(pack_dir, "0.1.5") + + assert manifest.id == "test-pack" + assert manager.registry.is_installed("test-pack") + + # Verify files are copied + installed_dir = project_dir / ".specify" / "presets" / "test-pack" + assert installed_dir.exists() + assert (installed_dir / "preset.yml").exists() + assert (installed_dir / "templates" / "spec-template.md").exists() + + def test_install_already_installed(self, project_dir, pack_dir): + """Test installing an already-installed pack raises error.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + with pytest.raises(PresetError, match="already installed"): + manager.install_from_directory(pack_dir, "0.1.5") + + def test_install_incompatible(self, project_dir, temp_dir, valid_pack_data): + """Test installing an incompatible pack raises error.""" + valid_pack_data["requires"]["speckit_version"] = ">=99.0.0" + incompat_dir = temp_dir / "incompat-pack" + incompat_dir.mkdir() + manifest_path = incompat_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (incompat_dir / "templates").mkdir() + (incompat_dir / "templates" / "spec-template.md").write_text("test") + + manager = PresetManager(project_dir) + with pytest.raises(PresetCompatibilityError): + manager.install_from_directory(incompat_dir, "0.1.5") + + def test_install_from_zip(self, project_dir, pack_dir, temp_dir): + """Test installing from a ZIP file.""" + zip_path = temp_dir / "test-pack.zip" + with zipfile.ZipFile(zip_path, 'w') as zf: + for file_path in pack_dir.rglob('*'): + if file_path.is_file(): + arcname = file_path.relative_to(pack_dir) + zf.write(file_path, arcname) + + manager = PresetManager(project_dir) + manifest = manager.install_from_zip(zip_path, "0.1.5") + assert manifest.id == "test-pack" + assert manager.registry.is_installed("test-pack") + + def test_install_from_zip_nested(self, project_dir, pack_dir, temp_dir): + """Test installing from ZIP with nested directory.""" + zip_path = temp_dir / "test-pack.zip" + with zipfile.ZipFile(zip_path, 'w') as zf: + for file_path in pack_dir.rglob('*'): + if file_path.is_file(): + arcname = Path("test-pack-v1.0.0") / file_path.relative_to(pack_dir) + zf.write(file_path, arcname) + + manager = PresetManager(project_dir) + manifest = manager.install_from_zip(zip_path, "0.1.5") + assert manifest.id == "test-pack" + + def test_install_from_zip_no_manifest(self, project_dir, temp_dir): + """Test installing from ZIP without manifest raises error.""" + zip_path = temp_dir / "bad.zip" + with zipfile.ZipFile(zip_path, 'w') as zf: + zf.writestr("readme.txt", "no manifest here") + + manager = PresetManager(project_dir) + with pytest.raises(PresetValidationError, match="No preset.yml found"): + manager.install_from_zip(zip_path, "0.1.5") + + def test_remove(self, project_dir, pack_dir): + """Test removing a preset.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + assert manager.registry.is_installed("test-pack") + + result = manager.remove("test-pack") + assert result is True + assert not manager.registry.is_installed("test-pack") + + installed_dir = project_dir / ".specify" / "presets" / "test-pack" + assert not installed_dir.exists() + + def test_remove_nonexistent(self, project_dir): + """Test removing a pack that doesn't exist.""" + manager = PresetManager(project_dir) + result = manager.remove("nonexistent") + assert result is False + + def test_list_installed(self, project_dir, pack_dir): + """Test listing installed packs.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + installed = manager.list_installed() + assert len(installed) == 1 + assert installed[0]["id"] == "test-pack" + assert installed[0]["name"] == "Test Preset" + assert installed[0]["version"] == "1.0.0" + assert installed[0]["template_count"] == 1 + + def test_list_installed_empty(self, project_dir): + """Test listing when no packs installed.""" + manager = PresetManager(project_dir) + assert manager.list_installed() == [] + + def test_get_pack(self, project_dir, pack_dir): + """Test getting a specific installed pack.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + pack = manager.get_pack("test-pack") + assert pack is not None + assert pack.id == "test-pack" + + def test_get_pack_not_installed(self, project_dir): + """Test getting a non-installed pack returns None.""" + manager = PresetManager(project_dir) + assert manager.get_pack("nonexistent") is None + + def test_check_compatibility_valid(self, pack_dir, temp_dir): + """Test compatibility check with valid version.""" + manager = PresetManager(temp_dir) + manifest = PresetManifest(pack_dir / "preset.yml") + assert manager.check_compatibility(manifest, "0.1.5") is True + + def test_check_compatibility_invalid(self, pack_dir, temp_dir): + """Test compatibility check with invalid specifier.""" + manager = PresetManager(temp_dir) + manifest = PresetManifest(pack_dir / "preset.yml") + manifest.data["requires"]["speckit_version"] = "not-a-specifier" + with pytest.raises(PresetCompatibilityError, match="Invalid version specifier"): + manager.check_compatibility(manifest, "0.1.5") + + def test_install_with_priority(self, project_dir, pack_dir): + """Test installing a pack with custom priority.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5", priority=5) + + metadata = manager.registry.get("test-pack") + assert metadata is not None + assert metadata["priority"] == 5 + + def test_install_default_priority(self, project_dir, pack_dir): + """Test that default priority is 10.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + metadata = manager.registry.get("test-pack") + assert metadata is not None + assert metadata["priority"] == 10 + + def test_list_installed_includes_priority(self, project_dir, pack_dir): + """Test that list_installed includes priority.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5", priority=3) + + installed = manager.list_installed() + assert len(installed) == 1 + assert installed[0]["priority"] == 3 + + +class TestRegistryPriority: + """Test registry priority sorting.""" + + def test_list_by_priority(self, temp_dir): + """Test that list_by_priority sorts by priority number.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("pack-high", {"version": "1.0.0", "priority": 1}) + registry.add("pack-low", {"version": "1.0.0", "priority": 20}) + registry.add("pack-mid", {"version": "1.0.0", "priority": 10}) + + sorted_packs = registry.list_by_priority() + assert len(sorted_packs) == 3 + assert sorted_packs[0][0] == "pack-high" + assert sorted_packs[1][0] == "pack-mid" + assert sorted_packs[2][0] == "pack-low" + + def test_list_by_priority_default(self, temp_dir): + """Test that packs without priority default to 10.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("pack-a", {"version": "1.0.0"}) # no priority, defaults to 10 + registry.add("pack-b", {"version": "1.0.0", "priority": 5}) + + sorted_packs = registry.list_by_priority() + assert sorted_packs[0][0] == "pack-b" + assert sorted_packs[1][0] == "pack-a" + + +# ===== PresetResolver Tests ===== + + +class TestPresetResolver: + """Test PresetResolver priority stack.""" + + def test_resolve_core_template(self, project_dir): + """Test resolving a core template.""" + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert result.name == "spec-template.md" + assert "Core Spec Template" in result.read_text() + + def test_resolve_nonexistent(self, project_dir): + """Test resolving a nonexistent template returns None.""" + resolver = PresetResolver(project_dir) + result = resolver.resolve("nonexistent-template") + assert result is None + + def test_resolve_higher_priority_pack_wins(self, project_dir, temp_dir, valid_pack_data): + """Test that a pack with lower priority number wins over higher number.""" + manager = PresetManager(project_dir) + + # Create pack A (priority 10 — lower precedence) + pack_a_dir = temp_dir / "pack-a" + pack_a_dir.mkdir() + data_a = {**valid_pack_data} + data_a["preset"] = {**valid_pack_data["preset"], "id": "pack-a", "name": "Pack A"} + with open(pack_a_dir / "preset.yml", 'w') as f: + yaml.dump(data_a, f) + (pack_a_dir / "templates").mkdir() + (pack_a_dir / "templates" / "spec-template.md").write_text("# From Pack A\n") + + # Create pack B (priority 1 — higher precedence) + pack_b_dir = temp_dir / "pack-b" + pack_b_dir.mkdir() + data_b = {**valid_pack_data} + data_b["preset"] = {**valid_pack_data["preset"], "id": "pack-b", "name": "Pack B"} + with open(pack_b_dir / "preset.yml", 'w') as f: + yaml.dump(data_b, f) + (pack_b_dir / "templates").mkdir() + (pack_b_dir / "templates" / "spec-template.md").write_text("# From Pack B\n") + + # Install A first (priority 10), B second (priority 1) + manager.install_from_directory(pack_a_dir, "0.1.5", priority=10) + manager.install_from_directory(pack_b_dir, "0.1.5", priority=1) + + # Pack B should win because lower priority number + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "From Pack B" in result.read_text() + + def test_resolve_override_takes_priority(self, project_dir): + """Test that project overrides take priority over core.""" + # Create override + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + override = overrides_dir / "spec-template.md" + override.write_text("# Override Spec Template\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "Override Spec Template" in result.read_text() + + def test_resolve_pack_takes_priority_over_core(self, project_dir, pack_dir): + """Test that installed packs take priority over core templates.""" + # Install the pack + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "Custom Spec Template" in result.read_text() + + def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir): + """Test that overrides take priority over installed packs.""" + # Install the pack + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + # Create override + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + override = overrides_dir / "spec-template.md" + override.write_text("# Override Spec Template\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "Override Spec Template" in result.read_text() + + def test_resolve_extension_provided_templates(self, project_dir): + """Test resolving templates provided by extensions.""" + # Create extension with templates + ext_dir = project_dir / ".specify" / "extensions" / "my-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + ext_template = ext_templates_dir / "custom-template.md" + ext_template.write_text("# Extension Custom Template\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("custom-template") + assert result is not None + assert "Extension Custom Template" in result.read_text() + + def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data): + """Test that pack templates take priority over extension templates.""" + # Create extension with templates + ext_dir = project_dir / ".specify" / "extensions" / "my-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + ext_template = ext_templates_dir / "spec-template.md" + ext_template.write_text("# Extension Spec Template\n") + + # Install a pack with the same template + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + # Pack should win over extension + assert "Custom Spec Template" in result.read_text() + + def test_resolve_with_source_core(self, project_dir): + """Test resolve_with_source for core template.""" + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("spec-template") + assert result is not None + assert result["source"] == "core" + assert "spec-template.md" in result["path"] + + def test_resolve_with_source_override(self, project_dir): + """Test resolve_with_source for override template.""" + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + override = overrides_dir / "spec-template.md" + override.write_text("# Override\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("spec-template") + assert result is not None + assert result["source"] == "project override" + + def test_resolve_with_source_pack(self, project_dir, pack_dir): + """Test resolve_with_source for pack template.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("spec-template") + assert result is not None + assert "test-pack" in result["source"] + assert "v1.0.0" in result["source"] + + def test_resolve_with_source_extension(self, project_dir): + """Test resolve_with_source for extension-provided template.""" + ext_dir = project_dir / ".specify" / "extensions" / "my-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + ext_template = ext_templates_dir / "unique-template.md" + ext_template.write_text("# Unique\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("unique-template") + assert result is not None + assert result["source"] == "extension:my-ext" + + def test_resolve_with_source_not_found(self, project_dir): + """Test resolve_with_source for nonexistent template.""" + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("nonexistent") + assert result is None + + def test_resolve_skips_hidden_extension_dirs(self, project_dir): + """Test that hidden directories in extensions are skipped.""" + ext_dir = project_dir / ".specify" / "extensions" / ".backup" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + ext_template = ext_templates_dir / "hidden-template.md" + ext_template.write_text("# Hidden\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("hidden-template") + assert result is None + + +# ===== PresetCatalog Tests ===== + + +class TestPresetCatalog: + """Test template catalog functionality.""" + + def test_default_catalog_url(self, project_dir): + """Test default catalog URL.""" + catalog = PresetCatalog(project_dir) + assert catalog.DEFAULT_CATALOG_URL.startswith("https://") + assert catalog.DEFAULT_CATALOG_URL.endswith("/presets/catalog.json") + + def test_community_catalog_url(self, project_dir): + """Test community catalog URL.""" + catalog = PresetCatalog(project_dir) + assert "presets/catalog.community.json" in catalog.COMMUNITY_CATALOG_URL + + def test_cache_validation_no_cache(self, project_dir): + """Test cache validation when no cache exists.""" + catalog = PresetCatalog(project_dir) + assert catalog.is_cache_valid() is False + + def test_cache_validation_valid(self, project_dir): + """Test cache validation with valid cache.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + + catalog.cache_file.write_text(json.dumps({ + "schema_version": "1.0", + "presets": {}, + })) + catalog.cache_metadata_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + })) + + assert catalog.is_cache_valid() is True + + def test_cache_validation_expired(self, project_dir): + """Test cache validation with expired cache.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + + catalog.cache_file.write_text(json.dumps({ + "schema_version": "1.0", + "presets": {}, + })) + catalog.cache_metadata_file.write_text(json.dumps({ + "cached_at": "2020-01-01T00:00:00+00:00", + })) + + assert catalog.is_cache_valid() is False + + def test_cache_validation_corrupted(self, project_dir): + """Test cache validation with corrupted metadata.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + + catalog.cache_file.write_text("not json") + catalog.cache_metadata_file.write_text("not json") + + assert catalog.is_cache_valid() is False + + def test_clear_cache(self, project_dir): + """Test clearing the cache.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + catalog.cache_file.write_text("{}") + catalog.cache_metadata_file.write_text("{}") + + catalog.clear_cache() + + assert not catalog.cache_file.exists() + assert not catalog.cache_metadata_file.exists() + + def test_search_with_cached_data(self, project_dir): + """Test search with cached catalog data.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + + catalog_data = { + "schema_version": "1.0", + "presets": { + "safe-agile": { + "name": "SAFe Agile Templates", + "description": "SAFe-aligned templates", + "author": "agile-community", + "version": "1.0.0", + "tags": ["safe", "agile"], + }, + "healthcare": { + "name": "Healthcare Compliance", + "description": "HIPAA-compliant templates", + "author": "healthcare-org", + "version": "1.0.0", + "tags": ["healthcare", "hipaa"], + }, + } + } + + catalog.cache_file.write_text(json.dumps(catalog_data)) + catalog.cache_metadata_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + })) + + # Search by query + results = catalog.search(query="agile") + assert len(results) == 1 + assert results[0]["id"] == "safe-agile" + + # Search by tag + results = catalog.search(tag="hipaa") + assert len(results) == 1 + assert results[0]["id"] == "healthcare" + + # Search by author + results = catalog.search(author="agile-community") + assert len(results) == 1 + + # Search all + results = catalog.search() + assert len(results) == 2 + + def test_get_pack_info(self, project_dir): + """Test getting info for a specific pack.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + + catalog_data = { + "schema_version": "1.0", + "presets": { + "test-pack": { + "name": "Test Pack", + "version": "1.0.0", + }, + } + } + + catalog.cache_file.write_text(json.dumps(catalog_data)) + catalog.cache_metadata_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + })) + + info = catalog.get_pack_info("test-pack") + assert info is not None + assert info["name"] == "Test Pack" + assert info["id"] == "test-pack" + + assert catalog.get_pack_info("nonexistent") is None + + def test_validate_catalog_url_https(self, project_dir): + """Test that HTTPS URLs are accepted.""" + catalog = PresetCatalog(project_dir) + catalog._validate_catalog_url("https://example.com/catalog.json") + + def test_validate_catalog_url_http_rejected(self, project_dir): + """Test that HTTP URLs are rejected.""" + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="must use HTTPS"): + catalog._validate_catalog_url("http://example.com/catalog.json") + + def test_validate_catalog_url_localhost_http_allowed(self, project_dir): + """Test that HTTP is allowed for localhost.""" + catalog = PresetCatalog(project_dir) + catalog._validate_catalog_url("http://localhost:8080/catalog.json") + catalog._validate_catalog_url("http://127.0.0.1:8080/catalog.json") + + def test_env_var_catalog_url(self, project_dir, monkeypatch): + """Test catalog URL from environment variable.""" + monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", "https://custom.example.com/catalog.json") + catalog = PresetCatalog(project_dir) + assert catalog.get_catalog_url() == "https://custom.example.com/catalog.json" + + +# ===== Integration Tests ===== + + +class TestIntegration: + """Integration tests for complete preset workflows.""" + + def test_full_install_resolve_remove_cycle(self, project_dir, pack_dir): + """Test complete lifecycle: install → resolve → remove.""" + # Install + manager = PresetManager(project_dir) + manifest = manager.install_from_directory(pack_dir, "0.1.5") + assert manifest.id == "test-pack" + + # Resolve — pack template should win over core + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "Custom Spec Template" in result.read_text() + + # Remove + manager.remove("test-pack") + + # Resolve — should fall back to core + result = resolver.resolve("spec-template") + assert result is not None + assert "Core Spec Template" in result.read_text() + + def test_override_beats_pack_beats_extension_beats_core(self, project_dir, pack_dir): + """Test the full priority stack: override > pack > extension > core.""" + resolver = PresetResolver(project_dir) + + # Core should resolve + result = resolver.resolve_with_source("spec-template") + assert result["source"] == "core" + + # Add extension template + ext_dir = project_dir / ".specify" / "extensions" / "my-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + (ext_templates_dir / "spec-template.md").write_text("# Extension\n") + + result = resolver.resolve_with_source("spec-template") + assert result["source"] == "extension:my-ext" + + # Install pack — should win over extension + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + result = resolver.resolve_with_source("spec-template") + assert "test-pack" in result["source"] + + # Add override — should win over pack + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + (overrides_dir / "spec-template.md").write_text("# Override\n") + + result = resolver.resolve_with_source("spec-template") + assert result["source"] == "project override" + + def test_install_from_zip_then_resolve(self, project_dir, pack_dir, temp_dir): + """Test installing from ZIP and then resolving.""" + # Create ZIP + zip_path = temp_dir / "test-pack.zip" + with zipfile.ZipFile(zip_path, 'w') as zf: + for file_path in pack_dir.rglob('*'): + if file_path.is_file(): + arcname = file_path.relative_to(pack_dir) + zf.write(file_path, arcname) + + # Install + manager = PresetManager(project_dir) + manager.install_from_zip(zip_path, "0.1.5") + + # Resolve + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "Custom Spec Template" in result.read_text() + + +# ===== PresetCatalogEntry Tests ===== + + +class TestPresetCatalogEntry: + """Test PresetCatalogEntry dataclass.""" + + def test_create_entry(self): + """Test creating a catalog entry.""" + entry = PresetCatalogEntry( + url="https://example.com/catalog.json", + name="test", + priority=1, + install_allowed=True, + description="Test catalog", + ) + assert entry.url == "https://example.com/catalog.json" + assert entry.name == "test" + assert entry.priority == 1 + assert entry.install_allowed is True + assert entry.description == "Test catalog" + + def test_default_description(self): + """Test default empty description.""" + entry = PresetCatalogEntry( + url="https://example.com/catalog.json", + name="test", + priority=1, + install_allowed=False, + ) + assert entry.description == "" + + +# ===== Multi-Catalog Tests ===== + + +class TestPresetCatalogMultiCatalog: + """Test multi-catalog support in PresetCatalog.""" + + def test_default_active_catalogs(self, project_dir): + """Test that default catalogs are returned when no config exists.""" + catalog = PresetCatalog(project_dir) + active = catalog.get_active_catalogs() + assert len(active) == 2 + assert active[0].name == "default" + assert active[0].priority == 1 + assert active[0].install_allowed is True + assert active[1].name == "community" + assert active[1].priority == 2 + assert active[1].install_allowed is False + + def test_env_var_overrides_catalogs(self, project_dir, monkeypatch): + """Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults.""" + monkeypatch.setenv( + "SPECKIT_PRESET_CATALOG_URL", + "https://custom.example.com/catalog.json", + ) + catalog = PresetCatalog(project_dir) + active = catalog.get_active_catalogs() + assert len(active) == 1 + assert active[0].name == "custom" + assert active[0].url == "https://custom.example.com/catalog.json" + assert active[0].install_allowed is True + + def test_project_config_overrides_defaults(self, project_dir): + """Test that project-level config overrides built-in defaults.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "my-catalog", + "url": "https://my.example.com/catalog.json", + "priority": 1, + "install_allowed": True, + } + ] + })) + + catalog = PresetCatalog(project_dir) + active = catalog.get_active_catalogs() + assert len(active) == 1 + assert active[0].name == "my-catalog" + assert active[0].url == "https://my.example.com/catalog.json" + + def test_load_catalog_config_nonexistent(self, project_dir): + """Test loading config from nonexistent file returns None.""" + catalog = PresetCatalog(project_dir) + result = catalog._load_catalog_config( + project_dir / ".specify" / "nonexistent.yml" + ) + assert result is None + + def test_load_catalog_config_empty(self, project_dir): + """Test loading empty config returns None.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text("") + + catalog = PresetCatalog(project_dir) + result = catalog._load_catalog_config(config_path) + assert result is None + + def test_load_catalog_config_invalid_yaml(self, project_dir): + """Test loading invalid YAML raises error.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(": invalid: {{{") + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="Failed to read"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_not_a_list(self, project_dir): + """Test that non-list catalogs key raises error.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({"catalogs": "not-a-list"})) + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="must be a list"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_invalid_entry(self, project_dir): + """Test that non-dict entry raises error.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({"catalogs": ["not-a-dict"]})) + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="expected a mapping"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_http_url_rejected(self, project_dir): + """Test that HTTP URLs are rejected.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "bad", + "url": "http://insecure.example.com/catalog.json", + "priority": 1, + } + ] + })) + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="must use HTTPS"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_priority_sorting(self, project_dir): + """Test that catalogs are sorted by priority.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "low-priority", + "url": "https://low.example.com/catalog.json", + "priority": 10, + "install_allowed": False, + }, + { + "name": "high-priority", + "url": "https://high.example.com/catalog.json", + "priority": 1, + "install_allowed": True, + }, + ] + })) + + catalog = PresetCatalog(project_dir) + entries = catalog._load_catalog_config(config_path) + assert entries is not None + assert len(entries) == 2 + assert entries[0].name == "high-priority" + assert entries[1].name == "low-priority" + + def test_load_catalog_config_invalid_priority(self, project_dir): + """Test that invalid priority raises error.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "bad", + "url": "https://example.com/catalog.json", + "priority": "not-a-number", + } + ] + })) + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="Invalid priority"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_install_allowed_string(self, project_dir): + """Test that install_allowed accepts string values.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "test", + "url": "https://example.com/catalog.json", + "priority": 1, + "install_allowed": "true", + } + ] + })) + + catalog = PresetCatalog(project_dir) + entries = catalog._load_catalog_config(config_path) + assert entries is not None + assert entries[0].install_allowed is True + + def test_get_catalog_url_uses_highest_priority(self, project_dir): + """Test that get_catalog_url returns URL of highest priority catalog.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "secondary", + "url": "https://secondary.example.com/catalog.json", + "priority": 5, + }, + { + "name": "primary", + "url": "https://primary.example.com/catalog.json", + "priority": 1, + }, + ] + })) + + catalog = PresetCatalog(project_dir) + assert catalog.get_catalog_url() == "https://primary.example.com/catalog.json" + + def test_cache_paths_default_url(self, project_dir): + """Test cache paths for default catalog URL use legacy locations.""" + catalog = PresetCatalog(project_dir) + cache_file, metadata_file = catalog._get_cache_paths( + PresetCatalog.DEFAULT_CATALOG_URL + ) + assert cache_file == catalog.cache_file + assert metadata_file == catalog.cache_metadata_file + + def test_cache_paths_custom_url(self, project_dir): + """Test cache paths for custom URLs use hash-based files.""" + catalog = PresetCatalog(project_dir) + cache_file, metadata_file = catalog._get_cache_paths( + "https://custom.example.com/catalog.json" + ) + assert cache_file != catalog.cache_file + assert "catalog-" in cache_file.name + assert cache_file.name.endswith(".json") + + def test_url_cache_valid(self, project_dir): + """Test URL-specific cache validation.""" + catalog = PresetCatalog(project_dir) + url = "https://custom.example.com/catalog.json" + cache_file, metadata_file = catalog._get_cache_paths(url) + + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps({"schema_version": "1.0", "presets": {}})) + metadata_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + })) + + assert catalog._is_url_cache_valid(url) is True + + def test_url_cache_expired(self, project_dir): + """Test URL-specific cache expiration.""" + catalog = PresetCatalog(project_dir) + url = "https://custom.example.com/catalog.json" + cache_file, metadata_file = catalog._get_cache_paths(url) + + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps({"schema_version": "1.0", "presets": {}})) + metadata_file.write_text(json.dumps({ + "cached_at": "2020-01-01T00:00:00+00:00", + })) + + assert catalog._is_url_cache_valid(url) is False + + +# ===== Self-Test Preset Tests ===== + + +SELF_TEST_PRESET_DIR = Path(__file__).parent.parent / "presets" / "self-test" + +CORE_TEMPLATE_NAMES = [ + "spec-template", + "plan-template", + "tasks-template", + "checklist-template", + "constitution-template", + "agent-file-template", +] + + +class TestSelfTestPreset: + """Tests using the self-test preset that ships with the repo.""" + + def test_self_test_preset_exists(self): + """Verify the self-test preset directory and manifest exist.""" + assert SELF_TEST_PRESET_DIR.exists() + assert (SELF_TEST_PRESET_DIR / "preset.yml").exists() + + def test_self_test_manifest_valid(self): + """Verify the self-test preset manifest is valid.""" + manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") + assert manifest.id == "self-test" + assert manifest.name == "Self-Test Preset" + assert manifest.version == "1.0.0" + assert len(manifest.templates) == 7 # 6 templates + 1 command + + def test_self_test_provides_all_core_templates(self): + """Verify the self-test preset provides an override for every core template.""" + manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") + provided_names = {t["name"] for t in manifest.templates} + for name in CORE_TEMPLATE_NAMES: + assert name in provided_names, f"Self-test preset missing template: {name}" + + def test_self_test_template_files_exist(self): + """Verify that all declared template files actually exist on disk.""" + manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") + for tmpl in manifest.templates: + tmpl_path = SELF_TEST_PRESET_DIR / tmpl["file"] + assert tmpl_path.exists(), f"Missing template file: {tmpl['file']}" + + def test_self_test_templates_have_marker(self): + """Verify each template contains the preset:self-test marker.""" + for name in CORE_TEMPLATE_NAMES: + tmpl_path = SELF_TEST_PRESET_DIR / "templates" / f"{name}.md" + content = tmpl_path.read_text() + assert "preset:self-test" in content, f"{name}.md missing preset:self-test marker" + + def test_install_self_test_preset(self, project_dir): + """Test installing the self-test preset from its directory.""" + manager = PresetManager(project_dir) + manifest = manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + assert manifest.id == "self-test" + assert manager.registry.is_installed("self-test") + + def test_self_test_overrides_all_core_templates(self, project_dir): + """Test that installing self-test overrides every core template.""" + # Set up core templates in the project + templates_dir = project_dir / ".specify" / "templates" + for name in CORE_TEMPLATE_NAMES: + (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") + + # Install self-test preset + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + + # Every core template should now resolve from the preset + resolver = PresetResolver(project_dir) + for name in CORE_TEMPLATE_NAMES: + result = resolver.resolve(name) + assert result is not None, f"{name} did not resolve" + content = result.read_text() + assert "preset:self-test" in content, ( + f"{name} resolved but not from self-test preset" + ) + + def test_self_test_resolve_with_source(self, project_dir): + """Test that resolve_with_source attributes templates to self-test.""" + templates_dir = project_dir / ".specify" / "templates" + for name in CORE_TEMPLATE_NAMES: + (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + + resolver = PresetResolver(project_dir) + for name in CORE_TEMPLATE_NAMES: + result = resolver.resolve_with_source(name) + assert result is not None, f"{name} did not resolve" + assert "self-test" in result["source"], ( + f"{name} source is '{result['source']}', expected self-test" + ) + + def test_self_test_removal_restores_core(self, project_dir): + """Test that removing self-test falls back to core templates.""" + templates_dir = project_dir / ".specify" / "templates" + for name in CORE_TEMPLATE_NAMES: + (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + manager.remove("self-test") + + resolver = PresetResolver(project_dir) + for name in CORE_TEMPLATE_NAMES: + result = resolver.resolve_with_source(name) + assert result is not None + assert result["source"] == "core" + + def test_self_test_not_in_catalog(self): + """Verify the self-test preset is NOT in the catalog (it's local-only).""" + catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" + catalog_data = json.loads(catalog_path.read_text()) + assert "self-test" not in catalog_data["presets"] + + def test_self_test_has_command(self): + """Verify the self-test preset includes a command override.""" + manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") + commands = [t for t in manifest.templates if t["type"] == "command"] + assert len(commands) >= 1 + assert commands[0]["name"] == "speckit.specify" + + def test_self_test_command_file_exists(self): + """Verify the self-test command file exists on disk.""" + cmd_path = SELF_TEST_PRESET_DIR / "commands" / "speckit.specify.md" + assert cmd_path.exists() + content = cmd_path.read_text() + assert "preset:self-test" in content + + def test_self_test_registers_commands_for_claude(self, project_dir): + """Test that installing self-test registers commands in .claude/commands/.""" + # Create Claude agent directory to simulate Claude being set up + claude_dir = project_dir / ".claude" / "commands" + claude_dir.mkdir(parents=True) + + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + + # Check the command was registered + cmd_file = claude_dir / "speckit.specify.md" + assert cmd_file.exists(), "Command not registered in .claude/commands/" + content = cmd_file.read_text() + assert "preset:self-test" in content + + def test_self_test_registers_commands_for_gemini(self, project_dir): + """Test that installing self-test registers commands in .gemini/commands/ as TOML.""" + # Create Gemini agent directory + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + + # Check the command was registered in TOML format + cmd_file = gemini_dir / "speckit.specify.toml" + assert cmd_file.exists(), "Command not registered in .gemini/commands/" + content = cmd_file.read_text() + assert "prompt" in content # TOML format has a prompt field + assert "{{args}}" in content # Gemini uses {{args}} placeholder + + def test_self_test_unregisters_commands_on_remove(self, project_dir): + """Test that removing self-test cleans up registered commands.""" + claude_dir = project_dir / ".claude" / "commands" + claude_dir.mkdir(parents=True) + + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + + cmd_file = claude_dir / "speckit.specify.md" + assert cmd_file.exists() + + manager.remove("self-test") + assert not cmd_file.exists(), "Command not cleaned up after preset removal" + + def test_self_test_no_commands_without_agent_dirs(self, project_dir): + """Test that no commands are registered when no agent dirs exist.""" + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + + metadata = manager.registry.get("self-test") + assert metadata["registered_commands"] == {} + + def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir): + """Test that extension command overrides are skipped if the extension isn't installed.""" + claude_dir = project_dir / ".claude" / "commands" + claude_dir.mkdir(parents=True) + + preset_dir = temp_dir / "ext-override-preset" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Override fakeext cmd\n---\nOverridden content" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "ext-override", + "name": "Ext Override", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + "description": "Override fakeext cmd", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + # Extension not installed — command should NOT be registered + cmd_file = claude_dir / "speckit.fakeext.cmd.md" + assert not cmd_file.exists(), "Command registered for missing extension" + metadata = manager.registry.get("ext-override") + assert metadata["registered_commands"] == {} + + def test_extension_command_registered_when_extension_present(self, project_dir, temp_dir): + """Test that extension command overrides ARE registered when the extension is installed.""" + claude_dir = project_dir / ".claude" / "commands" + claude_dir.mkdir(parents=True) + (project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True) + + preset_dir = temp_dir / "ext-override-preset2" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Override fakeext cmd\n---\nOverridden content" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "ext-override2", + "name": "Ext Override", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + "description": "Override fakeext cmd", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + cmd_file = claude_dir / "speckit.fakeext.cmd.md" + assert cmd_file.exists(), "Command not registered despite extension being present" + + +# ===== Init Options and Skills Tests ===== + + +class TestInitOptions: + """Tests for save_init_options / load_init_options helpers.""" + + def test_save_and_load_round_trip(self, project_dir): + from specify_cli import save_init_options, load_init_options + + opts = {"ai": "claude", "ai_skills": True, "here": False} + save_init_options(project_dir, opts) + + loaded = load_init_options(project_dir) + assert loaded["ai"] == "claude" + assert loaded["ai_skills"] is True + + def test_load_returns_empty_when_missing(self, project_dir): + from specify_cli import load_init_options + + assert load_init_options(project_dir) == {} + + def test_load_returns_empty_on_invalid_json(self, project_dir): + from specify_cli import load_init_options + + opts_file = project_dir / ".specify" / "init-options.json" + opts_file.parent.mkdir(parents=True, exist_ok=True) + opts_file.write_text("{bad json") + + assert load_init_options(project_dir) == {} + + +class TestPresetSkills: + """Tests for preset skill registration and unregistration.""" + + def _write_init_options(self, project_dir, ai="claude", ai_skills=True): + from specify_cli import save_init_options + + save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills}) + + def _create_skill(self, skills_dir, skill_name, body="original body"): + skill_dir = skills_dir / skill_name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text( + f"---\nname: {skill_name}\n---\n\n{body}\n" + ) + return skill_dir + + def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): + """When --ai-skills was used, a preset command override should update the skill.""" + # Simulate --ai-skills having been used: write init-options + create skill + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + # Also create the claude commands dir so commands get registered + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + # Install self-test preset (has a command override for speckit.specify) + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "preset:self-test" in content, "Skill should reference preset source" + + # Verify it was recorded in registry + metadata = manager.registry.get("self-test") + assert "speckit-specify" in metadata.get("registered_skills", []) + + def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir): + """When --ai-skills was NOT used, preset install should not touch skills.""" + self._write_init_options(project_dir, ai="claude", ai_skills=False) + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="untouched") + + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + content = skill_file.read_text() + assert "untouched" in content, "Skill should not be modified when ai_skills=False" + + def test_skill_not_updated_without_init_options(self, project_dir, temp_dir): + """When no init-options.json exists, preset install should not touch skills.""" + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="untouched") + + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + content = skill_file.read_text() + assert "untouched" in content + + def test_skill_restored_on_preset_remove(self, project_dir, temp_dir): + """When a preset is removed, skills should be restored from core templates.""" + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + # Set up core command template in the project so restoration works + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text("---\ndescription: Core specify command\n---\n\nCore specify body\n") + + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + + # Verify preset content is in the skill + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:self-test" in skill_file.read_text() + + # Remove the preset + manager.remove("self-test") + + # Skill should be restored (core specify.md template exists) + assert skill_file.exists(), "Skill should still exist after preset removal" + content = skill_file.read_text() + assert "preset:self-test" not in content, "Preset content should be gone" + assert "templates/commands/specify.md" in content, "Should reference core template" + + def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_dir): + """Skills should not be created when no existing skill dir is found.""" + self._write_init_options(project_dir, ai="claude") + # Don't create skills dir — simulate --ai-skills never created them + + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + + metadata = manager.registry.get("self-test") + assert metadata.get("registered_skills", []) == [] From f92d81bbec62120c83c5976c8eabc298dca5ecf8 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:17:19 -0500 Subject: [PATCH 10/11] chore: bump version to 0.3.0 (#1839) * chore: bump version to 0.3.0 * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 24 ++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89918e4c..4b1b6a47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ Recent changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-03-13 + +### Changed + +- No changes have been documented for this release yet. + + +- make c ignores consistent with c++ (#1747) +- chore: bump version to 0.1.13 (#1746) +- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690) +- feat: add verify extension to community catalog (#1726) +- Add Retrospective Extension to community catalog README table (#1741) +- fix(scripts): add empty description validation and branch checkout error handling (#1559) +- fix: correct Copilot extension command registration (#1724) +- fix(implement): remove Makefile from C ignore patterns (#1558) +- Add sync extension to community catalog (#1728) +- fix(checklist): clarify file handling behavior for append vs create (#1556) +- fix(clarify): correct conflicting question limit from 10 to 5 (#1557) +- chore: bump version to 0.1.12 (#1737) +- fix: use RELEASE_PAT so tag push triggers release workflow (#1736) +- fix: release-trigger uses release branch + PR instead of direct push to main (#1733) +- fix: Split release process to sync pyproject.toml version with git tags (#1732) + + ## [Unreleased] ### Added diff --git a/pyproject.toml b/pyproject.toml index 04a6791a..cdbad2e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.2.1" +version = "0.3.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 = [ From 4a3234496e9f58ce825cc5ca3a3a9c6fd45df222 Mon Sep 17 00:00:00 2001 From: Ricardo Accioly <63126795+raccioly@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:41:55 -0600 Subject: [PATCH 11/11] feat: Add DocGuard CDD enforcement extension to community catalog (#1838) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add DocGuard CDD enforcement extension to community catalog DocGuard is a Canonical-Driven Development enforcement tool that generates, validates, scores, and traces project documentation against 51 automated checks. Provides 6 commands: - guard: 51-check validation with quality labels - diagnose: AI-ready fix prompts - score: CDD maturity scoring (0-100) - trace: ISO 29119 traceability matrix - generate: Reverse-engineer docs from codebase - init: Initialize CDD with compliance profiles Features: - Zero dependencies (pure Node.js) - Config-aware traceability (respects .docguard.json) - Orphan file detection - Research-backed (AITPG/TRACE, IEEE TSE/TMLCN 2026) npm: https://www.npmjs.com/package/docguard-cli GitHub: https://github.com/raccioly/docguard * fix: use release asset URL for download_url The source archive URL nests files under a subdirectory, so the Spec Kit installer cannot find extension.yml at the archive root. Switch to a release asset ZIP built from the extension directory. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * docs: add DocGuard to community extensions README table * chore: update DocGuard entry to v0.8.0 (92 checks) * chore: update DocGuard description (51→92 checks) --------- Co-authored-by: Ricardo Accioly Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/README.md | 1 + extensions/catalog.community.json | 66 +++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/extensions/README.md b/extensions/README.md index 4c3f9d80..30fc7ca6 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -74,6 +74,7 @@ The following community-contributed extensions are available in [`catalog.commun |-----------|---------|-----| | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | +| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Generates, validates, scores, and traces project documentation against 92 automated checks with config-aware traceability, quality labels, and AI-ready fix prompts. Zero dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index f1e0a092..dc0e82a0 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -74,6 +74,48 @@ "created_at": "2026-02-22T00:00:00Z", "updated_at": "2026-02-22T00:00:00Z" }, + "docguard": { + "name": "DocGuard — CDD Enforcement", + "id": "docguard", + "description": "Canonical-Driven Development enforcement. Generates, validates, scores, and traces project documentation against 92 automated checks. Zero dependencies.", + "author": "raccioly", + "version": "0.8.0", + "download_url": "https://github.com/raccioly/docguard/releases/download/v0.8.0/spec-kit-docguard-v0.8.0.zip", + "repository": "https://github.com/raccioly/docguard", + "homepage": "https://www.npmjs.com/package/docguard-cli", + "documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md", + "changelog": "https://github.com/raccioly/docguard/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "node", + "version": ">=18.0.0", + "required": true + } + ] + }, + "provides": { + "commands": 6, + "hooks": 1 + }, + "tags": [ + "documentation", + "validation", + "quality", + "cdd", + "traceability", + "ai-agents", + "enforcement", + "scoring" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-13T00:00:00Z", + "updated_at": "2026-03-13T00:00:00Z" + }, "doctor": { "name": "Project Health Check", "id": "doctor", @@ -124,7 +166,12 @@ "commands": 2, "hooks": 1 }, - "tags": ["orchestration", "workflow", "human-in-the-loop", "parallel"], + "tags": [ + "orchestration", + "workflow", + "human-in-the-loop", + "parallel" + ], "verified": false, "downloads": 0, "stars": 0, @@ -191,7 +238,12 @@ "commands": 2, "hooks": 1 }, - "tags": ["implementation", "automation", "loop", "copilot"], + "tags": [ + "implementation", + "automation", + "loop", + "copilot" + ], "verified": false, "downloads": 0, "stars": 0, @@ -249,7 +301,15 @@ "commands": 7, "hooks": 1 }, - "tags": ["code-review", "quality", "review", "testing", "error-handling", "type-design", "simplification"], + "tags": [ + "code-review", + "quality", + "review", + "testing", + "error-handling", + "type-design", + "simplification" + ], "verified": false, "downloads": 0, "stars": 0,