Skip to content

feat(extensions): Quality of life improvements for RFC-aligned catalog integration#1776

Open
mbachorik wants to merge 9 commits intogithub:mainfrom
mbachorik:feat/extension-catalog-integration
Open

feat(extensions): Quality of life improvements for RFC-aligned catalog integration#1776
mbachorik wants to merge 9 commits intogithub:mainfrom
mbachorik:feat/extension-catalog-integration

Conversation

@mbachorik
Copy link
Contributor

@mbachorik mbachorik commented Mar 7, 2026

Quality of life comprehensive improvements for RFC-aligned catalog integration with version comparison and enhanced verbose output.

Changes

Original Features

  • Extension commands accept both ID and display name
  • List: --available, --all, --verbose flags with rich metadata
  • Info: version comparison installed vs catalog
  • Documentation updates

Bugfixes (follow-up commits)

  • Automatic extension updates: Implement download → remove → install flow with confirmation
  • Atomic backup/restore: Full rollback on failed updates (extension dir, command files, hooks, registry)
  • Config preservation: User config files preserved during updates
  • Registry restoration: Preserve installed_at timestamp on rollback and enable/disable
  • Always backup registry: Backup registry metadata even when extension directory is missing
  • Extension ID verification: Verify downloaded ZIP contains expected extension ID (security)
  • Better error handling: Catch ExtensionError explicitly, let KeyboardInterrupt propagate
  • Graceful failure handling: Per-extension update loop catches all exceptions
  • Catalog warnings: Show warning in verbose mode when catalog is unavailable
  • Catalog cache invalidation: Invalidate cache when SPECKIT_CATALOG_URL changes
  • Consistent UX: Use resolved display names in enable/disable/update output
  • UX messages: Distinguish "catalog unavailable" from "not in catalog"
  • Dead code removal: Remove unreachable conditional in error path

All Reviewer Feedback Addressed ✅

  • Catalog error fallback for installed extensions
  • UX message clarity (catalog unavailable vs not found)
  • Dead code removed
  • Exception handling narrowed (ExtensionError, KeyboardInterrupt)
  • enable/disable preserves installed_at
  • Registry rollback preserves installed_at
  • Rollback completeness (command files, hooks, extensions.yml)
  • Cache invalidation on URL change
  • Non-ExtensionError handling in update loop
  • Always backup registry (even without directory)
  • Extension ID mismatch detection (security)

Test Coverage

  • 68 tests pass (23 new tests added)
  • Covers: updates, backup/restore, config preservation, error handling, cache invalidation

Authored with GitHub Copilot and Claude

…ison and verbose output

Implement extension management improvements:
- All commands accept extension ID or display name
- List: --available, --all, --verbose flags
- Info: version comparison (installed vs catalog)
- Doc updates to EXTENSION-USER-GUIDE.md and API reference
- All 45 tests pass

Squashed commit from 3 changes.
@mbachorik mbachorik requested a review from mnriem as a code owner March 7, 2026 09:00
Copilot AI review requested due to automatic review settings March 7, 2026 09:00
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR enhances the Spec Kit extension CLI UX around RFC-aligned catalog integration by improving extension identification (ID vs display name), adding richer listing/info output, and documenting the new flags/behaviors.

Changes:

  • Added --verbose support to specify extension list (IDs + extra metadata, plus update-available hints).
  • Enhanced specify extension info with version comparison (installed vs catalog) and a new --verbose mode.
  • Updated extension docs to describe verbose output and “ID or name” arguments across commands.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.

File Description
src/specify_cli/__init__.py Adds verbose flags, richer extension list/info output, and resolves extensions by ID or display name in multiple commands.
extensions/EXTENSION-USER-GUIDE.md Documents verbose list/info usage and removing by display name.
extensions/EXTENSION-API-REFERENCE.md Updates CLI reference for --verbose and “ID or name” arguments.
Comments suppressed due to low confidence (1)

src/specify_cli/init.py:2342

  • extension_info() now returns/raises in all branches above, but a large block of the previous implementation remains after the final raise typer.Exit(1). This code is unreachable and duplicates output logic, which makes future edits error-prone; it should be deleted to avoid confusion and accidental drift.
        # Case 3: Extension not found anywhere
        console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog or installed locally")
        console.print("\nTry: specify extension search")
        raise typer.Exit(1)

        # Header
        verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else ""
        console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}")
        console.print(f"ID: {ext_info['id']}")
        console.print()


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Handle catalog lookup failures with local fallback in extension info, optimize verbose list version checks by prefetching catalog once, add explicit ambiguous-name handling for update/enable/disable, and remove unreachable extension info code. Add CLI tests for verbose update indicators, catalog-failure fallback, and ambiguity handling.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

iamaeroplane added 2 commits March 7, 2026 09:47
- Extract shared _resolve_installed_extension_id helper to eliminate code duplication across remove/update/enable/disable commands
- Add catalog display name resolution with ambiguity detection
- Implement _resolve_catalog_extension helper for extension_info catalog lookups
- Fix double catalog fetch: reuse prefetched catalog in verbose + available/all mode
- Update EXTENSION-USER-GUIDE.md to clarify display name support for both installed and catalog extensions
- Update EXTENSION-API-REFERENCE.md with detailed argument descriptions
- Add tests for catalog display name resolution (uninstalled and ambiguous cases)
- Add Dict and Any to typing imports

All 52 tests passing.
Copilot AI review requested due to automatic review settings March 8, 2026 08:18
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/specify_cli/init.py:2612

  • extension_enable resolves extension_id but some user-facing messages still interpolate the original extension argument (which may be a display name). This can produce confusing output (e.g., reporting "already enabled" / "enabled" for a string that isn't the canonical ID). Prefer printing the resolved ID and/or the extension's display name from the manifest for consistency with the actual target being modified.
    if metadata.get("enabled", True):
        console.print(f"[yellow]Extension '{extension}' is already enabled[/yellow]")
        raise typer.Exit(0)

    metadata["enabled"] = True
    manager.registry.add(extension_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_id:
                    hook["enabled"] = True
        hook_executor.save_project_config(config)

    console.print(f"[green]✓[/green] Extension '{extension}' enabled")


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Additional bugfixes and improvements:

- Implement automatic extension update (download, remove, install)
- Add atomic backup/restore mechanism for failed updates
- Preserve user config files during updates (install_from_directory)
- Restore registry entry on failed install (was previously lost)
- Fix silent exception swallowing in extension_list (use ExtensionError)
- Add catalog fetch error warning in verbose mode
- Use resolved display names in enable/disable/update output
- Add hint to extension_enable matching extension_disable
- Consistent variable naming (extension_id throughout)

Tests added:
- test_remove_keep_config_preserves_only_config_files
- test_config_preserved_on_reinstall
- test_extension_info_verbose_shows_extra_metadata
- test_extension_list_verbose_shows_warning_when_catalog_fails
- test_extension_enable_uses_resolved_name_in_output
- test_extension_disable_uses_resolved_name_in_output
- test_extension_update_no_updates_available
- test_extension_update_performs_automatic_update
- test_extension_update_handles_download_failure
- test_extension_update_restores_on_install_failure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Previously, setting SPECKIT_CATALOG_URL to a different catalog URL
(e.g., the community catalog) would still return cached data from the
default catalog because is_cache_valid() only checked cache age.

Now is_cache_valid() also verifies that the cached URL matches the
current catalog URL. When they differ, the cache is invalidated and
a fresh fetch occurs.

Also includes fixes from earlier:
- Comprehensive rollback for update failures (command files, hooks)
- Preserve installed_at timestamp on rollback
- Better UX messages when catalog is offline

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Previously, the per-extension update loop only caught ExtensionError.
If install_from_zip raised a non-ExtensionError (e.g., OSError, BadZipFile),
the error would bypass the handler and crash the CLI with a traceback,
skipping the summary and aborting remaining updates.

Now the loop catches all exceptions (except KeyboardInterrupt) so that:
- One extension's failure doesn't abort all updates
- The summary is always printed
- Users can still Ctrl+C to stop

Added test: test_extension_update_handles_non_extension_error

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

…ption handling

- extension_enable: Update registry directly instead of calling add()
  to preserve the original installed_at timestamp
- extension_disable: Same fix - direct registry update preserves timestamp
- extension_list --available/--all: Narrow exception handler from
  Exception to ExtensionError, let KeyboardInterrupt propagate

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1. Backup registry metadata and extensions.yml even when extension
   directory is missing/corrupted. Previously backups were only created
   if the directory existed, leaving registry in inconsistent state
   on failed updates.

2. Verify that the downloaded ZIP contains the expected extension ID.
   If the manifest ID doesn't match the extension being updated,
   treat it as a failed update and trigger rollback. This prevents
   installing a different extension if the catalog is compromised.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 9, 2026 09:11
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +2574 to +2577
# Download new version first (before removing old)
# This ensures we don't remove the old version if download fails
zip_path = catalog.download_extension(extension_id)

Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

catalog.download_extension() can raise after partially creating/writing the ZIP (e.g., IOError while saving). In that case zip_path is never returned here, so this command can’t clean up the partial file and may leave corrupt ZIPs in the downloads/cache dir. Consider making download_extension() responsible for deleting any partially-written file on exceptions, or downloading into a temp file/dir that is always cleaned up.

Copilot uses AI. Check for mistakes.
Comment on lines +345 to +358
config_files = list(dest_dir.glob("*-config.yml")) + list(
dest_dir.glob("*-config.local.yml")
)
for config_file in config_files:
preserved_configs[config_file.name] = config_file.read_text()
shutil.rmtree(dest_dir)

shutil.copytree(source_dir, dest_dir)

# Restore preserved config files (overwrite new defaults with user's config)
for config_name, config_content in preserved_configs.items():
config_path = dest_dir / config_name
config_path.write_text(config_content)

Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

install_from_directory() preserves existing *-config.yml files by reading/writing their contents. That rewrites the files and can lose permissions/ownership/mtime (e.g., turning a 0600 secrets file into default permissions) and can also fail on non-UTF8 content. Prefer preserving configs by copying the files themselves (e.g., shutil.copy2 to a temp dir and back, or capturing/restoring stat/mode) instead of read_text()/write_text().

Suggested change
config_files = list(dest_dir.glob("*-config.yml")) + list(
dest_dir.glob("*-config.local.yml")
)
for config_file in config_files:
preserved_configs[config_file.name] = config_file.read_text()
shutil.rmtree(dest_dir)
shutil.copytree(source_dir, dest_dir)
# Restore preserved config files (overwrite new defaults with user's config)
for config_name, config_content in preserved_configs.items():
config_path = dest_dir / config_name
config_path.write_text(config_content)
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
config_files = list(dest_dir.glob("*-config.yml")) + list(
dest_dir.glob("*-config.local.yml")
)
for config_file in config_files:
backup_path = tmpdir_path / config_file.name
shutil.copy2(config_file, backup_path)
preserved_configs[config_file.name] = backup_path
shutil.rmtree(dest_dir)
shutil.copytree(source_dir, dest_dir)
# Restore preserved config files (overwrite new defaults with user's config)
for config_name, backup_path in preserved_configs.items():
config_path = dest_dir / config_name
shutil.copy2(backup_path, config_path)
else:
shutil.copytree(source_dir, dest_dir)

Copilot uses AI. Check for mistakes.
@mnriem
Copy link
Collaborator

mnriem commented Mar 9, 2026

@mbachorik I have #1707 in flight and once that lands can you see if anything needs to change for this?

@mbachorik
Copy link
Contributor Author

@mnriem definitely. i'll keep an eye on #1707

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants