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
33 changes: 30 additions & 3 deletions extensions/RFC-EXTENSION-SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,12 +359,15 @@ specify extension add jira
"installed_at": "2026-01-28T14:30:00Z",
"source": "catalog",
"manifest_hash": "sha256:abc123...",
"enabled": true
"enabled": true,
"priority": 10
}
}
}
```

**Priority Field**: Extensions are ordered by `priority` (lower = higher precedence). Default is 10. Used for template resolution when multiple extensions provide the same template.

### 3. Configuration

```bash
Expand Down Expand Up @@ -1085,10 +1088,10 @@ $ specify extension list

Installed Extensions:
✓ jira (v1.0.0) - Jira Integration
Commands: 3 | Hooks: 2 | Status: Enabled
Commands: 3 | Hooks: 2 | Priority: 10 | Status: Enabled

✓ linear (v0.9.0) - Linear Integration
Commands: 1 | Hooks: 1 | Status: Enabled
Commands: 1 | Hooks: 1 | Priority: 10 | Status: Enabled
```

**Options:**
Expand Down Expand Up @@ -1200,6 +1203,7 @@ Next steps:
- `--version VERSION`: Install specific version
- `--dev PATH`: Install from local path (development mode)
- `--no-register`: Skip command registration (manual setup)
- `--priority NUMBER`: Set resolution priority (lower = higher precedence, default 10)

#### `specify extension remove NAME`

Expand Down Expand Up @@ -1280,6 +1284,29 @@ $ specify extension disable jira
To re-enable: specify extension enable jira
```

#### `specify extension set-priority NAME PRIORITY`

Change the resolution priority of an installed extension.

```bash
$ specify extension set-priority jira 5

✓ Extension 'Jira Integration' priority changed: 10 → 5

Lower priority = higher precedence in template resolution
```

**Priority Values:**

- Lower numbers = higher precedence (checked first in resolution)
- Default priority is 10
- Must be a positive integer (1 or higher)

**Use Cases:**

- Ensure a critical extension's templates take precedence
- Override default resolution order when multiple extensions provide similar templates

---

## Compatibility & Versioning
Expand Down
126 changes: 122 additions & 4 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2000,6 +2000,11 @@ def preset_add(
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)

# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)

manager = PresetManager(project_root)
speckit_version = get_speckit_version()

Expand Down Expand Up @@ -2210,6 +2215,10 @@ def preset_info(
if license_val:
console.print(f" License: {license_val}")
console.print("\n [green]Status: installed[/green]")
# Get priority from registry
pack_metadata = manager.registry.get(pack_id)
priority = pack_metadata.get("priority", 10) if pack_metadata else 10
console.print(f" [dim]Priority:[/dim] {priority}")
console.print()
return

Expand Down Expand Up @@ -2241,6 +2250,53 @@ def preset_info(
console.print()


@preset_app.command("set-priority")
def preset_set_priority(
pack_id: str = typer.Argument(help="Preset ID"),
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
):
"""Set the resolution priority of an installed 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)

# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
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:
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)")
raise typer.Exit(1)

old_priority = metadata.get("priority", 10)
if old_priority == priority:
console.print(f"[yellow]Preset '{pack_id}' already has priority {priority}[/yellow]")
raise typer.Exit(0)

# Update priority
manager.registry.update(pack_id, {"priority": priority})

console.print(f"[green]✓[/green] Preset '{pack_id}' priority changed: {old_priority} → {priority}")
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")


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


Expand Down Expand Up @@ -2576,8 +2632,9 @@ def extension_list(
status_color = "green" if ext["enabled"] else "red"

console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})")
console.print(f" [dim]{ext['id']}[/dim]")
console.print(f" {ext['description']}")
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
console.print()

if available or all_extensions:
Expand Down Expand Up @@ -2765,6 +2822,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"),
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
):
"""Install an extension."""
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
Expand All @@ -2778,6 +2836,11 @@ def extension_add(
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)

# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)

manager = ExtensionManager(project_root)
speckit_version = get_speckit_version()

Expand All @@ -2794,7 +2857,7 @@ 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)
manifest = manager.install_from_directory(source_path, speckit_version, priority=priority)

elif from_url:
# Install from URL (ZIP file)
Expand Down Expand Up @@ -2827,7 +2890,7 @@ def extension_add(
zip_path.write_bytes(zip_data)

# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version)
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
raise typer.Exit(1)
Expand Down Expand Up @@ -2871,7 +2934,7 @@ def extension_add(

try:
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version)
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
finally:
# Clean up downloaded ZIP
if zip_path.exists():
Expand Down Expand Up @@ -3113,6 +3176,8 @@ def extension_info(

console.print()
console.print("[green]✓ Installed[/green]")
priority = metadata.get("priority", 10)
console.print(f"[dim]Priority:[/dim] {priority}")
console.print(f"\nTo remove: specify extension remove {resolved_installed_id}")
return

Expand Down Expand Up @@ -3206,6 +3271,9 @@ def _print_extension_info(ext_info: dict, manager):
install_allowed = ext_info.get("_install_allowed", True)
if is_installed:
console.print("[green]✓ Installed[/green]")
metadata = manager.registry.get(ext_info['id'])
priority = metadata.get("priority", 10) if metadata else 10
console.print(f"[dim]Priority:[/dim] {priority}")
console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
elif install_allowed:
console.print("[yellow]Not installed[/yellow]")
Expand Down Expand Up @@ -3470,6 +3538,10 @@ def extension_update(
if "installed_at" in backup_registry_entry:
new_metadata["installed_at"] = backup_registry_entry["installed_at"]

# Preserve the original priority
if "priority" in backup_registry_entry:
new_metadata["priority"] = backup_registry_entry["priority"]

# If extension was disabled before update, disable it again
if not backup_registry_entry.get("enabled", True):
new_metadata["enabled"] = False
Expand Down Expand Up @@ -3716,6 +3788,52 @@ def extension_disable(
console.print(f"To re-enable: specify extension enable {extension_id}")


@extension_app.command("set-priority")
def extension_set_priority(
extension: str = typer.Argument(help="Extension ID or name"),
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
):
"""Set the resolution priority of an installed extension."""
from .extensions import ExtensionManager

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)

# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)

manager = ExtensionManager(project_root)

# Resolve extension ID from argument (handles ambiguous names)
installed = manager.list_installed()
extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority")

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

old_priority = metadata.get("priority", 10)
if old_priority == priority:
console.print(f"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]")
raise typer.Exit(0)

# Update priority
manager.registry.update(extension_id, {"priority": priority})

console.print(f"[green]✓[/green] Extension '{display_name}' priority changed: {old_priority} → {priority}")
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")


def main():
app()

Expand Down
Loading
Loading