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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: iamaeroplane <michal.bachorik@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michal Bachorik
2026-03-13 13:23:37 +01:00
committed by GitHub
parent 82f8a13f83
commit 58ce653908
4 changed files with 1238 additions and 288 deletions

View File

@@ -1,9 +1,9 @@
# RFC: Spec Kit Extension System # RFC: Spec Kit Extension System
**Status**: Draft **Status**: Implemented
**Author**: Stats Perform Engineering **Author**: Stats Perform Engineering
**Created**: 2026-01-28 **Created**: 2026-01-28
**Updated**: 2026-01-28 **Updated**: 2026-03-11
--- ---
@@ -24,8 +24,9 @@
13. [Security Considerations](#security-considerations) 13. [Security Considerations](#security-considerations)
14. [Migration Strategy](#migration-strategy) 14. [Migration Strategy](#migration-strategy)
15. [Implementation Phases](#implementation-phases) 15. [Implementation Phases](#implementation-phases)
16. [Open Questions](#open-questions) 16. [Resolved Questions](#resolved-questions)
17. [Appendices](#appendices) 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 ## Implementation Phases
### Phase 1: Core Extension System (Week 1-2) ### Phase 1: Core Extension System ✅ COMPLETED
**Goal**: Basic extension infrastructure **Goal**: Basic extension infrastructure
**Deliverables**: **Deliverables**:
- [ ] Extension manifest schema (`extension.yml`) - [x] Extension manifest schema (`extension.yml`)
- [ ] Extension directory structure - [x] Extension directory structure
- [ ] CLI commands: - [x] CLI commands:
- [ ] `specify extension list` - [x] `specify extension list`
- [ ] `specify extension add` (from URL) - [x] `specify extension add` (from URL and local `--dev`)
- [ ] `specify extension remove` - [x] `specify extension remove`
- [ ] Extension registry (`.specify/extensions/.registry`) - [x] Extension registry (`.specify/extensions/.registry`)
- [ ] Command registration (Claude only initially) - [x] Command registration (Claude and 15+ other agents)
- [ ] Basic validation (manifest schema, compatibility) - [x] Basic validation (manifest schema, compatibility)
- [ ] Documentation (extension development guide) - [x] Documentation (extension development guide)
**Testing**: **Testing**:
- [ ] Unit tests for manifest parsing - [x] Unit tests for manifest parsing
- [ ] Integration test: Install dummy extension - [x] Integration test: Install dummy extension
- [ ] Integration test: Register commands with Claude - [x] Integration test: Register commands with Claude
### Phase 2: Jira Extension (Week 3) ### Phase 2: Jira Extension ✅ COMPLETED
**Goal**: First production extension **Goal**: First production extension
**Deliverables**: **Deliverables**:
- [ ] Create `spec-kit-jira` repository - [x] Create `spec-kit-jira` repository
- [ ] Port Jira functionality to extension - [x] Port Jira functionality to extension
- [ ] Create `jira-config.yml` template - [x] Create `jira-config.yml` template
- [ ] Commands: - [x] Commands:
- [ ] `specstoissues.md` - [x] `specstoissues.md`
- [ ] `discover-fields.md` - [x] `discover-fields.md`
- [ ] `sync-status.md` - [x] `sync-status.md`
- [ ] Helper scripts - [x] Helper scripts
- [ ] Documentation (README, configuration guide, examples) - [x] Documentation (README, configuration guide, examples)
- [ ] Release v1.0.0 - [x] Release v3.0.0
**Testing**: **Testing**:
- [ ] Test on `eng-msa-ts` project - [x] Test on `eng-msa-ts` project
- [ ] Verify spec→Epic, phase→Story, task→Issue mapping - [x] Verify spec→Epic, phase→Story, task→Issue mapping
- [ ] Test configuration loading and validation - [x] Test configuration loading and validation
- [ ] Test custom field application - [x] Test custom field application
### Phase 3: Extension Catalog (Week 4) ### Phase 3: Extension Catalog ✅ COMPLETED
**Goal**: Discovery and distribution **Goal**: Discovery and distribution
**Deliverables**: **Deliverables**:
- [ ] Central catalog (`extensions/catalog.json` in spec-kit repo) - [x] Central catalog (`extensions/catalog.json` in spec-kit repo)
- [ ] Catalog fetch and parsing - [x] Community catalog (`extensions/catalog.community.json`)
- [ ] CLI commands: - [x] Catalog fetch and parsing with multi-catalog support
- [ ] `specify extension search` - [x] CLI commands:
- [ ] `specify extension info` - [x] `specify extension search`
- [ ] Catalog publishing process (GitHub Action) - [x] `specify extension info`
- [ ] Documentation (how to publish extensions) - [x] `specify extension catalog list`
- [x] `specify extension catalog add`
- [x] `specify extension catalog remove`
- [x] Documentation (how to publish extensions)
**Testing**: **Testing**:
- [ ] Test catalog fetch - [x] Test catalog fetch
- [ ] Test extension search/filtering - [x] Test extension search/filtering
- [ ] Test catalog caching - [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 **Goal**: Hooks, updates, multi-agent support
**Deliverables**: **Deliverables**:
- [ ] Hook system (`hooks` in extension.yml) - [x] Hook system (`hooks` in extension.yml)
- [ ] Hook registration and execution - [x] Hook registration and execution
- [ ] Project extensions config (`.specify/extensions.yml`) - [x] Project extensions config (`.specify/extensions.yml`)
- [ ] CLI commands: - [x] CLI commands:
- [ ] `specify extension update` - [x] `specify extension update` (with atomic backup/restore)
- [ ] `specify extension enable/disable` - [x] `specify extension enable/disable`
- [ ] Command registration for multiple agents (Gemini, Copilot) - [x] Command registration for multiple agents (15+ agents including Claude, Copilot, Gemini, Cursor, etc.)
- [ ] Extension update notifications - [x] Extension update notifications (version comparison)
- [ ] Configuration layer resolution (project, local, env) - [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**: **Testing**:
- [ ] Test hooks in core commands - [x] Test hooks in core commands
- [ ] Test extension updates (preserve config) - [x] Test extension updates (preserve config)
- [ ] Test multi-agent registration - [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 **Goal**: Production ready
**Deliverables**: **Deliverables**:
- [ ] Comprehensive documentation: - [x] Comprehensive documentation:
- [ ] User guide (installing/using extensions) - [x] User guide (EXTENSION-USER-GUIDE.md)
- [ ] Extension development guide - [x] Extension development guide (EXTENSION-DEV-GUIDE.md)
- [ ] Extension API reference - [x] Extension API reference (EXTENSION-API-REFERENCE.md)
- [ ] Migration guide (core → extension) - [x] Error messages and validation improvements
- [ ] Error messages and validation improvements - [x] CLI help text updates
- [ ] CLI help text updates
- [ ] Example extension template (cookiecutter)
- [ ] Blog post / announcement
- [ ] Video tutorial
**Testing**: **Testing**:
- [ ] End-to-end testing on multiple projects - [x] End-to-end testing on multiple projects
- [ ] Community beta testing - [x] 163 unit tests passing
- [ ] Performance testing (large projects)
--- ---
## 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? **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) **Implementation**: The `aliases` field in `extension.yml` allows extensions to register additional command names.
- B) Short alias: `/jira.specstoissues` (shorter, less verbose)
- C) Both: Register both names, prefer prefixed in docs
**Recommendation**: C (both), prefixed is canonical
--- ---
### 2. Config File Location ### 2. Config File Location ✅ RESOLVED
**Question**: Where should extension configs live? **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) **Implementation**: Each extension has its own config file within its directory, with layered resolution (defaults → project → local → env vars).
- B) Root level: `.specify/jira-config.yml` (more visible)
- C) Unified: `.specify/extensions.yml` (all extension configs in one file)
**Recommendation**: A (extension directory), cleaner separation
--- ---
### 3. Command File Format ### 3. Command File Format ✅ RESOLVED
**Question**: Should extensions use universal format or agent-specific? **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 **Implementation**: `CommandRegistrar` class handles conversion to 15+ agent formats (Claude, Copilot, Gemini, Cursor, etc.).
- B) Agent-specific: Extensions provide separate files for each agent
- C) Hybrid: Universal default, agent-specific overrides
**Recommendation**: A (universal), reduces duplication
--- ---
### 4. Hook Execution Model ### 4. Hook Execution Model ✅ RESOLVED
**Question**: How should hooks execute? **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` **Implementation**: `HookExecutor` class manages hook registration and state in `extensions.yml`.
- B) CLI executes: Core commands call `specify extension hook after_tasks`
- C) Agent built-in: Extension system built into AI agent (Claude SDK)
**Recommendation**: A initially (simpler), move to C long-term
--- ---
### 5. Extension Distribution ### 5. Extension Distribution ✅ RESOLVED
**Question**: How should extensions be packaged? **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 **Implementation**: `ExtensionManager.install_from_zip()` handles ZIP extraction and validation.
- B) Git repos: Cloned directly (`git clone`)
- C) Python packages: Installable via `uv tool install`
**Recommendation**: A (ZIP), simpler for non-Python extensions in future
--- ---
### 6. Multi-Version Support ### 6. Multi-Version Support ✅ RESOLVED
**Question**: Can multiple versions of same extension coexist? **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**: **Options**:
- A) Single version: Only one version installed at a time - A) No sandboxing (current): Extensions run with same privileges as AI agent
- B) Multi-version: Side-by-side versions (`.specify/extensions/jira@1.0/`, `.specify/extensions/jira@2.0/`) - B) Permission declarations: Extensions declare `filesystem:read`, `network:external`, etc.
- C) Per-branch: Different branches use different versions - 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.
--- ---

View File

@@ -1813,6 +1813,126 @@ def get_speckit_version() -> str:
return "unknown" 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} <extension-id>[/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} <extension-id>[/bold]")
raise typer.Exit(1)
# Not found
return (None, None)
except ExtensionError as e:
return (None, e)
@extension_app.command("list") @extension_app.command("list")
def extension_list( def extension_list(
available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"), available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"),
@@ -2111,8 +2231,11 @@ def extension_add(
# Install from catalog # Install from catalog
catalog = ExtensionCatalog(project_root) catalog = ExtensionCatalog(project_root)
# Check if extension exists in catalog # Check if extension exists in catalog (supports both ID and display name)
ext_info = catalog.get_extension_info(extension) 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: if not ext_info:
console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog") console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog")
console.print("\nSearch available extensions:") console.print("\nSearch available extensions:")
@@ -2132,9 +2255,10 @@ def extension_add(
) )
raise typer.Exit(1) 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')}...") 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: try:
# Install from downloaded ZIP # Install from downloaded ZIP
@@ -2167,7 +2291,7 @@ def extension_add(
@extension_app.command("remove") @extension_app.command("remove")
def extension_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"), keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"),
force: bool = typer.Option(False, "--force", help="Skip confirmation"), force: bool = typer.Option(False, "--force", help="Skip confirmation"),
): ):
@@ -2185,25 +2309,19 @@ def extension_remove(
manager = ExtensionManager(project_root) manager = ExtensionManager(project_root)
# Check if extension is installed # Resolve extension ID from argument (handles ambiguous names)
if not manager.registry.is_installed(extension): installed = manager.list_installed()
console.print(f"[red]Error:[/red] Extension '{extension}' is not installed") extension_id, display_name = _resolve_installed_extension(extension, installed, "remove")
raise typer.Exit(1)
# Get extension info # Get extension info for command count
ext_manifest = manager.get_extension(extension) ext_manifest = manager.get_extension(extension_id)
if ext_manifest: cmd_count = len(ext_manifest.commands) if ext_manifest else 0
ext_name = ext_manifest.name
cmd_count = len(ext_manifest.commands)
else:
ext_name = extension
cmd_count = 0
# Confirm removal # Confirm removal
if not force: if not force:
console.print("\n[yellow]⚠ This will remove:[/yellow]") console.print("\n[yellow]⚠ This will remove:[/yellow]")
console.print(f"{cmd_count} commands from AI agent") console.print(f"{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: if not keep_config:
console.print(" • Config files (will be backed up)") console.print(" • Config files (will be backed up)")
console.print() console.print()
@@ -2214,15 +2332,15 @@ def extension_remove(
raise typer.Exit(0) raise typer.Exit(0)
# Remove extension # Remove extension
success = manager.remove(extension, keep_config=keep_config) success = manager.remove(extension_id, keep_config=keep_config)
if success: 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: 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: else:
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension}/") console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension_id}/")
console.print(f"\nTo reinstall: specify extension add {extension}") console.print(f"\nTo reinstall: specify extension add {extension_id}")
else: else:
console.print("[red]Error:[/red] Failed to remove extension") console.print("[red]Error:[/red] Failed to remove extension")
raise typer.Exit(1) raise typer.Exit(1)
@@ -2320,7 +2438,7 @@ def extension_info(
extension: str = typer.Argument(help="Extension ID or name"), extension: str = typer.Argument(help="Extension ID or name"),
): ):
"""Show detailed information about an extension.""" """Show detailed information about an extension."""
from .extensions import ExtensionCatalog, ExtensionManager, ExtensionError from .extensions import ExtensionCatalog, ExtensionManager
project_root = Path.cwd() project_root = Path.cwd()
@@ -2333,118 +2451,181 @@ def extension_info(
catalog = ExtensionCatalog(project_root) catalog = ExtensionCatalog(project_root)
manager = ExtensionManager(project_root) manager = ExtensionManager(project_root)
installed = manager.list_installed()
try: # Try to resolve from installed extensions first (by ID or name)
ext_info = catalog.get_extension_info(extension) # 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: # Try catalog lookup (with error handling)
console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog") # If we resolved an installed extension by display name, use its ID for catalog lookup
console.print("\nTry: specify extension search") # to ensure we get the correct catalog entry (not a different extension with same name)
raise typer.Exit(1) lookup_key = resolved_installed_id if resolved_installed_id else extension
ext_info, catalog_error = _resolve_catalog_extension(lookup_key, catalog, "info")
# Header # Case 1: Found in catalog - show full catalog info
verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else "" if ext_info:
console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}") _print_extension_info(ext_info, manager)
console.print(f"ID: {ext_info['id']}") 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() console.print()
# Description if ext_manifest:
console.print(f"{ext_info['description']}") console.print(f"{ext_manifest.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() 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_manifest.commands:
if ext_info.get('provides'): console.print("[bold]Commands:[/bold]")
console.print("[bold]Provides:[/bold]") for cmd in ext_manifest.commands:
provides = ext_info['provides'] console.print(f"{cmd['name']}: {cmd.get('description', '')}")
if provides.get('commands'): console.print()
console.print(f" • Commands: {provides['commands']}")
if provides.get('hooks'):
console.print(f" • Hooks: {provides['hooks']}")
console.print()
# Tags # Show catalog status
if ext_info.get('tags'): if catalog_error:
tags_str = ", ".join(ext_info['tags']) console.print(f"[yellow]Catalog unavailable:[/yellow] {catalog_error}")
console.print(f"[bold]Tags:[/bold] {tags_str}") console.print("[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]")
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: else:
catalog_name = ext_info.get("_catalog_name", "community") console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)")
console.print("[yellow]Not installed[/yellow]")
console.print(
f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog "
f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml "
f"with install_allowed: true to enable installation."
)
except ExtensionError as e: console.print()
console.print(f"\n[red]Error:[/red] {e}") console.print("[green]✓ Installed[/green]")
raise typer.Exit(1) 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") @extension_app.command("update")
def extension_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.""" """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 from packaging import version as pkg_version
import shutil
project_root = Path.cwd() project_root = Path.cwd()
@@ -2457,18 +2638,17 @@ def extension_update(
manager = ExtensionManager(project_root) manager = ExtensionManager(project_root)
catalog = ExtensionCatalog(project_root) catalog = ExtensionCatalog(project_root)
speckit_version = get_speckit_version()
try: try:
# Get list of extensions to update # Get list of extensions to update
installed = manager.list_installed()
if extension: if extension:
# Update specific extension # Update specific extension - resolve ID from argument (handles ambiguous names)
if not manager.registry.is_installed(extension): extension_id, _ = _resolve_installed_extension(extension, installed, "update")
console.print(f"[red]Error:[/red] Extension '{extension}' is not installed") extensions_to_update = [extension_id]
raise typer.Exit(1)
extensions_to_update = [extension]
else: else:
# Update all extensions # Update all extensions
installed = manager.list_installed()
extensions_to_update = [ext["id"] for ext in installed] extensions_to_update = [ext["id"] for ext in installed]
if not extensions_to_update: if not extensions_to_update:
@@ -2482,7 +2662,16 @@ def extension_update(
for ext_id in extensions_to_update: for ext_id in extensions_to_update:
# Get installed version # Get installed version
metadata = manager.registry.get(ext_id) 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 # Get catalog info
ext_info = catalog.get_extension_info(ext_id) 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)") console.print(f"{ext_id}: Not found in catalog (skipping)")
continue 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: if catalog_version > installed_version:
updates_available.append( updates_available.append(
{ {
"id": ext_id, "id": ext_id,
"name": ext_info.get("name", ext_id), # Display name for status messages
"installed": str(installed_version), "installed": str(installed_version),
"available": str(catalog_version), "available": str(catalog_version),
"download_url": ext_info.get("download_url"), "download_url": ext_info.get("download_url"),
@@ -2521,25 +2722,288 @@ def extension_update(
console.print("Cancelled") console.print("Cancelled")
raise typer.Exit(0) raise typer.Exit(0)
# Perform updates # Perform updates with atomic backup/restore
console.print() console.print()
updated_extensions = []
failed_updates = []
registrar = CommandRegistrar()
hook_executor = HookExecutor(project_root)
for update in updates_available: for update in updates_available:
ext_id = update["id"] extension_id = update["id"]
console.print(f"📦 Updating {ext_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 # Backup paths
# For now, just show message backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update"
console.print( backup_ext_dir = backup_base / "extension"
"[yellow]Note:[/yellow] Automatic update not yet implemented. " backup_commands_dir = backup_base / "commands"
"Please update manually:" backup_config_dir = backup_base / "config"
)
console.print(f" specify extension remove {ext_id} --keep-config")
console.print(f" specify extension add {ext_id}")
console.print( # Store backup state
"\n[cyan]Tip:[/cyan] Automatic updates will be available in a future version" 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: except ExtensionError as e:
console.print(f"\n[red]Error:[/red] {e}") console.print(f"\n[red]Error:[/red] {e}")
raise typer.Exit(1) raise typer.Exit(1)
@@ -2547,7 +3011,7 @@ def extension_update(
@extension_app.command("enable") @extension_app.command("enable")
def extension_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.""" """Enable a disabled extension."""
from .extensions import ExtensionManager, HookExecutor from .extensions import ExtensionManager, HookExecutor
@@ -2564,34 +3028,38 @@ def extension_enable(
manager = ExtensionManager(project_root) manager = ExtensionManager(project_root)
hook_executor = HookExecutor(project_root) hook_executor = HookExecutor(project_root)
if not manager.registry.is_installed(extension): # Resolve extension ID from argument (handles ambiguous names)
console.print(f"[red]Error:[/red] Extension '{extension}' is not installed") installed = manager.list_installed()
raise typer.Exit(1) extension_id, display_name = _resolve_installed_extension(extension, installed, "enable")
# Update registry # 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): 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) raise typer.Exit(0)
metadata["enabled"] = True metadata["enabled"] = True
manager.registry.add(extension, metadata) manager.registry.update(extension_id, metadata)
# Enable hooks in extensions.yml # Enable hooks in extensions.yml
config = hook_executor.get_project_config() config = hook_executor.get_project_config()
if "hooks" in config: if "hooks" in config:
for hook_name in config["hooks"]: for hook_name in config["hooks"]:
for hook in config["hooks"][hook_name]: for hook in config["hooks"][hook_name]:
if hook.get("extension") == extension: if hook.get("extension") == extension_id:
hook["enabled"] = True hook["enabled"] = True
hook_executor.save_project_config(config) 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") @extension_app.command("disable")
def extension_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.""" """Disable an extension without removing it."""
from .extensions import ExtensionManager, HookExecutor from .extensions import ExtensionManager, HookExecutor
@@ -2608,31 +3076,35 @@ def extension_disable(
manager = ExtensionManager(project_root) manager = ExtensionManager(project_root)
hook_executor = HookExecutor(project_root) hook_executor = HookExecutor(project_root)
if not manager.registry.is_installed(extension): # Resolve extension ID from argument (handles ambiguous names)
console.print(f"[red]Error:[/red] Extension '{extension}' is not installed") installed = manager.list_installed()
raise typer.Exit(1) extension_id, display_name = _resolve_installed_extension(extension, installed, "disable")
# Update registry # 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): 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) raise typer.Exit(0)
metadata["enabled"] = False metadata["enabled"] = False
manager.registry.add(extension, metadata) manager.registry.update(extension_id, metadata)
# Disable hooks in extensions.yml # Disable hooks in extensions.yml
config = hook_executor.get_project_config() config = hook_executor.get_project_config()
if "hooks" in config: if "hooks" in config:
for hook_name in config["hooks"]: for hook_name in config["hooks"]:
for hook in config["hooks"][hook_name]: for hook in config["hooks"][hook_name]:
if hook.get("extension") == extension: if hook.get("extension") == extension_id:
hook["enabled"] = False hook["enabled"] = False
hook_executor.save_project_config(config) 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("\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(): def main():

View File

@@ -12,6 +12,7 @@ import os
import tempfile import tempfile
import zipfile import zipfile
import shutil import shutil
import copy
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, List, Any, Callable, Set from typing import Optional, Dict, List, Any, Callable, Set
@@ -228,6 +229,54 @@ class ExtensionRegistry:
} }
self._save() 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): def remove(self, extension_id: str):
"""Remove extension from registry. """Remove extension from registry.
@@ -241,21 +290,28 @@ class ExtensionRegistry:
def get(self, extension_id: str) -> Optional[dict]: def get(self, extension_id: str) -> Optional[dict]:
"""Get extension metadata from registry. """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: Args:
extension_id: Extension ID extension_id: Extension ID
Returns: 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]: def list(self) -> Dict[str, dict]:
"""Get all installed extensions. """Get all installed extensions.
Returns a deep copy of the extensions mapping to prevent callers
from accidentally mutating nested internal registry state.
Returns: 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: def is_installed(self, extension_id: str) -> bool:
"""Check if extension is installed. """Check if extension is installed.
@@ -600,7 +656,7 @@ class ExtensionManager:
result.append({ result.append({
"id": ext_id, "id": ext_id,
"name": manifest.name, "name": manifest.name,
"version": metadata["version"], "version": metadata.get("version", "unknown"),
"description": manifest.description, "description": manifest.description,
"enabled": metadata.get("enabled", True), "enabled": metadata.get("enabled", True),
"installed_at": metadata.get("installed_at"), "installed_at": metadata.get("installed_at"),
@@ -1112,12 +1168,13 @@ class ExtensionCatalog:
config_path: Path to extension-catalogs.yml config_path: Path to extension-catalogs.yml
Returns: Returns:
Ordered list of CatalogEntry objects, or None if file doesn't exist Ordered list of CatalogEntry objects, or None if file doesn't exist.
or contains no valid catalog entries.
Raises: Raises:
ValidationError: If any catalog entry has an invalid URL, 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(): if not config_path.exists():
return None return None
@@ -1129,12 +1186,17 @@ class ExtensionCatalog:
) )
catalogs_data = data.get("catalogs", []) catalogs_data = data.get("catalogs", [])
if not catalogs_data: 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): if not isinstance(catalogs_data, list):
raise ValidationError( raise ValidationError(
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}" f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
) )
entries: List[CatalogEntry] = [] entries: List[CatalogEntry] = []
skipped_entries: List[int] = []
for idx, item in enumerate(catalogs_data): for idx, item in enumerate(catalogs_data):
if not isinstance(item, dict): if not isinstance(item, dict):
raise ValidationError( raise ValidationError(
@@ -1142,6 +1204,7 @@ class ExtensionCatalog:
) )
url = str(item.get("url", "")).strip() url = str(item.get("url", "")).strip()
if not url: if not url:
skipped_entries.append(idx)
continue continue
self._validate_catalog_url(url) self._validate_catalog_url(url)
try: try:
@@ -1164,7 +1227,14 @@ class ExtensionCatalog:
description=str(item.get("description", "")), description=str(item.get("description", "")),
)) ))
entries.sort(key=lambda e: e.priority) 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]: def get_active_catalogs(self) -> List[CatalogEntry]:
"""Get the ordered list of active catalogs. """Get the ordered list of active catalogs.

View File

@@ -277,6 +277,135 @@ class TestExtensionRegistry:
assert registry2.is_installed("test-ext") assert registry2.is_installed("test-ext")
assert registry2.get("test-ext")["version"] == "1.0.0" 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 ===== # ===== ExtensionManager Tests =====
@@ -1402,8 +1531,8 @@ class TestCatalogStack:
with pytest.raises(ValidationError, match="HTTPS"): with pytest.raises(ValidationError, match="HTTPS"):
catalog.get_active_catalogs() catalog.get_active_catalogs()
def test_empty_project_config_falls_back_to_defaults(self, temp_dir): def test_empty_project_config_raises_error(self, temp_dir):
"""Empty catalogs list in config falls back to default stack.""" """Empty catalogs list in config raises ValidationError (fail-closed for security)."""
import yaml as yaml_module import yaml as yaml_module
project_dir = self._make_project(temp_dir) project_dir = self._make_project(temp_dir)
@@ -1412,11 +1541,32 @@ class TestCatalogStack:
yaml_module.dump({"catalogs": []}, f) yaml_module.dump({"catalogs": []}, f)
catalog = ExtensionCatalog(project_dir) catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
# Falls back to default stack # Fail-closed: empty config should raise, not fall back to defaults
assert len(entries) == 2 with pytest.raises(ValidationError) as exc_info:
assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL 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 --- # --- _load_catalog_config ---
@@ -1943,3 +2093,238 @@ class TestExtensionIgnore:
assert not (dest / "docs" / "guide.md").exists() assert not (dest / "docs" / "guide.md").exists()
assert not (dest / "docs" / "internal.md").exists() assert not (dest / "docs" / "internal.md").exists()
assert (dest / "docs" / "api.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}"