diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d3eb36391e..0ea19033c0 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -4262,6 +4262,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.""" @@ -4276,6 +4277,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\n") + try: with console.status(f"[cyan]Installing extension: {extension}[/cyan]"): if dev: @@ -4289,7 +4293,12 @@ def extension_add( console.print(f"[red]Error:[/red] No extension.yml found in {source_path}") raise typer.Exit(1) - manifest = manager.install_from_directory(source_path, speckit_version, priority=priority) + if force: + console.print(f"[yellow]--force:[/yellow] Reinstalling from [cyan]{source_path}[/cyan]...") + + manifest = manager.install_from_directory( + source_path, speckit_version, priority=priority, force=force + ) elif from_url: # Install from URL (ZIP file) @@ -4324,7 +4333,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 {from_url}: {e}") raise typer.Exit(1) @@ -4337,7 +4346,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) @@ -4358,7 +4369,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 @@ -4394,7 +4407,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(): diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 944ee4a06d..a316777c7a 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1132,6 +1132,7 @@ def install_from_directory( speckit_version: str, register_commands: bool = True, priority: int = 10, + force: bool = False, ) -> ExtensionManifest: """Install extension from a local directory. @@ -1140,6 +1141,8 @@ def install_from_directory( speckit_version: Current spec-kit version register_commands: If True, register commands with AI agents 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 @@ -1161,14 +1164,23 @@ def install_from_directory( # 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): + did_remove = self.remove(manifest.id) + # Install extension dest_dir = self.extensions_dir / manifest.id if dest_dir.exists(): @@ -1194,6 +1206,18 @@ def install_from_directory( hook_executor = HookExecutor(self.project_root) hook_executor.register_hooks(manifest) + # Restore config files from backup when --force triggered a removal + # Only restore when a remove was actually performed, so that stale + # backup files from a previous removal don't get resurrected when the + # extension wasn't already installed. + if did_remove: + backup_config_dir = self.extensions_dir / ".backup" / manifest.id + if backup_config_dir.exists(): + for cfg_file in backup_config_dir.iterdir(): + if cfg_file.is_file(): + shutil.copy2(cfg_file, dest_dir / cfg_file.name) + shutil.rmtree(backup_config_dir) + # Update registry self.registry.add(manifest.id, { "version": manifest.version, @@ -1212,6 +1236,7 @@ def install_from_zip( zip_path: Path, speckit_version: str, priority: int = 10, + force: bool = False, ) -> ExtensionManifest: """Install extension from ZIP file. @@ -1219,6 +1244,8 @@ def install_from_zip( 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 @@ -1265,7 +1292,9 @@ def install_from_zip( 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. diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 1434ba309d..f212ff485d 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -780,6 +780,102 @@ def test_install_duplicate(self, extension_dir, project_dir): with pytest.raises(ExtensionError, match="already installed"): manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + def test_install_force_reinstall(self, extension_dir, project_dir): + """Test force-reinstalling an already-installed extension.""" + manager = ExtensionManager(project_dir) + + # Install once + manifest1 = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + assert manager.registry.is_installed("test-ext") + + # Force-reinstall + manifest2 = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False, force=True + ) + + assert manifest2.id == "test-ext" + assert manager.registry.is_installed("test-ext") + # Check extension directory was recreated + ext_dir = project_dir / ".specify" / "extensions" / "test-ext" + assert ext_dir.exists() + assert (ext_dir / "extension.yml").exists() + assert (ext_dir / "commands" / "hello.md").exists() + + def test_install_force_config_preserved(self, extension_dir, project_dir): + """Test that config files are preserved when force-reinstalling.""" + manager = ExtensionManager(project_dir) + + # Install once + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Create a config file in the installed extension directory + ext_dir = project_dir / ".specify" / "extensions" / "test-ext" + config_file = ext_dir / "test-ext-config.yml" + config_file.write_text("test: config") + + # Force-reinstall + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False, force=True + ) + + # Config file should still exist after reinstall + new_config = ext_dir / "test-ext-config.yml" + assert new_config.exists() + assert new_config.read_text() == "test: config" + + def test_install_force_without_existing(self, extension_dir, project_dir): + """Test force-install when extension is NOT already installed (works normally).""" + manager = ExtensionManager(project_dir) + + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False, force=True + ) + + assert manifest.id == "test-ext" + assert manager.registry.is_installed("test-ext") + + def test_install_zip_force_reinstall(self, extension_dir, project_dir): + """Test force-reinstalling from ZIP when already installed.""" + import zipfile + import tempfile + + manager = ExtensionManager(project_dir) + + # Install once from directory + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + # Create a ZIP of the extension in a temp directory (not NamedTemporaryFile, + # which can fail on Windows due to file locking). + with tempfile.TemporaryDirectory() as tmpdir: + zip_path = Path(tmpdir) / "test-ext.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + for f in extension_dir.rglob("*"): + if f.is_file(): + zf.write(f, f.relative_to(extension_dir)) + + # Force-reinstall from ZIP + manifest = manager.install_from_zip( + zip_path, "0.1.0", force=True + ) + + assert manifest.id == "test-ext" + assert manager.registry.is_installed("test-ext") + ext_dir = project_dir / ".specify" / "extensions" / "test-ext" + assert ext_dir.exists() + + def test_install_duplicate_error_mentions_force(self, extension_dir, project_dir): + """Test that duplicate install error message suggests --force.""" + manager = ExtensionManager(project_dir) + + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + with pytest.raises(ExtensionError, match="--force"): + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_dir): """Install should reject extension IDs that shadow core commands.""" import yaml @@ -4321,3 +4417,69 @@ def test_remove_confirmation_plural_commands(self, tmp_path, extension_dir): ) assert "2 commands" in result.output + + +class TestExtensionForceCLI: + """CLI tests for `specify extension add --dev --force`.""" + + def _create_minimal_extension(self, base_dir: str | Path, ext_id: str = "test-ext") -> Path: + """Create a minimal extension directory with manifest.""" + import yaml + + ext_dir = Path(base_dir) / ext_id + ext_dir.mkdir(parents=True, exist_ok=True) + (ext_dir / "commands").mkdir() + + manifest = { + "schema_version": "1.0", + "extension": { + "id": ext_id, + "name": "Test Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": f"speckit.{ext_id}.hello", + "file": "commands/hello.md", + "description": "Test command", + } + ] + }, + } + + (ext_dir / "extension.yml").write_text(yaml.dump(manifest)) + (ext_dir / "commands" / "hello.md").write_text( + "---\ndescription: Test\n---\n\nHello $ARGUMENTS\n" + ) + return ext_dir + + def test_add_dev_force_reinstall(self, tmp_path): + """extension add --dev --force should reinstall without error.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + ext_src = self._create_minimal_extension(tmp_path) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + # First install + result1 = runner.invoke( + app, ["extension", "add", str(ext_src), "--dev"], catch_exceptions=False + ) + assert result1.exit_code == 0, strip_ansi(result1.output) + assert "installed" in strip_ansi(result1.output) + + # Force reinstall + result2 = runner.invoke( + app, ["extension", "add", str(ext_src), "--dev", "--force"], catch_exceptions=False + ) + assert result2.exit_code == 0, strip_ansi(result2.output) + assert "installed" in strip_ansi(result2.output)