From 4eccd7f73be5a4df034760eca3f9f3c4bbb424d7 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:46:54 -0500 Subject: [PATCH 1/7] feat: add bundled lean preset with minimal workflow commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a lean preset that overrides the 5 core workflow commands (specify, plan, tasks, implement, constitution) with minimal prompts that produce exactly one artifact each — no extension hooks, no scripts, no git branching, no templates. Bundled preset infrastructure: - Add _locate_bundled_preset() mirroring _locate_bundled_extension() - Update 'specify init --preset' to try bundled -> catalog fallback - Update 'specify preset add' to try bundled -> catalog fallback - Add bundled guard in download_pack() for presets without download URLs - Add lean to presets/catalog.json with 'bundled: true' marker - Add lean to pyproject.toml force-include for wheel packaging - Align error messages with bundled extension error pattern Tests: 15 new tests (TestLeanPreset + TestBundledPresetLocator) --- presets/catalog.json | 20 ++- presets/lean/commands/speckit.constitution.md | 15 ++ presets/lean/commands/speckit.implement.md | 23 +++ presets/lean/commands/speckit.plan.md | 19 ++ presets/lean/commands/speckit.specify.md | 23 +++ presets/lean/commands/speckit.tasks.md | 19 ++ presets/lean/preset.yml | 50 ++++++ pyproject.toml | 2 + src/specify_cli/__init__.py | 127 +++++++++---- src/specify_cli/presets.py | 9 + tests/test_presets.py | 170 ++++++++++++++++++ 11 files changed, 442 insertions(+), 35 deletions(-) create mode 100644 presets/lean/commands/speckit.constitution.md create mode 100644 presets/lean/commands/speckit.implement.md create mode 100644 presets/lean/commands/speckit.plan.md create mode 100644 presets/lean/commands/speckit.specify.md create mode 100644 presets/lean/commands/speckit.tasks.md create mode 100644 presets/lean/preset.yml diff --git a/presets/catalog.json b/presets/catalog.json index ca40f85280..1b036a7630 100644 --- a/presets/catalog.json +++ b/presets/catalog.json @@ -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" + ] + } + } } diff --git a/presets/lean/commands/speckit.constitution.md b/presets/lean/commands/speckit.constitution.md new file mode 100644 index 0000000000..920337003e --- /dev/null +++ b/presets/lean/commands/speckit.constitution.md @@ -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) diff --git a/presets/lean/commands/speckit.implement.md b/presets/lean/commands/speckit.implement.md new file mode 100644 index 0000000000..93dbbed5be --- /dev/null +++ b/presets/lean/commands/speckit.implement.md @@ -0,0 +1,23 @@ +--- +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**: `constitution.md` and `spec.md` and `plan.md` and `tasks.md`. + +3. **Execute tasks** phase by phase: + - Complete each phase before moving to the next + - Respect dependencies: sequential tasks in order, `[P]` tasks can run together + - Mark completed tasks as `[X]` in tasks.md + - Halt on failure for sequential tasks; continue and report for parallel tasks + +4. **Validate**: Verify all tasks are completed and the implementation matches the spec. diff --git a/presets/lean/commands/speckit.plan.md b/presets/lean/commands/speckit.plan.md new file mode 100644 index 0000000000..aa0a9f52d0 --- /dev/null +++ b/presets/lean/commands/speckit.plan.md @@ -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**: `constitution.md` and `spec.md`. + +3. Create an implementation plan and store it in `/plan.md`. + - Technical context: tech stack, dependencies, project structure + - Design decisions, architecture, file structure diff --git a/presets/lean/commands/speckit.specify.md b/presets/lean/commands/speckit.specify.md new file mode 100644 index 0000000000..c15353557a --- /dev/null +++ b/presets/lean/commands/speckit.specify.md @@ -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": "" } + ``` + +3. Create a specification from the user input and store it in `/spec.md`. + - Overview, functional requirements, user scenarios, success criteria + - Every requirement must be testable + - Make informed defaults for unspecified details diff --git a/presets/lean/commands/speckit.tasks.md b/presets/lean/commands/speckit.tasks.md new file mode 100644 index 0000000000..e06f2d933f --- /dev/null +++ b/presets/lean/commands/speckit.tasks.md @@ -0,0 +1,19 @@ +--- +description: Create the tasks needed for implementation and store it in tasks.md. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Outline + +1. Read `.specify/feature.json` to get the feature directory path. + +2. **Load context**: `constitution.md` and `spec.md` and `plan.md`. + +3. Create dependency-ordered implementation tasks and store them in `/tasks.md`. + - Every task uses checklist format: `- [ ] [TaskID] Description with file path` + - Organized by phase: setup, foundational, user stories in priority order, polish diff --git a/presets/lean/preset.yml b/presets/lean/preset.yml new file mode 100644 index 0000000000..e3bc754cd0 --- /dev/null +++ b/presets/lean/preset.yml @@ -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.1.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" diff --git a/pyproject.toml b/pyproject.toml index e43f812724..5c4e464c9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ packages = ["src/specify_cli"] "scripts/powershell" = "specify_cli/core_pack/scripts/powershell" # Bundled extensions (installable via `specify extension add `) "extensions/git" = "specify_cli/core_pack/extensions/git" +# Bundled presets (installable via `specify preset add ` or `specify init --preset `) +"presets/lean" = "specify_cli/core_pack/presets/lean" [project.optional-dependencies] test = [ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e37c4b45f6..e01f9318b7 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -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//`` 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, @@ -1266,27 +1291,41 @@ 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: 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) + # 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 PresetError as preset_err: + console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}") except Exception as preset_err: console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}") @@ -2140,28 +2179,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) diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 137d1d22a8..80c666960e 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -1587,6 +1587,15 @@ 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"): + 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." + ) + if not pack_info.get("_install_allowed", True): catalog_name = pack_info.get("_catalog_name", "unknown") raise PresetError( diff --git a/tests/test_presets.py b/tests/test_presets.py index d22264f806..d9d607b2a8 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2865,3 +2865,173 @@ def test_disable_corrupted_registry_entry(self, project_dir, pack_dir): assert result.exit_code == 1 assert "corrupted state" in result.output.lower() + + +# ===== Lean Preset Tests ===== + + +LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean" + +LEAN_COMMAND_NAMES = [ + "speckit.specify", + "speckit.plan", + "speckit.tasks", + "speckit.implement", + "speckit.constitution", +] + + +class TestLeanPreset: + """Tests for the lean preset that ships with the repo.""" + + def test_lean_preset_exists(self): + """Verify the lean preset directory and manifest exist.""" + assert LEAN_PRESET_DIR.exists() + assert (LEAN_PRESET_DIR / "preset.yml").exists() + + def test_lean_manifest_valid(self): + """Verify the lean preset manifest is valid.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + assert manifest.id == "lean" + assert manifest.name == "Lean Workflow" + assert manifest.version == "1.0.0" + assert len(manifest.templates) == 5 # 5 commands + + def test_lean_provides_core_workflow_commands(self): + """Verify the lean preset provides overrides for core workflow commands.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + provided_names = {t["name"] for t in manifest.templates} + for name in LEAN_COMMAND_NAMES: + assert name in provided_names, f"Lean preset missing command: {name}" + + def test_lean_command_files_exist(self): + """Verify that all declared command files actually exist on disk.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + for tmpl in manifest.templates: + tmpl_path = LEAN_PRESET_DIR / tmpl["file"] + assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}" + + def test_lean_commands_have_no_scripts(self): + """Verify lean commands do not reference scripts (no git branching dependency).""" + for name in LEAN_COMMAND_NAMES: + cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" + content = cmd_path.read_text() + assert "scripts:" not in content, f"{name} should not have scripts frontmatter" + + def test_lean_commands_have_no_hooks(self): + """Verify lean commands do not contain extension hook boilerplate.""" + for name in LEAN_COMMAND_NAMES: + cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" + content = cmd_path.read_text() + assert "hooks." not in content, f"{name} should not reference extension hooks" + assert "extensions.yml" not in content, f"{name} should not reference extensions.yml" + + def test_install_lean_preset(self, project_dir): + """Test installing the lean preset from its directory.""" + manager = PresetManager(project_dir) + manifest = manager.install_from_directory(LEAN_PRESET_DIR, "0.1.5") + assert manifest.id == "lean" + assert manager.registry.is_installed("lean") + + def test_lean_overrides_commands(self, project_dir): + """Test that lean preset overrides are resolved correctly.""" + manager = PresetManager(project_dir) + manager.install_from_directory(LEAN_PRESET_DIR, "0.1.5") + + resolver = PresetResolver(project_dir) + for name in LEAN_COMMAND_NAMES: + result = resolver.resolve(name, template_type="command") + assert result is not None, f"Lean override for {name} not resolved" + + +# ===== Bundled Preset Locator Tests ===== + + +class TestBundledPresetLocator: + """Tests for _locate_bundled_preset discovery function.""" + + def test_locate_bundled_lean_preset(self): + """_locate_bundled_preset finds the lean preset.""" + from specify_cli import _locate_bundled_preset + + path = _locate_bundled_preset("lean") + assert path is not None + assert (path / "preset.yml").is_file() + + def test_locate_bundled_preset_not_found(self): + """_locate_bundled_preset returns None for nonexistent preset.""" + from specify_cli import _locate_bundled_preset + + path = _locate_bundled_preset("nonexistent-preset") + assert path is None + + def test_locate_bundled_preset_rejects_invalid_id(self): + """_locate_bundled_preset rejects IDs with invalid characters.""" + from specify_cli import _locate_bundled_preset + + assert _locate_bundled_preset("../escape") is None + assert _locate_bundled_preset("UPPERCASE") is None + assert _locate_bundled_preset("has spaces") is None + + def test_bundled_preset_add_via_cli(self, project_dir): + """Test that 'specify preset add lean' installs the bundled preset.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "add", "lean"]) + + assert result.exit_code == 0, result.output + assert "Lean Workflow" in result.output + assert "installed" in result.output.lower() + + def test_bundled_preset_in_catalog(self): + """Verify the lean preset is listed in catalog.json with bundled marker.""" + catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" + catalog = json.loads(catalog_path.read_text()) + assert "lean" in catalog["presets"] + assert catalog["presets"]["lean"]["bundled"] is True + assert "download_url" not in catalog["presets"]["lean"] + + def test_bundled_preset_download_raises_error(self, project_dir): + """download_pack raises PresetError for bundled presets without download_url.""" + catalog = PresetCatalog(project_dir) + + # Write a catalog with a bundled preset (no download_url) + cache_dir = project_dir / ".specify" / "presets" / ".cache" + cache_dir.mkdir(parents=True, exist_ok=True) + + catalog_data = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "presets": { + "test-bundled": { + "name": "Test Bundled", + "version": "1.0.0", + "bundled": True, + } + } + } + # Use the catalog's internal cache mechanism + from unittest.mock import patch, MagicMock + with patch.object(catalog, "_get_merged_packs", return_value=catalog_data["presets"]): + with pytest.raises(PresetError, match="bundled with spec-kit"): + catalog.download_pack("test-bundled") + + def test_bundled_preset_missing_locally_cli_error(self, project_dir): + """CLI shows clear error when bundled preset cannot be found locally.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + # Patch _locate_bundled_preset to return None (simulating missing files) + # and provide a catalog that marks "lean" as bundled + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli._locate_bundled_preset", return_value=None): + result = runner.invoke(app, ["preset", "add", "lean"]) + + # Should fail with a helpful error (either "not found" or "bundled...not found locally") + assert result.exit_code == 1 From 442146cd099f3e9b4e39c208fa13bd0f936c0fbf Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:57:53 -0500 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20address=20review=20=E2=80=94=20?= =?UTF-8?q?clean=20up=20unused=20imports,=20strengthen=20test=20assertions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused MagicMock import and cache_dir setup in download test - Assert 'bundled' and 'reinstall' in CLI error output (not just exit code) - Mock catalog in missing-locally test for deterministic bundled error path - Fix test versions to satisfy updated speckit_version >=0.6.0 requirement --- presets/lean/preset.yml | 2 +- tests/test_presets.py | 47 +++++++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/presets/lean/preset.yml b/presets/lean/preset.yml index e3bc754cd0..fa44917f91 100644 --- a/presets/lean/preset.yml +++ b/presets/lean/preset.yml @@ -10,7 +10,7 @@ preset: license: "MIT" requires: - speckit_version: ">=0.1.0" + speckit_version: ">=0.6.0" provides: templates: diff --git a/tests/test_presets.py b/tests/test_presets.py index d9d607b2a8..c8d0942e54 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2929,14 +2929,14 @@ def test_lean_commands_have_no_hooks(self): def test_install_lean_preset(self, project_dir): """Test installing the lean preset from its directory.""" manager = PresetManager(project_dir) - manifest = manager.install_from_directory(LEAN_PRESET_DIR, "0.1.5") + manifest = manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") assert manifest.id == "lean" assert manager.registry.is_installed("lean") def test_lean_overrides_commands(self, project_dir): """Test that lean preset overrides are resolved correctly.""" manager = PresetManager(project_dir) - manager.install_from_directory(LEAN_PRESET_DIR, "0.1.5") + manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") resolver = PresetResolver(project_dir) for name in LEAN_COMMAND_NAMES: @@ -2980,7 +2980,8 @@ def test_bundled_preset_add_via_cli(self, project_dir): from specify_cli import app runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.get_speckit_version", return_value="0.6.0"): result = runner.invoke(app, ["preset", "add", "lean"]) assert result.exit_code == 0, result.output @@ -2999,24 +3000,15 @@ def test_bundled_preset_download_raises_error(self, project_dir): """download_pack raises PresetError for bundled presets without download_url.""" catalog = PresetCatalog(project_dir) - # Write a catalog with a bundled preset (no download_url) - cache_dir = project_dir / ".specify" / "presets" / ".cache" - cache_dir.mkdir(parents=True, exist_ok=True) - catalog_data = { - "schema_version": "1.0", - "updated_at": "2026-01-01T00:00:00Z", - "presets": { - "test-bundled": { - "name": "Test Bundled", - "version": "1.0.0", - "bundled": True, - } + "test-bundled": { + "name": "Test Bundled", + "version": "1.0.0", + "bundled": True, } } - # Use the catalog's internal cache mechanism - from unittest.mock import patch, MagicMock - with patch.object(catalog, "_get_merged_packs", return_value=catalog_data["presets"]): + from unittest.mock import patch + with patch.object(catalog, "_get_merged_packs", return_value=catalog_data): with pytest.raises(PresetError, match="bundled with spec-kit"): catalog.download_pack("test-bundled") @@ -3028,10 +3020,23 @@ def test_bundled_preset_missing_locally_cli_error(self, project_dir): runner = CliRunner() # Patch _locate_bundled_preset to return None (simulating missing files) - # and provide a catalog that marks "lean" as bundled + # and mock the catalog to return a bundled entry for "lean" + fake_pack_info = { + "id": "lean", + "name": "Lean Workflow", + "version": "1.0.0", + "bundled": True, + "_install_allowed": True, + } with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli._locate_bundled_preset", return_value=None): + patch("specify_cli._locate_bundled_preset", return_value=None), \ + patch("specify_cli.presets.PresetCatalog") as MockCatalog: + MockCatalog.return_value.get_pack_info.return_value = fake_pack_info result = runner.invoke(app, ["preset", "add", "lean"]) - # Should fail with a helpful error (either "not found" or "bundled...not found locally") + # Should fail with a helpful error explaining this is a bundled preset + # and suggesting how to recover. assert result.exit_code == 1 + output = strip_ansi(result.output).lower() + assert "bundled" in output, result.output + assert "reinstall" in output, result.output From 00508dddc340d938d3c8bab10f0173115e2c6079 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:08:02 -0500 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20address=20review=20=E2=80=94=20?= =?UTF-8?q?fix=20constitution=20paths,=20add=20REINSTALL=5FCOMMAND=20to=20?= =?UTF-8?q?presets.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix constitution path to .specify/memory/constitution.md in plan, tasks, implement commands (matching core command convention) - Include REINSTALL_COMMAND in download_pack() bundled guard for consistent recovery instructions across bundled extensions and presets --- presets/lean/commands/speckit.implement.md | 2 +- presets/lean/commands/speckit.plan.md | 2 +- presets/lean/commands/speckit.tasks.md | 2 +- src/specify_cli/presets.py | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/presets/lean/commands/speckit.implement.md b/presets/lean/commands/speckit.implement.md index 93dbbed5be..69079ed924 100644 --- a/presets/lean/commands/speckit.implement.md +++ b/presets/lean/commands/speckit.implement.md @@ -12,7 +12,7 @@ $ARGUMENTS 1. Read `.specify/feature.json` to get the feature directory path. -2. **Load context**: `constitution.md` and `spec.md` and `plan.md` and `tasks.md`. +2. **Load context**: `.specify/memory/constitution.md` and `spec.md` and `plan.md` and `tasks.md`. 3. **Execute tasks** phase by phase: - Complete each phase before moving to the next diff --git a/presets/lean/commands/speckit.plan.md b/presets/lean/commands/speckit.plan.md index aa0a9f52d0..28ebd94efa 100644 --- a/presets/lean/commands/speckit.plan.md +++ b/presets/lean/commands/speckit.plan.md @@ -12,7 +12,7 @@ $ARGUMENTS 1. Read `.specify/feature.json` to get the feature directory path. -2. **Load context**: `constitution.md` and `spec.md`. +2. **Load context**: `.specify/memory/constitution.md` and `spec.md`. 3. Create an implementation plan and store it in `/plan.md`. - Technical context: tech stack, dependencies, project structure diff --git a/presets/lean/commands/speckit.tasks.md b/presets/lean/commands/speckit.tasks.md index e06f2d933f..1058afd1bd 100644 --- a/presets/lean/commands/speckit.tasks.md +++ b/presets/lean/commands/speckit.tasks.md @@ -12,7 +12,7 @@ $ARGUMENTS 1. Read `.specify/feature.json` to get the feature directory path. -2. **Load context**: `constitution.md` and `spec.md` and `plan.md`. +2. **Load context**: `.specify/memory/constitution.md` and `spec.md` and `plan.md`. 3. Create dependency-ordered implementation tasks and store them in `/tasks.md`. - Every task uses checklist format: `- [ ] [TaskID] Description with file path` diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 80c666960e..3a0f469a77 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -1589,11 +1589,12 @@ def download_pack( # 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." + f"or reinstall spec-kit if the bundled files are missing: {REINSTALL_COMMAND}" ) if not pack_info.get("_install_allowed", True): From f6d350ac63603d5b871e1b84b70e56662f471272 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:32:30 -0500 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20address=20review=20=E2=80=94=20?= =?UTF-8?q?explicit=20feature=5Fdirectory=20paths,=20ZIP=20cleanup=20in=20?= =?UTF-8?q?finally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prefix spec.md/plan.md/tasks.md with / in plan, tasks, and implement commands so the agent doesn't operate on repo root by mistake - Move ZIP unlink into finally block in init --preset path so cleanup runs even when install_from_zip raises (matching preset_add pattern) --- presets/lean/commands/speckit.implement.md | 4 ++-- presets/lean/commands/speckit.plan.md | 2 +- presets/lean/commands/speckit.tasks.md | 2 +- src/specify_cli/__init__.py | 15 +++++++++------ 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/presets/lean/commands/speckit.implement.md b/presets/lean/commands/speckit.implement.md index 69079ed924..446b25a9d4 100644 --- a/presets/lean/commands/speckit.implement.md +++ b/presets/lean/commands/speckit.implement.md @@ -12,12 +12,12 @@ $ARGUMENTS 1. Read `.specify/feature.json` to get the feature directory path. -2. **Load context**: `.specify/memory/constitution.md` and `spec.md` and `plan.md` and `tasks.md`. +2. **Load context**: `.specify/memory/constitution.md` and `/spec.md` and `/plan.md` and `/tasks.md`. 3. **Execute tasks** phase by phase: - Complete each phase before moving to the next - Respect dependencies: sequential tasks in order, `[P]` tasks can run together - - Mark completed tasks as `[X]` in tasks.md + - Mark completed tasks as `[X]` in `/tasks.md` - Halt on failure for sequential tasks; continue and report for parallel tasks 4. **Validate**: Verify all tasks are completed and the implementation matches the spec. diff --git a/presets/lean/commands/speckit.plan.md b/presets/lean/commands/speckit.plan.md index 28ebd94efa..9fbbe4c371 100644 --- a/presets/lean/commands/speckit.plan.md +++ b/presets/lean/commands/speckit.plan.md @@ -12,7 +12,7 @@ $ARGUMENTS 1. Read `.specify/feature.json` to get the feature directory path. -2. **Load context**: `.specify/memory/constitution.md` and `spec.md`. +2. **Load context**: `.specify/memory/constitution.md` and `/spec.md`. 3. Create an implementation plan and store it in `/plan.md`. - Technical context: tech stack, dependencies, project structure diff --git a/presets/lean/commands/speckit.tasks.md b/presets/lean/commands/speckit.tasks.md index 1058afd1bd..75e4786949 100644 --- a/presets/lean/commands/speckit.tasks.md +++ b/presets/lean/commands/speckit.tasks.md @@ -12,7 +12,7 @@ $ARGUMENTS 1. Read `.specify/feature.json` to get the feature directory path. -2. **Load context**: `.specify/memory/constitution.md` and `spec.md` and `plan.md`. +2. **Load context**: `.specify/memory/constitution.md` and `/spec.md` and `/plan.md`. 3. Create dependency-ordered implementation tasks and store them in `/tasks.md`. - Every task uses checklist format: `- [ ] [TaskID] Description with file path` diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e01f9318b7..0bbf42ad5a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1315,17 +1315,20 @@ def init( ) console.print(f"Try reinstalling: {REINSTALL_COMMAND}") else: + zip_path = None 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 - 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}") + 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}") From 9bfe082ed05db64cb124c135d30ba2d2081e7a07 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:40:19 -0500 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20address=20review=20=E2=80=94=20?= =?UTF-8?q?replace=20Unicode=20em=20dashes=20with=20ASCII,=20fix=20grammar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace all Unicode em dashes with ASCII hyphens in preset.yml and catalog.json to avoid decode errors on non-UTF-8 environments - Fix grammar: 'store it in tasks.md' -> 'store them in tasks.md' --- presets/catalog.json | 2 +- presets/lean/commands/speckit.tasks.md | 2 +- presets/lean/preset.yml | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/presets/catalog.json b/presets/catalog.json index 1b036a7630..5650092baf 100644 --- a/presets/catalog.json +++ b/presets/catalog.json @@ -7,7 +7,7 @@ "name": "Lean Workflow", "id": "lean", "version": "1.0.0", - "description": "Minimal core workflow commands — just the prompt, just the artifact", + "description": "Minimal core workflow commands - just the prompt, just the artifact", "author": "github", "repository": "https://github.com/github/spec-kit", "bundled": true, diff --git a/presets/lean/commands/speckit.tasks.md b/presets/lean/commands/speckit.tasks.md index 75e4786949..724a7b8400 100644 --- a/presets/lean/commands/speckit.tasks.md +++ b/presets/lean/commands/speckit.tasks.md @@ -1,5 +1,5 @@ --- -description: Create the tasks needed for implementation and store it in tasks.md. +description: Create the tasks needed for implementation and store them in tasks.md. --- ## User Input diff --git a/presets/lean/preset.yml b/presets/lean/preset.yml index fa44917f91..eae84928c8 100644 --- a/presets/lean/preset.yml +++ b/presets/lean/preset.yml @@ -4,7 +4,7 @@ preset: id: "lean" name: "Lean Workflow" version: "1.0.0" - description: "Minimal core workflow commands — just the prompt, just the artifact" + description: "Minimal core workflow commands - just the prompt, just the artifact" author: "github" repository: "https://github.com/github/spec-kit" license: "MIT" @@ -17,31 +17,31 @@ provides: - type: "command" name: "speckit.specify" file: "commands/speckit.specify.md" - description: "Lean specify — create spec.md from a feature description" + 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" + 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" + 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" + 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" + description: "Lean constitution - create or update project constitution" replaces: "speckit.constitution" tags: From 88c413ec8bb7af84ff0b76a9d560ced294fee9c5 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:47:38 -0500 Subject: [PATCH 6/7] refactor: address review - align task format between tasks and implement - Remove undefined [P] marker from implement (lean uses sequential execution) - Clarify checkbox update: 'change - [ ] to - [x]' instead of ambiguous '[X]' - Simplify implement to execute tasks in order without parallel complexity --- presets/lean/commands/speckit.implement.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/presets/lean/commands/speckit.implement.md b/presets/lean/commands/speckit.implement.md index 446b25a9d4..fc68a1f8b1 100644 --- a/presets/lean/commands/speckit.implement.md +++ b/presets/lean/commands/speckit.implement.md @@ -14,10 +14,9 @@ $ARGUMENTS 2. **Load context**: `.specify/memory/constitution.md` and `/spec.md` and `/plan.md` and `/tasks.md`. -3. **Execute tasks** phase by phase: - - Complete each phase before moving to the next - - Respect dependencies: sequential tasks in order, `[P]` tasks can run together - - Mark completed tasks as `[X]` in `/tasks.md` - - Halt on failure for sequential tasks; continue and report for parallel tasks +3. **Execute tasks** in order: + - Complete each task before moving to the next + - Mark completed tasks by changing `- [ ]` to `- [x]` in `/tasks.md` + - Halt on failure and report the issue 4. **Validate**: Verify all tasks are completed and the implementation matches the spec. From db308b2af028a45ca4fa149860a2a9df72c7c996 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:55:51 -0500 Subject: [PATCH 7/7] refactor: address review - parse frontmatter instead of raw substring search - Use CommandRegistrar.parse_frontmatter() to check for scripts/agent_scripts keys in YAML frontmatter instead of brittle 'scripts:' substring search --- tests/test_presets.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_presets.py b/tests/test_presets.py index c8d0942e54..95af7a900f 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2912,11 +2912,15 @@ def test_lean_command_files_exist(self): assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}" def test_lean_commands_have_no_scripts(self): - """Verify lean commands do not reference scripts (no git branching dependency).""" + """Verify lean commands have no scripts or agent_scripts in frontmatter.""" + from specify_cli.agents import CommandRegistrar + for name in LEAN_COMMAND_NAMES: cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" content = cmd_path.read_text() - assert "scripts:" not in content, f"{name} should not have scripts frontmatter" + frontmatter, _ = CommandRegistrar.parse_frontmatter(content) + assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter" + assert "agent_scripts" not in frontmatter, f"{name} should not have agent_scripts in frontmatter" def test_lean_commands_have_no_hooks(self): """Verify lean commands do not contain extension hook boilerplate."""