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
**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.
---

View File

@@ -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} <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")
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,15 +2451,74 @@ 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")
# 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")
# 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()
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()
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()
# 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:
console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)")
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}")
@@ -2433,18 +2610,22 @@ def extension_info(
f"with install_allowed: true to enable installation."
)
except ExtensionError as e:
console.print(f"\n[red]Error:[/red] {e}")
raise typer.Exit(1)
@extension_app.command("update")
def extension_update(
extension: str = typer.Argument(None, help="Extension ID to update (or all)"),
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)
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
# 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:"
# 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"
# 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")
)
console.print(f" specify extension remove {ext_id} --keep-config")
console.print(f" specify extension add {ext_id}")
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)
console.print(
"\n[cyan]Tip:[/cyan] Automatic updates will be available in a future version"
# 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():

View File

@@ -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.

View File

@@ -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}"