Skip to content
Merged
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
20 changes: 18 additions & 2 deletions presets/catalog.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
{
"schema_version": "1.0",
"updated_at": "2026-03-10T00:00:00Z",
"updated_at": "2026-04-10T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json",
"presets": {}
"presets": {
"lean": {
"name": "Lean Workflow",
"id": "lean",
"version": "1.0.0",
"description": "Minimal core workflow commands - just the prompt, just the artifact",
"author": "github",
"repository": "https://github.com/github/spec-kit",
"bundled": true,
"tags": [
"lean",
"minimal",
"workflow",
"core"
]
}
}
}
15 changes: 15 additions & 0 deletions presets/lean/commands/speckit.constitution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
description: Create or update the project constitution.
---

## User Input

```text
$ARGUMENTS
```

## Outline

1. Create or update the project constitution and store it in `.specify/memory/constitution.md`.
- Project name, guiding principles, non-negotiable rules
- Derive from user input and existing repo context (README, docs)
22 changes: 22 additions & 0 deletions presets/lean/commands/speckit.implement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
description: Execute the implementation plan by processing all tasks in tasks.md.
---

## User Input

```text
$ARGUMENTS
```

## Outline

1. Read `.specify/feature.json` to get the feature directory path.

2. **Load context**: `.specify/memory/constitution.md` and `<feature_directory>/spec.md` and `<feature_directory>/plan.md` and `<feature_directory>/tasks.md`.

3. **Execute tasks** in order:
- Complete each task before moving to the next
- Mark completed tasks by changing `- [ ]` to `- [x]` in `<feature_directory>/tasks.md`
- Halt on failure and report the issue

4. **Validate**: Verify all tasks are completed and the implementation matches the spec.
19 changes: 19 additions & 0 deletions presets/lean/commands/speckit.plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
description: Create a plan and store it in plan.md.
---

## User Input

```text
$ARGUMENTS
```

## Outline

1. Read `.specify/feature.json` to get the feature directory path.

2. **Load context**: `.specify/memory/constitution.md` and `<feature_directory>/spec.md`.

3. Create an implementation plan and store it in `<feature_directory>/plan.md`.
- Technical context: tech stack, dependencies, project structure
- Design decisions, architecture, file structure
23 changes: 23 additions & 0 deletions presets/lean/commands/speckit.specify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
description: Create a specification and store it in spec.md.
---

## User Input

```text
$ARGUMENTS
```

## Outline

1. **Ask the user** for the feature directory path (e.g., `specs/my-feature`). Do not proceed until provided.

2. Create the directory and write `.specify/feature.json`:
```json
{ "feature_directory": "<feature_directory>" }
```

3. Create a specification from the user input and store it in `<feature_directory>/spec.md`.
- Overview, functional requirements, user scenarios, success criteria
- Every requirement must be testable
- Make informed defaults for unspecified details
19 changes: 19 additions & 0 deletions presets/lean/commands/speckit.tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
description: Create the tasks needed for implementation and store them in tasks.md.
---

## User Input

```text
$ARGUMENTS
```

## Outline

1. Read `.specify/feature.json` to get the feature directory path.

2. **Load context**: `.specify/memory/constitution.md` and `<feature_directory>/spec.md` and `<feature_directory>/plan.md`.

3. Create dependency-ordered implementation tasks and store them in `<feature_directory>/tasks.md`.
- Every task uses checklist format: `- [ ] [TaskID] Description with file path`
- Organized by phase: setup, foundational, user stories in priority order, polish
50 changes: 50 additions & 0 deletions presets/lean/preset.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
schema_version: "1.0"

preset:
id: "lean"
name: "Lean Workflow"
version: "1.0.0"
description: "Minimal core workflow commands - just the prompt, just the artifact"
author: "github"
repository: "https://github.com/github/spec-kit"
license: "MIT"

requires:
speckit_version: ">=0.6.0"

provides:
templates:
- type: "command"
name: "speckit.specify"
file: "commands/speckit.specify.md"
description: "Lean specify - create spec.md from a feature description"
replaces: "speckit.specify"

- type: "command"
name: "speckit.plan"
file: "commands/speckit.plan.md"
description: "Lean plan - create plan.md from the spec"
replaces: "speckit.plan"

- type: "command"
name: "speckit.tasks"
file: "commands/speckit.tasks.md"
description: "Lean tasks - create tasks.md from plan and spec"
replaces: "speckit.tasks"

- type: "command"
name: "speckit.implement"
file: "commands/speckit.implement.md"
description: "Lean implement - execute tasks from tasks.md"
replaces: "speckit.implement"

- type: "command"
name: "speckit.constitution"
file: "commands/speckit.constitution.md"
description: "Lean constitution - create or update project constitution"
replaces: "speckit.constitution"

tags:
- "lean"
- "minimal"
- "workflow"
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ packages = ["src/specify_cli"]
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
# Bundled extensions (installable via `specify extension add <name>`)
"extensions/git" = "specify_cli/core_pack/extensions/git"
# Bundled presets (installable via `specify preset add <name>` or `specify init --preset <name>`)
"presets/lean" = "specify_cli/core_pack/presets/lean"

[project.optional-dependencies]
test = [
Expand Down
130 changes: 97 additions & 33 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,31 @@ def _locate_bundled_extension(extension_id: str) -> Path | None:
return None


def _locate_bundled_preset(preset_id: str) -> Path | None:
"""Return the path to a bundled preset, or None.

Checks the wheel's core_pack first, then falls back to the
source-checkout ``presets/<id>/`` directory.
"""
import re as _re
if not _re.match(r'^[a-z0-9-]+$', preset_id):
return None

core = _locate_core_pack()
if core is not None:
candidate = core / "presets" / preset_id
if (candidate / "preset.yml").is_file():
return candidate

# Source-checkout / editable install: look relative to repo root
repo_root = Path(__file__).parent.parent.parent
candidate = repo_root / "presets" / preset_id
if (candidate / "preset.yml").is_file():
return candidate

return None


def _install_shared_infra(
project_path: Path,
script_type: str,
Expand Down Expand Up @@ -1266,27 +1291,44 @@ def init(
preset_manager = PresetManager(project_path)
speckit_ver = get_speckit_version()

# Try local directory first, then catalog
# Try local directory first, then bundled, then catalog
local_path = Path(preset).resolve()
if local_path.is_dir() and (local_path / "preset.yml").exists():
preset_manager.install_from_directory(local_path, speckit_ver)
else:
preset_catalog = PresetCatalog(project_path)
pack_info = preset_catalog.get_pack_info(preset)
if not pack_info:
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
bundled_path = _locate_bundled_preset(preset)
if bundled_path:
preset_manager.install_from_directory(bundled_path, speckit_ver)
else:
try:
zip_path = preset_catalog.download_pack(preset)
preset_manager.install_from_zip(zip_path, speckit_ver)
# Clean up downloaded ZIP to avoid cache accumulation
preset_catalog = PresetCatalog(project_path)
pack_info = preset_catalog.get_pack_info(preset)
if not pack_info:
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
elif pack_info.get("bundled") and not pack_info.get("download_url"):
from .extensions import REINSTALL_COMMAND
console.print(
f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit "
f"but could not be found in the installed package."
)
console.print(
"This usually means the spec-kit installation is incomplete or corrupted."
)
console.print(f"Try reinstalling: {REINSTALL_COMMAND}")
else:
zip_path = None
try:
zip_path.unlink(missing_ok=True)
except OSError:
# Best-effort cleanup; failure to delete is non-fatal
pass
except PresetError as preset_err:
console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}")
zip_path = preset_catalog.download_pack(preset)
preset_manager.install_from_zip(zip_path, speckit_ver)
except PresetError as preset_err:
console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}")
finally:
if zip_path is not None:
# Clean up downloaded ZIP to avoid cache accumulation
try:
zip_path.unlink(missing_ok=True)
except OSError:
# Best-effort cleanup; failure to delete is non-fatal
pass
except Exception as preset_err:
console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")

Expand Down Expand Up @@ -2140,28 +2182,50 @@ def preset_add(
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")

elif pack_id:
catalog = PresetCatalog(project_root)
pack_info = catalog.get_pack_info(pack_id)
# Try bundled preset first, then catalog
bundled_path = _locate_bundled_preset(pack_id)
if bundled_path:
console.print(f"Installing bundled preset [cyan]{pack_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)

if not pack_info:
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog")
raise typer.Exit(1)
if not pack_info:
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog")
raise typer.Exit(1)

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("Add the catalog with --install-allowed or install from the preset's repository directly with --from.")
raise typer.Exit(1)
# Bundled presets should have been caught above; if we reach
# here the bundled files are missing from the installation.
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"but could not be found in the installed package."
)
console.print(
"\nThis usually means the spec-kit installation is incomplete or corrupted."
)
console.print("Try reinstalling spec-kit:")
console.print(f" {REINSTALL_COMMAND}")
raise typer.Exit(1)

console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...")
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("Add the catalog with --install-allowed or install from the preset's repository directly with --from.")
raise typer.Exit(1)

try:
zip_path = catalog.download_pack(pack_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:
if 'zip_path' in locals() and zip_path.exists():
zip_path.unlink(missing_ok=True)
console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...")

try:
zip_path = catalog.download_pack(pack_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:
if 'zip_path' in locals() and zip_path.exists():
zip_path.unlink(missing_ok=True)
else:
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
raise typer.Exit(1)
Expand Down
10 changes: 10 additions & 0 deletions src/specify_cli/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1587,6 +1587,16 @@ def download_pack(
f"Preset '{pack_id}' not found in catalog"
)

# Bundled presets without a download URL must be installed locally
if pack_info.get("bundled") and not pack_info.get("download_url"):
from .extensions import REINSTALL_COMMAND
raise PresetError(
f"Preset '{pack_id}' is bundled with spec-kit and has no download URL. "
f"It should be installed from the local package. "
f"Use 'specify preset add {pack_id}' to install from the bundled package, "
f"or reinstall spec-kit if the bundled files are missing: {REINSTALL_COMMAND}"
)

if not pack_info.get("_install_allowed", True):
catalog_name = pack_info.get("_catalog_name", "unknown")
raise PresetError(
Expand Down
Loading
Loading