Skip to content

Commit ceb55ef

Browse files
iamaeroplaneclaude
andcommitted
feat(extensions,presets): add priority-based resolution ordering
Add priority field to extension and preset registries for deterministic template resolution when multiple sources provide the same template. Extensions: - Add `list_by_priority()` method to ExtensionRegistry - Add `--priority` option to `extension add` command - Add `extension set-priority` command - Show priority in `extension list` and `extension info` - Preserve priority during `extension update` - Update RFC documentation Presets: - Add `preset set-priority` command - Show priority in `preset info` output - Use priority ordering in PresetResolver for extensions Both systems: - Lower priority number = higher precedence (default: 10) - Backwards compatible with legacy entries (missing priority defaults to 10) - Comprehensive test coverage including backwards compatibility Closes #1845 Closes #1854 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4a32344 commit ceb55ef

File tree

6 files changed

+698
-18
lines changed

6 files changed

+698
-18
lines changed

extensions/RFC-EXTENSION-SYSTEM.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -359,12 +359,15 @@ specify extension add jira
359359
"installed_at": "2026-01-28T14:30:00Z",
360360
"source": "catalog",
361361
"manifest_hash": "sha256:abc123...",
362-
"enabled": true
362+
"enabled": true,
363+
"priority": 10
363364
}
364365
}
365366
}
366367
```
367368

369+
**Priority Field**: Extensions are ordered by `priority` (lower = higher precedence). Default is 10. Used for template resolution when multiple extensions provide the same template.
370+
368371
### 3. Configuration
369372

370373
```bash
@@ -1085,10 +1088,10 @@ $ specify extension list
10851088
10861089
Installed Extensions:
10871090
✓ jira (v1.0.0) - Jira Integration
1088-
Commands: 3 | Hooks: 2 | Status: Enabled
1091+
Commands: 3 | Hooks: 2 | Priority: 10 | Status: Enabled
10891092
10901093
✓ linear (v0.9.0) - Linear Integration
1091-
Commands: 1 | Hooks: 1 | Status: Enabled
1094+
Commands: 1 | Hooks: 1 | Priority: 10 | Status: Enabled
10921095
```
10931096

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

12041208
#### `specify extension remove NAME`
12051209

@@ -1280,6 +1284,29 @@ $ specify extension disable jira
12801284
To re-enable: specify extension enable jira
12811285
```
12821286

1287+
#### `specify extension set-priority NAME PRIORITY`
1288+
1289+
Change the resolution priority of an installed extension.
1290+
1291+
```bash
1292+
$ specify extension set-priority jira 5
1293+
1294+
✓ Extension 'Jira Integration' priority changed: 10 → 5
1295+
1296+
Lower priority = higher precedence in template resolution
1297+
```
1298+
1299+
**Priority Values:**
1300+
1301+
- Lower numbers = higher precedence (checked first in resolution)
1302+
- Default priority is 10
1303+
- Must be a positive integer (1 or higher)
1304+
1305+
**Use Cases:**
1306+
1307+
- Ensure a critical extension's templates take precedence
1308+
- Override default resolution order when multiple extensions provide similar templates
1309+
12831310
---
12841311

12851312
## Compatibility & Versioning

src/specify_cli/__init__.py

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2210,6 +2210,10 @@ def preset_info(
22102210
if license_val:
22112211
console.print(f" License: {license_val}")
22122212
console.print("\n [green]Status: installed[/green]")
2213+
# Get priority from registry
2214+
pack_metadata = manager.registry.get(pack_id)
2215+
priority = pack_metadata.get("priority", 10) if pack_metadata else 10
2216+
console.print(f" [dim]Priority:[/dim] {priority}")
22132217
console.print()
22142218
return
22152219

@@ -2241,6 +2245,53 @@ def preset_info(
22412245
console.print()
22422246

22432247

2248+
@preset_app.command("set-priority")
2249+
def preset_set_priority(
2250+
pack_id: str = typer.Argument(help="Preset ID"),
2251+
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
2252+
):
2253+
"""Set the resolution priority of an installed preset."""
2254+
from .presets import PresetManager
2255+
2256+
project_root = Path.cwd()
2257+
2258+
# Check if we're in a spec-kit project
2259+
specify_dir = project_root / ".specify"
2260+
if not specify_dir.exists():
2261+
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
2262+
console.print("Run this command from a spec-kit project root")
2263+
raise typer.Exit(1)
2264+
2265+
# Validate priority
2266+
if priority < 1:
2267+
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
2268+
raise typer.Exit(1)
2269+
2270+
manager = PresetManager(project_root)
2271+
2272+
# Check if preset is installed
2273+
if not manager.registry.is_installed(pack_id):
2274+
console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed")
2275+
raise typer.Exit(1)
2276+
2277+
# Get current metadata
2278+
metadata = manager.registry.get(pack_id)
2279+
if metadata is None:
2280+
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)")
2281+
raise typer.Exit(1)
2282+
2283+
old_priority = metadata.get("priority", 10)
2284+
if old_priority == priority:
2285+
console.print(f"[yellow]Preset '{pack_id}' already has priority {priority}[/yellow]")
2286+
raise typer.Exit(0)
2287+
2288+
# Update priority
2289+
manager.registry.update(pack_id, {"priority": priority})
2290+
2291+
console.print(f"[green]✓[/green] Preset '{pack_id}' priority changed: {old_priority}{priority}")
2292+
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
2293+
2294+
22442295
# ===== Preset Catalog Commands =====
22452296

22462297

@@ -2576,8 +2627,9 @@ def extension_list(
25762627
status_color = "green" if ext["enabled"] else "red"
25772628

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

25832635
if available or all_extensions:
@@ -2765,6 +2817,7 @@ def extension_add(
27652817
extension: str = typer.Argument(help="Extension name or path"),
27662818
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
27672819
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
2820+
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
27682821
):
27692822
"""Install an extension."""
27702823
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
@@ -2794,7 +2847,7 @@ def extension_add(
27942847
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
27952848
raise typer.Exit(1)
27962849

2797-
manifest = manager.install_from_directory(source_path, speckit_version)
2850+
manifest = manager.install_from_directory(source_path, speckit_version, priority=priority)
27982851

27992852
elif from_url:
28002853
# Install from URL (ZIP file)
@@ -2827,7 +2880,7 @@ def extension_add(
28272880
zip_path.write_bytes(zip_data)
28282881

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

28722925
try:
28732926
# Install from downloaded ZIP
2874-
manifest = manager.install_from_zip(zip_path, speckit_version)
2927+
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
28752928
finally:
28762929
# Clean up downloaded ZIP
28772930
if zip_path.exists():
@@ -3113,6 +3166,8 @@ def extension_info(
31133166

31143167
console.print()
31153168
console.print("[green]✓ Installed[/green]")
3169+
priority = metadata.get("priority", 10)
3170+
console.print(f"[dim]Priority:[/dim] {priority}")
31163171
console.print(f"\nTo remove: specify extension remove {resolved_installed_id}")
31173172
return
31183173

@@ -3206,6 +3261,9 @@ def _print_extension_info(ext_info: dict, manager):
32063261
install_allowed = ext_info.get("_install_allowed", True)
32073262
if is_installed:
32083263
console.print("[green]✓ Installed[/green]")
3264+
metadata = manager.registry.get(ext_info['id'])
3265+
priority = metadata.get("priority", 10) if metadata else 10
3266+
console.print(f"[dim]Priority:[/dim] {priority}")
32093267
console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
32103268
elif install_allowed:
32113269
console.print("[yellow]Not installed[/yellow]")
@@ -3470,6 +3528,10 @@ def extension_update(
34703528
if "installed_at" in backup_registry_entry:
34713529
new_metadata["installed_at"] = backup_registry_entry["installed_at"]
34723530

3531+
# Preserve the original priority
3532+
if "priority" in backup_registry_entry:
3533+
new_metadata["priority"] = backup_registry_entry["priority"]
3534+
34733535
# If extension was disabled before update, disable it again
34743536
if not backup_registry_entry.get("enabled", True):
34753537
new_metadata["enabled"] = False
@@ -3716,6 +3778,52 @@ def extension_disable(
37163778
console.print(f"To re-enable: specify extension enable {extension_id}")
37173779

37183780

3781+
@extension_app.command("set-priority")
3782+
def extension_set_priority(
3783+
extension: str = typer.Argument(help="Extension ID or name"),
3784+
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
3785+
):
3786+
"""Set the resolution priority of an installed extension."""
3787+
from .extensions import ExtensionManager
3788+
3789+
project_root = Path.cwd()
3790+
3791+
# Check if we're in a spec-kit project
3792+
specify_dir = project_root / ".specify"
3793+
if not specify_dir.exists():
3794+
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
3795+
console.print("Run this command from a spec-kit project root")
3796+
raise typer.Exit(1)
3797+
3798+
# Validate priority
3799+
if priority < 1:
3800+
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
3801+
raise typer.Exit(1)
3802+
3803+
manager = ExtensionManager(project_root)
3804+
3805+
# Resolve extension ID from argument (handles ambiguous names)
3806+
installed = manager.list_installed()
3807+
extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority")
3808+
3809+
# Get current metadata
3810+
metadata = manager.registry.get(extension_id)
3811+
if metadata is None:
3812+
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
3813+
raise typer.Exit(1)
3814+
3815+
old_priority = metadata.get("priority", 10)
3816+
if old_priority == priority:
3817+
console.print(f"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]")
3818+
raise typer.Exit(0)
3819+
3820+
# Update priority
3821+
manager.registry.update(extension_id, {"priority": priority})
3822+
3823+
console.print(f"[green]✓[/green] Extension '{display_name}' priority changed: {old_priority}{priority}")
3824+
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
3825+
3826+
37193827
def main():
37203828
app()
37213829

src/specify_cli/extensions.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,20 @@ def is_installed(self, extension_id: str) -> bool:
324324
"""
325325
return extension_id in self.data["extensions"]
326326

327+
def list_by_priority(self) -> List[tuple]:
328+
"""Get all installed extensions sorted by priority.
329+
330+
Lower priority number = higher precedence (checked first).
331+
332+
Returns:
333+
List of (extension_id, metadata) tuples sorted by priority
334+
"""
335+
extensions = self.data["extensions"]
336+
return sorted(
337+
extensions.items(),
338+
key=lambda item: item[1].get("priority", 10),
339+
)
340+
327341

328342
class ExtensionManager:
329343
"""Manages extension lifecycle: installation, removal, updates."""
@@ -440,14 +454,16 @@ def install_from_directory(
440454
self,
441455
source_dir: Path,
442456
speckit_version: str,
443-
register_commands: bool = True
457+
register_commands: bool = True,
458+
priority: int = 10,
444459
) -> ExtensionManifest:
445460
"""Install extension from a local directory.
446461
447462
Args:
448463
source_dir: Path to extension directory
449464
speckit_version: Current spec-kit version
450465
register_commands: If True, register commands with AI agents
466+
priority: Resolution priority (lower = higher precedence, default 10)
451467
452468
Returns:
453469
Installed extension manifest
@@ -497,6 +513,7 @@ def install_from_directory(
497513
"source": "local",
498514
"manifest_hash": manifest.get_hash(),
499515
"enabled": True,
516+
"priority": priority,
500517
"registered_commands": registered_commands
501518
})
502519

@@ -505,13 +522,15 @@ def install_from_directory(
505522
def install_from_zip(
506523
self,
507524
zip_path: Path,
508-
speckit_version: str
525+
speckit_version: str,
526+
priority: int = 10,
509527
) -> ExtensionManifest:
510528
"""Install extension from ZIP file.
511529
512530
Args:
513531
zip_path: Path to extension ZIP file
514532
speckit_version: Current spec-kit version
533+
priority: Resolution priority (lower = higher precedence, default 10)
515534
516535
Returns:
517536
Installed extension manifest
@@ -554,7 +573,7 @@ def install_from_zip(
554573
raise ValidationError("No extension.yml found in ZIP file")
555574

556575
# Install from extracted directory
557-
return self.install_from_directory(extension_dir, speckit_version)
576+
return self.install_from_directory(extension_dir, speckit_version, priority=priority)
558577

559578
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
560579
"""Remove an installed extension.
@@ -643,6 +662,7 @@ def list_installed(self) -> List[Dict[str, Any]]:
643662
"version": metadata.get("version", "unknown"),
644663
"description": manifest.description,
645664
"enabled": metadata.get("enabled", True),
665+
"priority": metadata.get("priority", 10),
646666
"installed_at": metadata.get("installed_at"),
647667
"command_count": len(manifest.commands),
648668
"hook_count": len(manifest.hooks)
@@ -655,6 +675,7 @@ def list_installed(self) -> List[Dict[str, Any]]:
655675
"version": metadata.get("version", "unknown"),
656676
"description": "⚠️ Corrupted extension",
657677
"enabled": False,
678+
"priority": metadata.get("priority", 10),
658679
"installed_at": metadata.get("installed_at"),
659680
"command_count": 0,
660681
"hook_count": 0

0 commit comments

Comments
 (0)