feat(extensions): Quality of life improvements for RFC-aligned catalog integration#1776
feat(extensions): Quality of life improvements for RFC-aligned catalog integration#1776mbachorik wants to merge 2 commits intogithub:mainfrom
Conversation
There was a problem hiding this comment.
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
--verbosesupport tospecify extension list(IDs + extra metadata, plus update-available hints). - Enhanced
specify extension infowith version comparison (installed vs catalog) and a new--verbosemode. - 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 finalraise 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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_enableresolvesextension_idbut some user-facing messages still interpolate the originalextensionargument (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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
src/specify_cli/__init__.py
Outdated
| # 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) | ||
|
|
There was a problem hiding this comment.
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.
src/specify_cli/extensions.py
Outdated
| 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) | ||
|
|
There was a problem hiding this comment.
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().
| 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) |
|
@mbachorik I have #1707 in flight and once that lands can you see if anything needs to change for this? |
- 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>
b804065 to
9ff9c5d
Compare
…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>
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
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).
| # 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) | ||
|
|
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
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.
| # 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 |
| # Clean up backup directory after rollback attempt | ||
| if backup_base.exists(): | ||
| shutil.rmtree(backup_base) |
There was a problem hiding this comment.
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).
| # Update registry directly to preserve installed_at (add() would overwrite it) | ||
| manager.registry.data["extensions"][extension_id] = metadata | ||
| manager.registry._save() |
There was a problem hiding this comment.
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.
| # 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) |
| # 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}'" | ||
| ) |
There was a problem hiding this comment.
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.
| # 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) |
| # 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) |
There was a problem hiding this comment.
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.
| if backup_ext_dir.exists(): | ||
| extension_dir = manager.extensions_dir / extension_id | ||
| if extension_dir.exists(): | ||
| shutil.rmtree(extension_dir) |
There was a problem hiding this comment.
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.
| 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(): |
| def _print_extension_info(ext_info: dict, manager, catalog_error: Optional[Exception] = None): | ||
| """Print formatted extension info from catalog data.""" |
There was a problem hiding this comment.
_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).
| 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() |
There was a problem hiding this comment.
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.
| manager.registry._save() | |
| manager.registry.save() |
Quality of life comprehensive improvements for RFC-aligned catalog integration with version comparison and enhanced verbose output.
Changes
Core Feature: Automatic Extension Updates
keep_config=True)installed_attimestamp on rollback and enable/disableKeyboardInterruptin update loopFixes
installed_attimestamp by using direct registry manipulation instead ofregistry.add()Test Coverage
Authored with GitHub Copilot and Claude