Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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
83 changes: 83 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2405,6 +2405,89 @@ def preset_set_priority(
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")


@preset_app.command("enable")
def preset_enable(
pack_id: str = typer.Argument(help="Preset ID to enable"),
):
"""Enable a disabled preset."""
from .presets import PresetManager

project_root = Path.cwd()

# Check if we're in a spec-kit project
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)

manager = PresetManager(project_root)

# Check if preset is installed
if not manager.registry.is_installed(pack_id):
console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed")
raise typer.Exit(1)

# Get current metadata
metadata = manager.registry.get(pack_id)
if metadata is None or not isinstance(metadata, dict):
Comment on lines +2426 to +2433
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)")
raise typer.Exit(1)

if metadata.get("enabled", True):
console.print(f"[yellow]Preset '{pack_id}' is already enabled[/yellow]")
raise typer.Exit(0)

# Enable the preset
manager.registry.update(pack_id, {"enabled": True})

console.print(f"[green]✓[/green] Preset '{pack_id}' enabled")
console.print("\nTemplates from this preset will now be included in resolution.")
console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]")


@preset_app.command("disable")
def preset_disable(
pack_id: str = typer.Argument(help="Preset ID to disable"),
):
"""Disable a preset without removing it."""
from .presets import PresetManager

project_root = Path.cwd()

# Check if we're in a spec-kit project
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)

manager = PresetManager(project_root)

# Check if preset is installed
if not manager.registry.is_installed(pack_id):
console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed")
raise typer.Exit(1)

# Get current metadata
metadata = manager.registry.get(pack_id)
if metadata is None or not isinstance(metadata, dict):
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)")
raise typer.Exit(1)

if not metadata.get("enabled", True):
console.print(f"[yellow]Preset '{pack_id}' is already disabled[/yellow]")
raise typer.Exit(0)

# Disable the preset
manager.registry.update(pack_id, {"enabled": False})

console.print(f"[green]✓[/green] Preset '{pack_id}' disabled")
console.print("\nTemplates from this preset will be skipped during resolution.")
console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]")
console.print(f"To re-enable: specify preset enable {pack_id}")


# ===== Preset Catalog Commands =====


Expand Down
22 changes: 18 additions & 4 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,13 @@ def restore(self, extension_id: str, metadata: dict):
Args:
extension_id: Extension ID
metadata: Complete extension metadata including installed_at

Raises:
ValueError: If metadata is None or not a dict
"""
self.data["extensions"][extension_id] = dict(metadata)
if metadata is None or not isinstance(metadata, dict):
raise ValueError(f"Cannot restore '{extension_id}': metadata must be a dict")
self.data["extensions"][extension_id] = copy.deepcopy(metadata)
self._save()

def remove(self, extension_id: str):
Expand All @@ -320,10 +325,13 @@ def get(self, extension_id: str) -> Optional[dict]:
extension_id: Extension ID

Returns:
Deep copy of extension metadata, or None if not found
Deep copy of extension metadata, or None if not found or corrupted
"""
entry = self.data["extensions"].get(extension_id)
return copy.deepcopy(entry) if entry is not None else None
# Return None for missing or corrupted (non-dict) entries
if entry is None or not isinstance(entry, dict):
return None
return copy.deepcopy(entry)

def list(self) -> Dict[str, dict]:
"""Get all installed extensions.
Expand All @@ -347,13 +355,16 @@ def is_installed(self, extension_id: str) -> bool:
"""
return extension_id in self.data["extensions"]

def list_by_priority(self) -> List[tuple]:
def list_by_priority(self, include_disabled: bool = False) -> List[tuple]:
"""Get all installed extensions sorted by priority.

Lower priority number = higher precedence (checked first).
Extensions with equal priority are sorted alphabetically by ID
for deterministic ordering.

Args:
include_disabled: If True, include disabled extensions. Default False.

Returns:
List of (extension_id, metadata_copy) tuples sorted by priority.
Metadata is deep-copied to prevent accidental mutation.
Expand All @@ -365,6 +376,9 @@ def list_by_priority(self) -> List[tuple]:
for ext_id, meta in extensions.items():
if not isinstance(meta, dict):
continue
# Skip disabled extensions unless explicitly requested
if not include_disabled and not meta.get("enabled", True):
continue
metadata_copy = copy.deepcopy(meta)
metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10))
sortable_extensions.append((ext_id, metadata_copy))
Expand Down
56 changes: 48 additions & 8 deletions src/specify_cli/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,32 +306,64 @@ def update(self, pack_id: str, updates: dict):
self.data["presets"][pack_id] = merged
self._save()

def restore(self, pack_id: str, metadata: dict):
"""Restore preset metadata to registry without modifying timestamps.

Use this method for rollback scenarios where you have a complete backup
of the registry entry (including installed_at) and want to restore it
exactly as it was.

Args:
pack_id: Preset ID
metadata: Complete preset metadata including installed_at

Raises:
ValueError: If metadata is None or not a dict
"""
if metadata is None or not isinstance(metadata, dict):
raise ValueError(f"Cannot restore '{pack_id}': metadata must be a dict")
self.data["presets"][pack_id] = copy.deepcopy(metadata)
self._save()

def get(self, pack_id: str) -> Optional[dict]:
"""Get preset metadata from registry.

Returns a deep copy to prevent callers from accidentally mutating
nested internal registry state without going through the write path.

Args:
pack_id: Preset ID

Returns:
Pack metadata or None if not found
Deep copy of preset metadata, or None if not found or corrupted
"""
return self.data["presets"].get(pack_id)
entry = self.data["presets"].get(pack_id)
# Return None for missing or corrupted (non-dict) entries
if entry is None or not isinstance(entry, dict):
return None
return copy.deepcopy(entry)

def list(self) -> Dict[str, dict]:
"""Get all installed presets.

Returns a deep copy of the presets mapping to prevent callers
from accidentally mutating nested internal registry state.

Returns:
Dictionary of pack_id -> metadata
Dictionary of pack_id -> metadata (deep copies)
"""
return self.data["presets"]
return copy.deepcopy(self.data["presets"])

def list_by_priority(self) -> List[tuple]:
def list_by_priority(self, include_disabled: bool = False) -> List[tuple]:
"""Get all installed presets sorted by priority.

Lower priority number = higher precedence (checked first).
Presets with equal priority are sorted alphabetically by ID
for deterministic ordering.

Args:
include_disabled: If True, include disabled presets. Default False.

Returns:
List of (pack_id, metadata_copy) tuples sorted by priority.
Metadata is deep-copied to prevent accidental mutation.
Expand All @@ -343,6 +375,9 @@ def list_by_priority(self) -> List[tuple]:
for pack_id, meta in packs.items():
if not isinstance(meta, dict):
continue
# Skip disabled presets unless explicitly requested
if not include_disabled and not meta.get("enabled", True):
continue
metadata_copy = copy.deepcopy(meta)
metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10))
sortable_packs.append((pack_id, metadata_copy))
Expand Down Expand Up @@ -1466,12 +1501,17 @@ def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
return []

registry = ExtensionRegistry(self.extensions_dir)
registered_extensions = registry.list_by_priority()
registered_extension_ids = {ext_id for ext_id, _ in registered_extensions}
# Get ALL registered extensions (including disabled) to know which dirs are tracked
all_registered = registry.list_by_priority(include_disabled=True)
registered_extension_ids = {ext_id for ext_id, _ in all_registered}

all_extensions: list[tuple[int, str, dict | None]] = []

for ext_id, metadata in registered_extensions:
# Only include enabled extensions in the result
for ext_id, metadata in all_registered:
# Skip disabled extensions
if not metadata.get("enabled", True):
continue
priority = normalize_priority(metadata.get("priority") if metadata else None)
all_extensions.append((priority, ext_id, metadata))

Expand Down
96 changes: 96 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,48 @@ def test_restore_can_recreate_removed_entry(self, temp_dir):
assert registry.is_installed("test-ext")
assert registry.get("test-ext")["version"] == "1.0.0"

def test_restore_rejects_none_metadata(self, temp_dir):
"""Test restore() raises ValueError for None metadata."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)

with pytest.raises(ValueError, match="metadata must be a dict"):
registry.restore("test-ext", None)

def test_restore_rejects_non_dict_metadata(self, temp_dir):
"""Test restore() raises ValueError for non-dict metadata."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)

with pytest.raises(ValueError, match="metadata must be a dict"):
registry.restore("test-ext", "not-a-dict")

with pytest.raises(ValueError, match="metadata must be a dict"):
registry.restore("test-ext", ["list", "not", "dict"])

def test_restore_uses_deep_copy(self, temp_dir):
"""Test restore() deep copies metadata to prevent mutation."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)

original_metadata = {
"version": "1.0.0",
"nested": {"key": "original"},
}
registry.restore("test-ext", original_metadata)

# Mutate the original metadata after restore
original_metadata["version"] = "MUTATED"
original_metadata["nested"]["key"] = "MUTATED"

# Registry should have the original values
stored = registry.get("test-ext")
assert stored["version"] == "1.0.0"
assert stored["nested"]["key"] == "original"

def test_get_returns_deep_copy(self, temp_dir):
"""Test that get() returns deep copies for nested structures."""
extensions_dir = temp_dir / "extensions"
Expand All @@ -439,6 +481,26 @@ def test_get_returns_deep_copy(self, temp_dir):
internal = registry.data["extensions"]["test-ext"]
assert internal["registered_commands"] == {"claude": ["cmd1"]}

def test_get_returns_none_for_corrupted_entry(self, temp_dir):
"""Test that get() returns None for corrupted (non-dict) entries."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()

registry = ExtensionRegistry(extensions_dir)

# Directly corrupt the registry with non-dict entries
registry.data["extensions"]["corrupted-string"] = "not a dict"
registry.data["extensions"]["corrupted-list"] = ["not", "a", "dict"]
registry.data["extensions"]["corrupted-int"] = 42
registry._save()

# All corrupted entries should return None
assert registry.get("corrupted-string") is None
assert registry.get("corrupted-list") is None
assert registry.get("corrupted-int") is None
# Non-existent should also return None
assert registry.get("nonexistent") is None

def test_list_returns_deep_copy(self, temp_dir):
"""Test that list() returns deep copies for nested structures."""
extensions_dir = temp_dir / "extensions"
Expand Down Expand Up @@ -2500,6 +2562,40 @@ def test_list_by_priority_invalid_priority_defaults(self, temp_dir):
assert [item[0] for item in result] == ["ext-high", "ext-invalid"]
assert result[1][1]["priority"] == 10

def test_list_by_priority_excludes_disabled(self, temp_dir):
"""Test that list_by_priority excludes disabled extensions by default."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()

registry = ExtensionRegistry(extensions_dir)
registry.add("ext-enabled", {"version": "1.0.0", "enabled": True, "priority": 5})
registry.add("ext-disabled", {"version": "1.0.0", "enabled": False, "priority": 1})
registry.add("ext-default", {"version": "1.0.0", "priority": 10}) # no enabled field = True

# Default: exclude disabled
by_priority = registry.list_by_priority()
ext_ids = [p[0] for p in by_priority]
assert "ext-enabled" in ext_ids
assert "ext-default" in ext_ids
assert "ext-disabled" not in ext_ids

def test_list_by_priority_includes_disabled_when_requested(self, temp_dir):
"""Test that list_by_priority includes disabled extensions when requested."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()

registry = ExtensionRegistry(extensions_dir)
registry.add("ext-enabled", {"version": "1.0.0", "enabled": True, "priority": 5})
registry.add("ext-disabled", {"version": "1.0.0", "enabled": False, "priority": 1})

# Include disabled
by_priority = registry.list_by_priority(include_disabled=True)
ext_ids = [p[0] for p in by_priority]
assert "ext-enabled" in ext_ids
assert "ext-disabled" in ext_ids
# Disabled ext has lower priority number, so it comes first when included
assert ext_ids[0] == "ext-disabled"

def test_install_with_priority(self, extension_dir, project_dir):
"""Test that install_from_directory stores priority."""
manager = ExtensionManager(project_dir)
Expand Down
Loading
Loading