Skip to content

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

Draft
mbachorik wants to merge 2 commits intogithub:mainfrom
mbachorik:feat/extension-catalog-integration
Draft

feat(extensions): Quality of life improvements for RFC-aligned catalog integration#1776
mbachorik wants to merge 2 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.

Note: This PR was rebased onto upstream/main to incorporate the multi-catalog feature. The automatic extension updates with atomic backup/restore have been re-implemented.

Changes

Core Feature: Automatic Extension Updates

  • Automatic extension updates: Implement download → remove → install flow with confirmation
  • Atomic backup/restore: Full rollback on failed updates including:
    • Extension directory backup/restore
    • Command files for all AI agents backup/restore
    • Hooks in extensions.yml backup/restore
    • Registry metadata backup/restore
  • Config preservation: User config files preserved during updates (via keep_config=True)
  • Registry integrity: 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 all exceptions except KeyboardInterrupt in update loop
  • Graceful failure handling: Per-extension update catches errors, continues to next

Fixes

  • enable/disable: Preserve installed_at timestamp by using direct registry manipulation instead of registry.add()

Test Coverage

  • All 158 tests pass

Authored with GitHub Copilot and Claude

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

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.

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.

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.

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.

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.

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

- 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>
@mbachorik mbachorik force-pushed the feat/extension-catalog-integration branch from b804065 to 9ff9c5d Compare March 10, 2026 21:45
@mbachorik mbachorik marked this pull request as draft March 10, 2026 21:52
…rovements

- 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>
Copilot AI review requested due to automatic review settings March 10, 2026 21:56
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 1 out of 1 changed files in this pull request and generated 10 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 +2436 to +2443
# Try to resolve from installed extensions first (by ID or name)
resolved_installed_id = None
resolved_installed_name = None
for ext in installed:
if ext["id"] == extension or ext["name"].lower() == extension.lower():
resolved_installed_id = ext["id"]
resolved_installed_name = ext["name"]
break
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

extension_info resolves an installed extension by breaking on the first name match, which means ambiguous installed names will silently pick the first extension and bypass the ambiguity error path. This is inconsistent with the new _resolve_installed_extension() behavior and can show info/remove instructions for the wrong extension. Use _resolve_installed_extension() (or perform ambiguity detection before selecting a match).

Copilot uses AI. Check for mistakes.
Comment on lines +2768 to +2775
# 5. Remove old extension (handles command file cleanup and registry removal)
manager.remove(extension_id, keep_config=True)

# 6. Download and install new version
zip_path = catalog.download_extension(extension_id)
try:
installed_manifest = manager.install_from_zip(zip_path, speckit_version)

Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

In extension_update, calling manager.remove(..., keep_config=True) does not actually preserve user config across the update because install_from_zip()install_from_directory() deletes the destination extension directory if it exists (shutil.rmtree(dest_dir)), which removes the preserved *-config.yml files. To truly preserve config, move config files out before install and restore them after, or add a dedicated install/update path that merges existing config files into the new install.

Copilot uses AI. Check for mistakes.
Comment on lines +2771 to +2776
# 6. Download and install new version
zip_path = catalog.download_extension(extension_id)
try:
installed_manifest = manager.install_from_zip(zip_path, speckit_version)

# 7. Verify extension ID matches
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

Automatic updates download/install from the catalog without checking the merged catalog entry's _install_allowed flag. Since extension add enforces this policy, extension update should likely also block or require explicit confirmation when _install_allowed is false to avoid fetching code from discovery-only catalogs.

Suggested change
# 6. Download and install new version
zip_path = catalog.download_extension(extension_id)
try:
installed_manifest = manager.install_from_zip(zip_path, speckit_version)
# 7. Verify extension ID matches
# 6. Respect catalog install policy before downloading new version
install_allowed = True
get_merged_entry = getattr(catalog, "get_merged_entry", None)
if callable(get_merged_entry):
merged_entry = get_merged_entry(extension_id)
if isinstance(merged_entry, dict):
install_allowed = merged_entry.get("_install_allowed", True)
if not install_allowed:
# For discovery-only extensions, require explicit confirmation before updating
if not typer.confirm(
f"Extension '{ext_name}' is marked as discovery-only and is not allowed for "
"automatic installation. Do you want to continue with this update?",
default=False,
):
console.print(
f" [yellow]Skipping update for discovery-only extension '{ext_name}'.[/yellow]"
)
continue
# 7. Download and install new version
zip_path = catalog.download_extension(extension_id)
try:
installed_manifest = manager.install_from_zip(zip_path, speckit_version)
# 8. Verify extension ID matches

Copilot uses AI. Check for mistakes.
Comment on lines +2844 to +2846
# Clean up backup directory after rollback attempt
if backup_base.exists():
shutil.rmtree(backup_base)
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

The rollback cleanup deletes backup_base even when rollback fails. If rollback fails, keeping the backup directory would provide a recovery path and makes it easier to diagnose what went wrong. Consider only deleting the backup on successful update or successful rollback (or provide a --keep-backups option).

Copilot uses AI. Check for mistakes.
Comment on lines +2939 to +2941
# Update registry directly to preserve installed_at (add() would overwrite it)
manager.registry.data["extensions"][extension_id] = metadata
manager.registry._save()
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

Same concern as extension_enable: direct mutation of manager.registry.data plus calling the private _save() creates cross-module coupling to registry internals. A small public API on ExtensionRegistry for updating an existing entry while preserving installed_at would be safer and reduce future breakage.

Suggested change
# Update registry directly to preserve installed_at (add() would overwrite it)
manager.registry.data["extensions"][extension_id] = metadata
manager.registry._save()
# Use the registry's public update method to avoid mutating internals directly
manager.registry.update(extension_id, metadata)

Copilot uses AI. Check for mistakes.
Comment on lines +2768 to +2780
# 5. Remove old extension (handles command file cleanup and registry removal)
manager.remove(extension_id, keep_config=True)

# 6. Download and install new version
zip_path = catalog.download_extension(extension_id)
try:
installed_manifest = manager.install_from_zip(zip_path, speckit_version)

# 7. Verify extension ID matches
if installed_manifest.id != extension_id:
raise ValueError(
f"Extension ID mismatch: expected '{extension_id}', got '{installed_manifest.id}'"
)
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

The extension ID mismatch check happens after install_from_zip() completes, but install_from_zip()/install_from_directory() already copies files, registers commands, and registers hooks as side effects. If a ZIP contains a different extension than requested, rollback currently restores old files but does not reliably remove any newly created command files/hooks for the wrong extension. Prefer validating the extension ID from the ZIP before installing (e.g., inspect extension.yml in a temp extract) or ensure rollback fully uninstalls whatever was installed before restoring backups.

Suggested change
# 5. Remove old extension (handles command file cleanup and registry removal)
manager.remove(extension_id, keep_config=True)
# 6. Download and install new version
zip_path = catalog.download_extension(extension_id)
try:
installed_manifest = manager.install_from_zip(zip_path, speckit_version)
# 7. Verify extension ID matches
if installed_manifest.id != extension_id:
raise ValueError(
f"Extension ID mismatch: expected '{extension_id}', got '{installed_manifest.id}'"
)
# 5. Download new version
zip_path = catalog.download_extension(extension_id)
try:
# 6. Validate extension ID from the ZIP before modifying the installation
with zipfile.ZipFile(zip_path, "r") as zf:
try:
with zf.open("extension.yml") as f:
manifest_data = yaml.safe_load(f) or {}
except KeyError:
raise ValueError("Downloaded extension archive is missing 'extension.yml'")
zip_extension_id = manifest_data.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
installed_manifest = manager.install_from_zip(zip_path, speckit_version)

Copilot uses AI. Check for mistakes.
Comment on lines +2818 to +2833
# Restore hooks in extensions.yml
if backup_hooks:
config = hook_executor.get_project_config()
if "hooks" not in config:
config["hooks"] = {}
for hook_name, hooks in backup_hooks.items():
if hook_name not in config["hooks"]:
config["hooks"][hook_name] = []
# Remove any existing hooks for this extension first
config["hooks"][hook_name] = [
h for h in config["hooks"][hook_name]
if h.get("extension") != extension_id
]
# Add back the backed up hooks
config["hooks"][hook_name].extend(hooks)
hook_executor.save_project_config(config)
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

Rollback after a failed update only restores hooks if backup_hooks is truthy, and even then it only touches hook names present in backup_hooks. If the failed install registered new hooks (or old hooks were empty), those hooks can remain in .specify/extensions.yml after rollback. Consider always calling hook_executor.unregister_hooks(extension_id) during rollback, then re-applying any backed-up hooks to fully revert state.

Copilot uses AI. Check for mistakes.
Comment on lines +2804 to +2807
if backup_ext_dir.exists():
extension_dir = manager.extensions_dir / extension_id
if extension_dir.exists():
shutil.rmtree(extension_dir)
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

Rollback only restores the extension directory if backup_ext_dir exists. If the pre-update extension directory was missing (a case this code explicitly anticipates by still backing up the registry), a failed update can leave a partially/newly installed extension directory on disk while the registry is reverted to the old entry, resulting in an inconsistent state. On rollback, explicitly remove any existing extensions/{extension_id} when there is no backup to restore, or back up and restore the entire directory state even when missing.

Suggested change
if backup_ext_dir.exists():
extension_dir = manager.extensions_dir / extension_id
if extension_dir.exists():
shutil.rmtree(extension_dir)
extension_dir = manager.extensions_dir / extension_id
# Always remove any existing directory created during a failed update
if extension_dir.exists():
shutil.rmtree(extension_dir)
# If a backup exists, restore it; otherwise leave the directory absent,
# matching the pre-update state when there was no original extension dir
if backup_ext_dir.exists():

Copilot uses AI. Check for mistakes.
Comment on lines +2517 to +2518
def _print_extension_info(ext_info: dict, manager, catalog_error: Optional[Exception] = None):
"""Print formatted extension info from catalog data."""
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

_print_extension_info accepts a catalog_error parameter but never uses it. Either remove the parameter or display the catalog error when present (if you intended to surface partial-catalog failures).

Copilot uses AI. Check for mistakes.
manager.registry.add(extension, metadata)
# Update registry directly to preserve installed_at (add() would overwrite it)
manager.registry.data["extensions"][extension_id] = metadata
manager.registry._save()
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

extension_enable/extension_disable update the registry by mutating manager.registry.data and calling the private manager.registry._save(). This couples the CLI to registry internals and bypasses any invariants add()/remove() might enforce. Consider adding a public registry method (e.g., update_metadata_preserve_installed_at(...)) or enhancing add() to optionally preserve installed_at, then use that API here.

Suggested change
manager.registry._save()
manager.registry.save()

Copilot uses AI. Check for mistakes.
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