feat(extensions): add --force flag to extension add for overwrite reinstall (#2530)

* feat(extensions): add --force flag to extension add for overwrite reinstall

Add --force support to `specify extension add` that allows overwriting
an already-installed extension without manually removing it first.

- install_from_directory() and install_from_zip() accept force=True,
  automatically calling remove() before installation
- The --force CLI flag works with all install modes (--dev, --from URL,
  bundled, and catalog)
- Config files (*-config.yml) are preserved across force reinstall
- Error message suggests --force when extension is already installed
- 6 new tests covering unit and CLI force reinstall flows

* fix: address PR review feedback on --force implementation

- Remove unused `backup_config_dir` variable assignment (Ruff F841)
- Defer `remove()` until after `_validate_install_conflicts()` to prevent
  data loss if validation fails mid-reinstall
- Use `TemporaryDirectory` instead of `NamedTemporaryFile` in ZIP test
  to avoid Windows file-locking failures

* fix: only restore config backup when --force actually triggers a remove

When --force is used but the extension is not already installed, the
backup restore/cleanup should not run. Previously it could resurrect
stale config files from a previous removal and delete the backup
directory unnecessarily.

* fix: address Copilot review feedback on --force implementation

- Clear stale backup dir before remove() so only fresh backups are restored
- Restore only config files (*-config.yml, *-config.local.yml) from backup
- Remove trailing \n from --force console message (console.print adds newline)

* fix: handle non-directory paths in backup cleanup/restore

- Use is_dir() before rmtree/iterdir on backup path to avoid crashes
  when .backup/<id> exists as a file or symlink
- Remove unused manifest1 variable in test_install_force_reinstall

* fix: handle symlinks in backup cleanup/restore and correct CLI message

- Check is_symlink() before is_dir() in backup cleanup and restore:
  Path.is_dir() follows symlinks (returns True for symlink-to-dir) but
  shutil.rmtree() raises OSError on symlinks. Handle symlinks by
  unlinking them instead.
- Skip symlink entries during config file restore.
- Change --force dev-install message from "Reinstalling" to
  "Installing [...] (will overwrite if already installed)" because
  --force also works for first-time installs.
This commit is contained in:
Grissiom.GuRui
2026-06-04 05:34:24 +08:00
committed by GitHub
parent df09fd49c6
commit 659a41a6cc
3 changed files with 232 additions and 10 deletions

View File

@@ -1611,6 +1611,7 @@ def extension_add(
extension: str = typer.Argument(help="Extension name or path"),
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
force: bool = typer.Option(False, "--force", help="Overwrite if already installed"),
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
):
"""Install an extension."""
@@ -1625,6 +1626,9 @@ def extension_add(
manager = ExtensionManager(project_root)
speckit_version = get_speckit_version()
if force:
console.print("[yellow]--force:[/yellow] Will overwrite if already installed")
# Prompt for URL-based installs BEFORE the spinner so the user can
# actually see and respond to the confirmation (the Rich status
# spinner overwrites the typer.confirm prompt line, making it appear
@@ -1675,11 +1679,15 @@ def extension_add(
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
raise typer.Exit(1)
if force:
console.print(f"[yellow]--force:[/yellow] Installing from [cyan]{source_path}[/cyan] (will overwrite if already installed)...")
manifest = manager.install_from_directory(
source_path,
speckit_version,
priority=priority,
link_commands=True,
force=force
)
elif from_url:
@@ -1701,7 +1709,7 @@ def extension_add(
zip_path.write_bytes(zip_data)
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download from {safe_url}: {e}")
raise typer.Exit(1)
@@ -1714,7 +1722,9 @@ def extension_add(
# Try bundled extensions first (shipped with spec-kit)
bundled_path = _locate_bundled_extension(extension)
if bundled_path is not None:
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
manifest = manager.install_from_directory(
bundled_path, speckit_version, priority=priority, force=force
)
else:
# Install from catalog (also resolves display names to IDs)
catalog = ExtensionCatalog(project_root)
@@ -1735,7 +1745,9 @@ def extension_add(
if resolved_id != extension:
bundled_path = _locate_bundled_extension(resolved_id)
if bundled_path is not None:
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
manifest = manager.install_from_directory(
bundled_path, speckit_version, priority=priority, force=force
)
if bundled_path is None:
# Bundled extensions without a download URL must come from the local package
@@ -1771,7 +1783,7 @@ def extension_add(
try:
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
finally:
# Clean up downloaded ZIP
if zip_path.exists():

View File

@@ -1173,6 +1173,7 @@ class ExtensionManager:
register_commands: bool = True,
priority: int = 10,
link_commands: bool = False,
force: bool = False,
) -> ExtensionManifest:
"""Install extension from a local directory.
@@ -1183,6 +1184,8 @@ class ExtensionManager:
priority: Resolution priority (lower = higher precedence, default 10)
link_commands: If True, register rendered agent artifacts as
symlinks to a dev cache when supported by the OS.
force: If True and extension is already installed, remove it first
before proceeding with installation
Returns:
Installed extension manifest
@@ -1204,14 +1207,34 @@ class ExtensionManager:
# Check if already installed
if self.registry.is_installed(manifest.id):
raise ExtensionError(
f"Extension '{manifest.id}' is already installed. "
f"Use 'specify extension remove {manifest.id}' first."
)
if not force:
raise ExtensionError(
f"Extension '{manifest.id}' is already installed. "
f"Use 'specify extension remove {manifest.id}' first, "
f"or retry with --force to overwrite."
)
# Reject manifests that would shadow core commands or installed extensions.
self._validate_install_conflicts(manifest)
# Remove existing installation AFTER all validations pass so that a
# validation failure doesn't leave the user with a half-uninstalled
# extension (configs stranded in .backup/).
did_remove = False
if force and self.registry.is_installed(manifest.id):
# Clear any stale backup from a previous remove so that only the
# backup produced by the current remove() call is restored later.
backup_config_dir = self.extensions_dir / ".backup" / manifest.id
# Check is_symlink first: is_dir() follows symlinks so a
# symlink-to-directory would pass, but rmtree() raises on them.
if backup_config_dir.is_symlink():
backup_config_dir.unlink()
elif backup_config_dir.is_dir():
shutil.rmtree(backup_config_dir)
elif backup_config_dir.exists():
backup_config_dir.unlink()
did_remove = self.remove(manifest.id)
# Install extension
dest_dir = self.extensions_dir / manifest.id
if dest_dir.exists():
@@ -1239,6 +1262,26 @@ class ExtensionManager:
hook_executor = HookExecutor(self.project_root)
hook_executor.register_hooks(manifest)
# Restore config files from backup when --force triggered a removal.
# Only restore *.yml config files to match what remove() backs up,
# so unexpected artifacts in .backup/ are not resurrected.
if did_remove:
backup_config_dir = self.extensions_dir / ".backup" / manifest.id
# is_symlink first: is_dir() follows symlinks, but rmtree()
# raises on them — and we shouldn't follow symlinks to restore.
if backup_config_dir.is_symlink():
backup_config_dir.unlink()
elif backup_config_dir.is_dir():
for cfg_file in backup_config_dir.iterdir():
if cfg_file.is_file() and not cfg_file.is_symlink() and (
cfg_file.name.endswith("-config.yml") or
cfg_file.name.endswith("-config.local.yml")
):
shutil.copy2(cfg_file, dest_dir / cfg_file.name)
shutil.rmtree(backup_config_dir)
elif backup_config_dir.exists():
backup_config_dir.unlink()
# Update registry
self.registry.add(manifest.id, {
"version": manifest.version,
@@ -1257,6 +1300,7 @@ class ExtensionManager:
zip_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
) -> ExtensionManifest:
"""Install extension from ZIP file.
@@ -1264,6 +1308,8 @@ class ExtensionManager:
zip_path: Path to extension ZIP file
speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
force: If True and extension is already installed, remove it first
before proceeding with installation
Returns:
Installed extension manifest
@@ -1310,7 +1356,9 @@ class ExtensionManager:
raise ValidationError("No extension.yml found in ZIP file")
# Install from extracted directory
return self.install_from_directory(extension_dir, speckit_version, priority=priority)
return self.install_from_directory(
extension_dir, speckit_version, priority=priority, force=force
)
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
"""Remove an installed extension.
@@ -2742,7 +2790,7 @@ class HookExecutor:
if not isinstance(config, dict):
config = {}
# We don't save yet, as there are no hooks to unregister,
# We don't save yet, as there are no hooks to unregister,
# but unregister_extension above might have already saved a normalized config.
return