Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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():
Expand Down
35 changes: 30 additions & 5 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -1161,14 +1164,22 @@ 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."
)
Comment thread
mnriem marked this conversation as resolved.

# 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/).
if force and self.registry.is_installed(manifest.id):
self.remove(manifest.id)

# Install extension
dest_dir = self.extensions_dir / manifest.id
if dest_dir.exists():
Expand All @@ -1194,6 +1205,15 @@ def install_from_directory(
hook_executor = HookExecutor(self.project_root)
hook_executor.register_hooks(manifest)

# Restore config files from backup when reinstalling with --force
if force:
backup_config_dir = self.extensions_dir / ".backup" / manifest.id
if backup_config_dir.exists():
for cfg_file in backup_config_dir.iterdir():
Comment on lines +1208 to +1212
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,
Expand All @@ -1212,13 +1232,16 @@ def install_from_zip(
zip_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
) -> ExtensionManifest:
"""Install extension from ZIP file.

Args:
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
Expand Down Expand Up @@ -1265,7 +1288,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.
Expand Down
162 changes: 162 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Loading