diff --git a/README.md b/README.md index 38d5fb3028..b8be28b66c 100644 --- a/README.md +++ b/README.md @@ -526,7 +526,7 @@ specify preset add For example, presets could restructure spec templates to require regulatory traceability, adapt the workflow to fit the methodology you use (e.g., Agile, Kanban, Waterfall, jobs-to-be-done, or domain-driven design), add mandatory security review gates to plans, enforce test-first task ordering, or localize the entire workflow to a different language. The [pirate-speak demo](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo) shows just how deep the customization can go. Multiple presets can be stacked with priority ordering. -See the [Presets README](./presets/README.md) for the full guide, including resolution order, priority, and how to create your own. +See the [Presets reference](https://github.github.io/spec-kit/presets.html) for the full command guide, including resolution order and priority stacking. ### When to Use Which diff --git a/docs/presets.md b/docs/presets.md new file mode 100644 index 0000000000..4a613ffc00 --- /dev/null +++ b/docs/presets.md @@ -0,0 +1,224 @@ +# Presets + +Presets customize how Spec Kit works — overriding templates, commands, and terminology without changing any tooling. They let you enforce organizational standards, adapt the workflow to your methodology, or localize the entire experience. Multiple presets can be stacked with priority ordering. + +## Search Available Presets + +```bash +specify preset search [query] +``` + +| Option | Description | +| ---------- | -------------------- | +| `--tag` | Filter by tag | +| `--author` | Filter by author | + +Searches all active catalogs for presets matching the query. Without a query, lists all available presets. + +## Install a Preset + +```bash +specify preset add [] +``` + +| Option | Description | +| ---------------- | -------------------------------------------------------- | +| `--dev ` | Install from a local directory (for development) | +| `--from ` | Install from a custom URL instead of the catalog | +| `--priority ` | Resolution priority (default: 10; lower = higher precedence) | + +Installs a preset from the catalog, a URL, or a local directory. Preset commands are automatically registered with the currently installed AI coding agent integration. + +> **Note:** All preset commands require a project already initialized with `specify init`. + +## Remove a Preset + +```bash +specify preset remove +``` + +Removes an installed preset and cleans up its registered commands. + +## List Installed Presets + +```bash +specify preset list +``` + +Lists installed presets with their versions, descriptions, template counts, and current status. + +## Preset Info + +```bash +specify preset info +``` + +Shows detailed information about an installed or available preset, including its templates, metadata, and tags. + +## Resolve a File + +```bash +specify preset resolve +``` + +Shows which file will be used for a given name by tracing the full resolution stack. Useful for debugging when multiple presets provide the same file. + +## Enable / Disable a Preset + +```bash +specify preset enable +specify preset disable +``` + +Disable a preset without removing it. Disabled presets are skipped during file resolution but their commands remain registered. Re-enable with `enable`. + +## Set Preset Priority + +```bash +specify preset set-priority +``` + +Changes the resolution priority of an installed preset. Lower numbers take precedence. When multiple presets provide the same file, the one with the lowest priority number wins. + +## Catalog Management + +Preset catalogs control where `search` and `add` look for presets. Catalogs are checked in priority order (lower number = higher precedence). + +### List Catalogs + +```bash +specify preset catalog list +``` + +Shows all active catalogs with their priorities and install permissions. + +### Add a Catalog + +```bash +specify preset catalog add +``` + +| Option | Description | +| -------------------------------------------- | -------------------------------------------------- | +| `--name ` | Required. Unique name for the catalog | +| `--priority ` | Priority (default: 10; lower = higher precedence) | +| `--install-allowed / --no-install-allowed` | Whether presets can be installed from this catalog (default: discovery only) | +| `--description ` | Optional description | + +Adds a catalog to the project's `.specify/preset-catalogs.yml`. + +### Remove a Catalog + +```bash +specify preset catalog remove +``` + +Removes a catalog from the project configuration. + +### Catalog Resolution Order + +Catalogs are resolved in this order (first match wins): + +1. **Environment variable** — `SPECKIT_PRESET_CATALOG_URL` overrides all catalogs +2. **Project config** — `.specify/preset-catalogs.yml` +3. **User config** — `~/.specify/preset-catalogs.yml` +4. **Built-in defaults** — official catalog + community catalog + +Example `.specify/preset-catalogs.yml`: + +```yaml +catalogs: + - name: "my-org-presets" + url: "https://example.com/preset-catalog.json" + priority: 5 + install_allowed: true + description: "Our approved presets" +``` + +## File Resolution + +Presets can provide command files, template files (like `plan-template.md`), and script files. These are resolved at runtime using a **replace** strategy — the first match in the priority stack wins and is used entirely. Each file is looked up independently, so different files can come from different layers. + +> **Note:** Additional composition strategies (`append`, `prepend`, `wrap`) are planned for a future release. + +The resolution stack, from highest to lowest precedence: + +1. **Project-local overrides** — `.specify/templates/overrides/` +2. **Installed presets** — sorted by priority (lower = checked first) +3. **Installed extensions** — sorted by priority +4. **Spec Kit core** — `.specify/templates/` + +Commands are registered at install time (not resolved through the stack at runtime). + +### Resolution Stack + +```mermaid +flowchart TB + subgraph stack [" "] + direction TB + A["⬆ Highest precedence

1. Project-local overrides
.specify/templates/overrides/"] + B["2. Presets — by priority
.specify/presets/‹id›/"] + C["3. Extensions — by priority
.specify/extensions/‹id›/"] + D["4. Spec Kit core
.specify/templates/

⬇ Lowest precedence"] + end + + A --> B --> C --> D + + style A fill:#4a9,color:#fff + style B fill:#49a,color:#fff + style C fill:#a94,color:#fff + style D fill:#999,color:#fff +``` + +Within each layer, files are organized by type: + +| Type | Subdirectory | Override path | +| --------- | -------------- | ------------------------------------------ | +| Templates | `templates/` | `.specify/templates/overrides/` | +| Commands | `commands/` | `.specify/templates/overrides/` | +| Scripts | `scripts/` | `.specify/templates/overrides/scripts/` | + +### Resolution in Action + +```mermaid +flowchart TB + A["File requested:
plan-template.md"] --> B{"Project-local override?"} + B -- Found --> Z["✓ Use this file"] + B -- Not found --> C{"Preset: compliance
(priority 5)"} + C -- Found --> Z + C -- Not found --> D{"Preset: team-workflow
(priority 10)"} + D -- Found --> Z + D -- Not found --> E{"Extension files?"} + E -- Found --> Z + E -- Not found --> F["Spec Kit core"] + F --> Z +``` + +### Example + +```bash +specify preset add compliance --priority 5 +specify preset add team-workflow --priority 10 +``` + +For any file that both provide, `compliance` wins (priority 5 < 10). For files only one provides, that one is used. For files neither provides, the core default is used. + +## FAQ + +### Can I use multiple presets at the same time? + +Yes. Presets stack by priority — each file is resolved independently from the highest-priority source that provides it. Use `specify preset set-priority` to control the order. + +### How do I see which file is actually being used? + +Run `specify preset resolve ` to trace the resolution stack and see which file wins. + +### What's the difference between disabling and removing a preset? + +**Disabling** (`specify preset disable`) keeps the preset installed but excludes its files from the resolution stack. Commands the preset registered remain available in your AI coding agent. This is useful for temporarily testing behavior without a preset, or comparing output with and without it. Re-enable anytime with `specify preset enable`. + +**Removing** (`specify preset remove`) fully uninstalls the preset — deletes its files, unregisters its commands from your AI coding agent, and removes it from the registry. + +### Who maintains presets? + +Most presets are independently created and maintained by their respective authors. The Spec Kit maintainers do not review, audit, endorse, or support preset code. Review a preset's source code before installing and use at your own discretion. For issues with a specific preset, contact its author or file an issue on the preset's repository. diff --git a/docs/toc.yml b/docs/toc.yml index b65fcac9c8..3f53367075 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -19,6 +19,8 @@ href: integrations.md - name: Extensions href: extensions.md + - name: Presets + href: presets.md # Development workflows - name: Development diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 9f895cb3b9..5c079ece89 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2376,7 +2376,7 @@ def preset_list(): @preset_app.command("add") def preset_add( - pack_id: str = typer.Argument(None, help="Preset ID to install from catalog"), + preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"), from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"), dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"), priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), @@ -2444,19 +2444,19 @@ def preset_add( console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - elif pack_id: + elif preset_id: # Try bundled preset first, then catalog - bundled_path = _locate_bundled_preset(pack_id) + bundled_path = _locate_bundled_preset(preset_id) if bundled_path: - console.print(f"Installing bundled preset [cyan]{pack_id}[/cyan]...") + console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...") manifest = manager.install_from_directory(bundled_path, speckit_version, priority) console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") else: catalog = PresetCatalog(project_root) - pack_info = catalog.get_pack_info(pack_id) + pack_info = catalog.get_pack_info(preset_id) if not pack_info: - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in catalog") raise typer.Exit(1) # Bundled presets should have been caught above; if we reach @@ -2464,7 +2464,7 @@ def preset_add( if pack_info.get("bundled") and not pack_info.get("download_url"): from .extensions import REINSTALL_COMMAND console.print( - f"[red]Error:[/red] Preset '{pack_id}' is bundled with spec-kit " + f"[red]Error:[/red] Preset '{preset_id}' is bundled with spec-kit " f"but could not be found in the installed package." ) console.print( @@ -2476,14 +2476,14 @@ def preset_add( if not pack_info.get("_install_allowed", True): catalog_name = pack_info.get("_catalog_name", "unknown") - console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") + console.print(f"[red]Error:[/red] Preset '{preset_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.") raise typer.Exit(1) - console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...") + console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...") try: - zip_path = catalog.download_pack(pack_id) + zip_path = catalog.download_pack(preset_id) manifest = manager.install_from_zip(zip_path, speckit_version, priority) console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") finally: @@ -2506,7 +2506,7 @@ def preset_add( @preset_app.command("remove") def preset_remove( - pack_id: str = typer.Argument(..., help="Preset ID to remove"), + preset_id: str = typer.Argument(..., help="Preset ID to remove"), ): """Remove an installed preset.""" from .presets import PresetManager @@ -2521,14 +2521,14 @@ def preset_remove( manager = PresetManager(project_root) - if not manager.registry.is_installed(pack_id): - console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed") + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") raise typer.Exit(1) - if manager.remove(pack_id): - console.print(f"[green]✓[/green] Preset '{pack_id}' removed successfully") + if manager.remove(preset_id): + console.print(f"[green]✓[/green] Preset '{preset_id}' removed successfully") else: - console.print(f"[red]Error:[/red] Failed to remove preset '{pack_id}'") + console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'") raise typer.Exit(1) @@ -2599,7 +2599,7 @@ def preset_resolve( @preset_app.command("info") def preset_info( - pack_id: str = typer.Argument(..., help="Preset ID to get info about"), + preset_id: str = typer.Argument(..., help="Preset ID to get info about"), ): """Show detailed information about a preset.""" from .extensions import normalize_priority @@ -2615,7 +2615,7 @@ def preset_info( # Check if installed locally first manager = PresetManager(project_root) - local_pack = manager.get_pack(pack_id) + local_pack = manager.get_pack(preset_id) if local_pack: console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n") @@ -2637,7 +2637,7 @@ def preset_info( console.print(f" License: {license_val}") console.print("\n [green]Status: installed[/green]") # Get priority from registry - pack_metadata = manager.registry.get(pack_id) + pack_metadata = manager.registry.get(preset_id) priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None) console.print(f" [dim]Priority:[/dim] {priority}") console.print() @@ -2646,15 +2646,15 @@ def preset_info( # Fall back to catalog catalog = PresetCatalog(project_root) try: - pack_info = catalog.get_pack_info(pack_id) + pack_info = catalog.get_pack_info(preset_id) except PresetError: pack_info = None if not pack_info: - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found (not installed and not in catalog)") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)") raise typer.Exit(1) - console.print(f"\n[bold cyan]Preset: {pack_info.get('name', pack_id)}[/bold cyan]\n") + console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n") console.print(f" ID: {pack_info['id']}") console.print(f" Version: {pack_info.get('version', '?')}") console.print(f" Description: {pack_info.get('description', '')}") @@ -2667,13 +2667,13 @@ def preset_info( if pack_info.get("license"): console.print(f" License: {pack_info['license']}") console.print("\n [yellow]Status: not installed[/yellow]") - console.print(f" Install with: [cyan]specify preset add {pack_id}[/cyan]") + console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]") console.print() @preset_app.command("set-priority") def preset_set_priority( - pack_id: str = typer.Argument(help="Preset ID"), + preset_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.""" @@ -2696,14 +2696,14 @@ def preset_set_priority( 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") + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") raise typer.Exit(1) # Get current metadata - metadata = manager.registry.get(pack_id) + metadata = manager.registry.get(preset_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)") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") raise typer.Exit(1) from .extensions import normalize_priority @@ -2711,21 +2711,21 @@ def preset_set_priority( # Only skip if the stored value is already a valid int equal to requested priority # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) if isinstance(raw_priority, int) and raw_priority == priority: - console.print(f"[yellow]Preset '{pack_id}' already has priority {priority}[/yellow]") + console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]") raise typer.Exit(0) old_priority = normalize_priority(raw_priority) # Update priority - manager.registry.update(pack_id, {"priority": priority}) + manager.registry.update(preset_id, {"priority": priority}) - console.print(f"[green]✓[/green] Preset '{pack_id}' priority changed: {old_priority} → {priority}") + console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {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"), + preset_id: str = typer.Argument(help="Preset ID to enable"), ): """Enable a disabled preset.""" from .presets import PresetManager @@ -2742,31 +2742,31 @@ def preset_enable( 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") + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") raise typer.Exit(1) # Get current metadata - metadata = manager.registry.get(pack_id) + metadata = manager.registry.get(preset_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)") + console.print(f"[red]Error:[/red] Preset '{preset_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]") + console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]") raise typer.Exit(0) # Enable the preset - manager.registry.update(pack_id, {"enabled": True}) + manager.registry.update(preset_id, {"enabled": True}) - console.print(f"[green]✓[/green] Preset '{pack_id}' enabled") + console.print(f"[green]✓[/green] Preset '{preset_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"), + preset_id: str = typer.Argument(help="Preset ID to disable"), ): """Disable a preset without removing it.""" from .presets import PresetManager @@ -2783,27 +2783,27 @@ def preset_disable( 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") + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") raise typer.Exit(1) # Get current metadata - metadata = manager.registry.get(pack_id) + metadata = manager.registry.get(preset_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)") + console.print(f"[red]Error:[/red] Preset '{preset_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]") + console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]") raise typer.Exit(0) # Disable the preset - manager.registry.update(pack_id, {"enabled": False}) + manager.registry.update(preset_id, {"enabled": False}) - console.print(f"[green]✓[/green] Preset '{pack_id}' disabled") + console.print(f"[green]✓[/green] Preset '{preset_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}") + console.print(f"To re-enable: specify preset enable {preset_id}") # ===== Preset Catalog Commands =====