feat: add --dev flag to extension update command#2006
feat: add --dev flag to extension update command#2006Testimonial wants to merge 1 commit intogithub:mainfrom
Conversation
Adds `--dev` support to the update command, enabling local directory updates without remove + add cycle: specify extension update --dev /tmp/cognitive-squad Behavior: - Reads extension.yml from the source directory to get the extension ID - If extension is not installed: installs fresh (same as add --dev) - If extension is installed: backs up config files, removes old version, installs from local directory, restores config files and registry metadata (installed_at, priority, enabled state) - Sets source to "local" in registry This eliminates the common dev workflow pain point: rm -rf /tmp/ext && git clone ... && specify extension remove ext && specify extension add --dev /tmp/ext Now just: cd /tmp/ext && git pull && specify extension update --dev /tmp/ext Config files (*-config.yml, *-config.local.yml, local-config.yml) are preserved across updates. The catalog code path is unchanged when --dev is not specified. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a --dev mode to specify extension update to support updating dev-installed extensions directly from a local source directory, aiming to preserve config and key registry metadata during the update workflow.
Changes:
- Introduces
--devflag tospecify extension update. - Implements a dev update code path that reads
extension.ymlfrom a provided directory, removes the installed extension, reinstalls from the directory, and restores config/registry metadata. - Keeps existing catalog-based update behavior when
--devis not used.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Check if installed | ||
| installed = manager.list_installed() | ||
| installed_ids = {ext["id"] for ext in installed} | ||
|
|
||
| if extension_id not in installed_ids: |
There was a problem hiding this comment.
Installed detection uses manager.list_installed() to build installed_ids. list_installed() is based on registry.list() which filters out corrupted/non-dict entries, so an extension can still be “installed” per registry.is_installed() yet be missing from installed_ids—leading --dev to attempt a fresh install and fail with “already installed”. Consider checking installation via manager.registry.is_installed(extension_id) (or manager.registry.keys()) instead of relying on list_installed().
| # Check if installed | |
| installed = manager.list_installed() | |
| installed_ids = {ext["id"] for ext in installed} | |
| if extension_id not in installed_ids: | |
| # Check if installed using registry to handle corrupted entries gracefully | |
| try: | |
| is_installed = manager.registry.is_installed(extension_id) | |
| except AttributeError: | |
| # Fallback for environments without registry.is_installed | |
| installed = manager.list_installed() | |
| installed_ids = {ext["id"] for ext in installed} | |
| is_installed = extension_id in installed_ids | |
| if not is_installed: |
| # Restore preserved metadata (installed_at, priority, enabled state) | ||
| if backup_registry_entry and isinstance(backup_registry_entry, dict): | ||
| current_metadata = manager.registry.get(extension_id) | ||
| if current_metadata and isinstance(current_metadata, dict): | ||
| new_metadata = dict(current_metadata) | ||
| if "installed_at" in backup_registry_entry: | ||
| new_metadata["installed_at"] = backup_registry_entry["installed_at"] | ||
| if "priority" in backup_registry_entry: | ||
| new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"]) | ||
| if not backup_registry_entry.get("enabled", True): | ||
| new_metadata["enabled"] = False | ||
| new_metadata["source"] = "local" | ||
| manager.registry.restore(extension_id, new_metadata) |
There was a problem hiding this comment.
If the extension was disabled before the update, this code restores enabled=False in the registry, but it doesn’t re-disable hooks in .specify/extensions.yml. The catalog update path explicitly disables those hooks to keep behavior consistent. Consider mirroring that logic here so disabled extensions don’t end up executing hooks after a dev update.
| console.print(f"\n[green]✓[/green] Updated {extension_id} to v{new_version} from {source_path}") | ||
| except Exception as e: | ||
| console.print(f"\n[red]✗[/red] Update failed: {e}") | ||
| raise typer.Exit(1) | ||
| finally: | ||
| # Clean up backup | ||
| backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-dev-update" | ||
| if backup_base.exists(): | ||
| shutil.rmtree(backup_base) | ||
|
|
There was a problem hiding this comment.
Failure handling here can cause irreversible loss: after remove() succeeds, if install_from_directory() or config restore fails, the old extension is gone and the config backup is deleted in finally. Consider implementing rollback (similar to the existing catalog update path) or, at minimum, only deleting the backup directory after a successful update and leaving it in place on failure. Also consider guarding shutil.rmtree in the cleanup path so a cleanup error doesn’t mask the original update failure.
| # ── Dev mode: update from local directory ────────────────────────── | ||
| if dev: | ||
| if not extension: | ||
| console.print("[red]Error:[/red] --dev requires extension path argument") | ||
| console.print("Usage: specify extension update --dev /path/to/extension") | ||
| raise typer.Exit(1) |
There was a problem hiding this comment.
There are CLI integration tests for specify extension update already (see TestExtensionUpdateCLI), but this new --dev code path is untested. Adding tests for --dev update (installed and not-installed cases, config preservation, and disabled extension hook behavior) would help prevent regressions.
| source_path = Path(extension).expanduser().resolve() | ||
| if not source_path.exists(): | ||
| console.print(f"[red]Error:[/red] Directory not found: {source_path}") | ||
| raise typer.Exit(1) |
There was a problem hiding this comment.
--dev accepts a local directory, but the code only checks exists(). If a user passes a file path, the error message becomes “No extension.yml found” rather than a clear “must be a directory”. Consider validating source_path.is_dir() and exiting with an explicit message when it isn’t a directory.
| raise typer.Exit(1) | |
| raise typer.Exit(1) | |
| elif not source_path.is_dir(): | |
| console.print(f"[red]Error:[/red] --dev path must be a directory: {source_path}") | |
| raise typer.Exit(1) |
| import yaml | ||
| with open(manifest_path) as f: | ||
| manifest_data = yaml.safe_load(f) or {} |
There was a problem hiding this comment.
Reading/parsing extension.yml here is outside the surrounding try/except and can raise (YAML syntax error, permission error, etc.), resulting in an uncaught exception/traceback. Consider using the existing ExtensionManifest loader/validator (from specify_cli.extensions) or wrapping the file read + yaml.safe_load in error handling and returning a clean Typer error message.
| import yaml | |
| with open(manifest_path) as f: | |
| manifest_data = yaml.safe_load(f) or {} | |
| try: | |
| with open(manifest_path) as f: | |
| manifest_data = yaml.safe_load(f) or {} | |
| except (OSError, yaml.YAMLError) as e: | |
| console.print(f"[red]Error:[/red] Failed to read or parse {manifest_path}: {e}") | |
| raise typer.Exit(1) |
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback. If not applicable, please explain why
Summary
--devflag tospecify extension updatefor local directory updatesrm -rf && git clone && remove && add --devgit pull && specify extension update --dev /pathProblem
Dev-installed extensions (
specify extension add --dev /path) cannot be updated without a full remove + add cycle. Theupdatecommand only checks the catalog, so locally-installed extensions get "Not found in catalog (skipping)".Common workaround:
This destroys any local config files and resets registry metadata (installed_at, priority).
Solution
When
--devis specified:extension.ymlfrom source directory to identify the extensionadd --dev)installed_at,priority,enabledstate,*-config.yml,*-config.local.yml,local-config.ymlsource: "local"in registryNo changes to the catalog update code path when
--devis not used.Test plan
specify extension update --dev /path/to/extupdates an installed dev extensionspecify extension update --dev /path/to/new-extinstalls if not yet installedspecify extension update --dev(no path) prints usage errorspecify extension update --dev /nonexistentprints directory not found errorspecify extension update ext-name(no --dev) still uses catalog (unchanged)🤖 Generated with Claude Code