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 9 commits intogithub:mainfrom
Conversation
…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.
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.
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.
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.
- 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.
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.
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>
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.
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>
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.
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>
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.
…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>
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.
| # 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.
| 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? |
Quality of life comprehensive improvements for RFC-aligned catalog integration with version comparison and enhanced verbose output.
Changes
Original Features
Bugfixes (follow-up commits)
installed_attimestamp on rollback and enable/disableExtensionErrorexplicitly, letKeyboardInterruptpropagateSPECKIT_CATALOG_URLchangesAll Reviewer Feedback Addressed ✅
Test Coverage
Authored with GitHub Copilot and Claude