diff --git a/CHANGELOG.md b/CHANGELOG.md index 092afefa..7e3de2fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Preset catalog files (`presets/catalog.json`, `presets/catalog.community.json`) - Preset scaffold directory (`presets/scaffold/`) - Scripts updated to use template resolution instead of hardcoded paths +- feat(presets): Preset command overrides now propagate to agent skills when `--ai-skills` was used during init +- feat: `specify init` persists CLI options to `.specify/init-options.json` for downstream operations - feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781) ## [0.2.0] - 2026-03-09 diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 916afdf6..b7ae77eb 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -34,7 +34,7 @@ import shlex import json import yaml from pathlib import Path -from typing import Optional, Tuple +from typing import Any, Optional, Tuple import typer import httpx @@ -1060,6 +1060,36 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | else: console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") + +INIT_OPTIONS_FILE = ".specify/init-options.json" + + +def save_init_options(project_path: Path, options: dict[str, Any]) -> None: + """Persist the CLI options used during ``specify init``. + + Writes a small JSON file to ``.specify/init-options.json`` so that + later operations (e.g. preset install) can adapt their behaviour + without scanning the filesystem. + """ + dest = project_path / INIT_OPTIONS_FILE + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps(options, indent=2, sort_keys=True)) + + +def load_init_options(project_path: Path) -> dict[str, Any]: + """Load the init options previously saved by ``specify init``. + + Returns an empty dict if the file does not exist or cannot be parsed. + """ + path = project_path / INIT_OPTIONS_FILE + if not path.exists(): + return {} + try: + return json.loads(path.read_text()) + except (json.JSONDecodeError, OSError): + return {} + + # Agent-specific skill directory overrides for agents whose skills directory # doesn't follow the standard /skills/ pattern AGENT_SKILLS_DIR_OVERRIDES = { @@ -1565,6 +1595,18 @@ def init( except Exception as preset_err: console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}") + # Persist the CLI options so later operations (e.g. preset add) + # can adapt their behaviour without re-scanning the filesystem. + save_init_options(project_path, { + "ai": selected_ai, + "ai_skills": ai_skills, + "ai_commands_dir": ai_commands_dir, + "here": here, + "preset": preset, + "script": selected_script, + "speckit_version": get_speckit_version(), + }) + tracker.complete("final", "project ready") except Exception as e: tracker.error("final", str(e)) diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index e0c18164..522fc4b5 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -419,6 +419,217 @@ class PresetManager: registrar = CommandRegistrar() registrar.unregister_commands(registered_commands, self.project_root) + def _get_skills_dir(self) -> Optional[Path]: + """Return the skills directory if ``--ai-skills`` was used during init. + + Reads ``.specify/init-options.json`` to determine whether skills + are enabled and which agent was selected, then delegates to + ``_get_skills_dir()`` for the concrete path. + + Returns: + The skills directory ``Path``, or ``None`` if skills were not + enabled or the init-options file is missing. + """ + from . import load_init_options, _get_skills_dir + + opts = load_init_options(self.project_root) + if not opts.get("ai_skills"): + return None + + agent = opts.get("ai") + if not agent: + return None + + skills_dir = _get_skills_dir(self.project_root, agent) + if not skills_dir.is_dir(): + return None + + return skills_dir + + def _register_skills( + self, + manifest: "PresetManifest", + preset_dir: Path, + ) -> List[str]: + """Generate SKILL.md files for preset command overrides. + + For every command template in the preset, checks whether a + corresponding skill already exists in any detected skills + directory. If so, the skill is overwritten with content derived + from the preset's command file. This ensures that presets that + override commands also propagate to the agentskills.io skill + layer when ``--ai-skills`` was used during project initialisation. + + Args: + manifest: Preset manifest. + preset_dir: Installed preset directory. + + Returns: + List of skill names that were written (for registry storage). + """ + command_templates = [ + t for t in manifest.templates if t.get("type") == "command" + ] + if not command_templates: + return [] + + skills_dir = self._get_skills_dir() + if not skills_dir: + return [] + + from . import SKILL_DESCRIPTIONS + + written: List[str] = [] + + for cmd_tmpl in command_templates: + cmd_name = cmd_tmpl["name"] + cmd_file_rel = cmd_tmpl["file"] + source_file = preset_dir / cmd_file_rel + if not source_file.exists(): + continue + + # Derive the short command name (e.g. "specify" from "speckit.specify") + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + skill_name = f"speckit-{short_name}" + + # Only overwrite if the skill already exists (i.e. --ai-skills was used) + skill_subdir = skills_dir / skill_name + if not skill_subdir.exists(): + continue + + # Parse the command file + content = source_file.read_text(encoding="utf-8") + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + frontmatter = yaml.safe_load(parts[1]) + if not isinstance(frontmatter, dict): + frontmatter = {} + body = parts[2].strip() + else: + frontmatter = {} + body = content + else: + frontmatter = {} + body = content + + original_desc = frontmatter.get("description", "") + enhanced_desc = SKILL_DESCRIPTIONS.get( + short_name, + original_desc or f"Spec-kit workflow command: {short_name}", + ) + + frontmatter_data = { + "name": skill_name, + "description": enhanced_desc, + "compatibility": "Requires spec-kit project structure with .specify/ directory", + "metadata": { + "author": "github-spec-kit", + "source": f"preset:{manifest.id}", + }, + } + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# Speckit {short_name.title()} Skill\n\n" + f"{body}\n" + ) + + skill_file = skill_subdir / "SKILL.md" + skill_file.write_text(skill_content, encoding="utf-8") + written.append(skill_name) + + return written + + def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: + """Restore original SKILL.md files after a preset is removed. + + For each skill that was overridden by the preset, attempts to + regenerate the skill from the core command template. If no core + template exists, the skill directory is removed. + + Args: + skill_names: List of skill names written by the preset. + preset_dir: The preset's installed directory (may already be deleted). + """ + if not skill_names: + return + + skills_dir = self._get_skills_dir() + if not skills_dir: + return + + from . import SKILL_DESCRIPTIONS + + # Locate core command templates + script_dir = Path(__file__).parent.parent.parent # up from src/specify_cli/ + core_templates_dir = script_dir / "templates" / "commands" + + for skill_name in skill_names: + # Derive command name from skill name (speckit-specify -> specify) + short_name = skill_name + if short_name.startswith("speckit-"): + short_name = short_name[len("speckit-"):] + + skill_subdir = skills_dir / skill_name + skill_file = skill_subdir / "SKILL.md" + if not skill_file.exists(): + continue + + # Try to find the core command template + core_file = core_templates_dir / f"{short_name}.md" if core_templates_dir.exists() else None + if core_file and not core_file.exists(): + core_file = None + + if core_file: + # Restore from core template + content = core_file.read_text(encoding="utf-8") + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + frontmatter = yaml.safe_load(parts[1]) + if not isinstance(frontmatter, dict): + frontmatter = {} + body = parts[2].strip() + else: + frontmatter = {} + body = content + else: + frontmatter = {} + body = content + + original_desc = frontmatter.get("description", "") + enhanced_desc = SKILL_DESCRIPTIONS.get( + short_name, + original_desc or f"Spec-kit workflow command: {short_name}", + ) + + frontmatter_data = { + "name": skill_name, + "description": enhanced_desc, + "compatibility": "Requires spec-kit project structure with .specify/ directory", + "metadata": { + "author": "github-spec-kit", + "source": f"templates/commands/{short_name}.md", + }, + } + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# Speckit {short_name.title()} Skill\n\n" + f"{body}\n" + ) + skill_file.write_text(skill_content, encoding="utf-8") + else: + # No core template — remove the skill entirely + shutil.rmtree(skill_subdir) + def install_from_directory( self, source_dir: Path, @@ -459,6 +670,9 @@ class PresetManager: # Register command overrides with AI agents registered_commands = self._register_commands(manifest, dest_dir) + # Update corresponding skills when --ai-skills was previously used + registered_skills = self._register_skills(manifest, dest_dir) + self.registry.add(manifest.id, { "version": manifest.version, "source": "local", @@ -466,6 +680,7 @@ class PresetManager: "enabled": True, "priority": priority, "registered_commands": registered_commands, + "registered_skills": registered_skills, }) return manifest @@ -539,7 +754,12 @@ class PresetManager: if registered_commands: self._unregister_commands(registered_commands) + # Restore original skills when preset is removed + registered_skills = metadata.get("registered_skills", []) if metadata else [] pack_dir = self.presets_dir / pack_id + if registered_skills: + self._unregister_skills(registered_skills, pack_dir) + if pack_dir.exists(): shutil.rmtree(pack_dir) diff --git a/tests/test_presets.py b/tests/test_presets.py index 8a2b84d0..6ae0d2da 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1566,3 +1566,145 @@ class TestSelfTestPreset: cmd_file = claude_dir / "speckit.fakeext.cmd.md" assert cmd_file.exists(), "Command not registered despite extension being present" + + +# ===== Init Options and Skills Tests ===== + + +class TestInitOptions: + """Tests for save_init_options / load_init_options helpers.""" + + def test_save_and_load_round_trip(self, project_dir): + from specify_cli import save_init_options, load_init_options + + opts = {"ai": "claude", "ai_skills": True, "here": False} + save_init_options(project_dir, opts) + + loaded = load_init_options(project_dir) + assert loaded["ai"] == "claude" + assert loaded["ai_skills"] is True + + def test_load_returns_empty_when_missing(self, project_dir): + from specify_cli import load_init_options + + assert load_init_options(project_dir) == {} + + def test_load_returns_empty_on_invalid_json(self, project_dir): + from specify_cli import load_init_options + + opts_file = project_dir / ".specify" / "init-options.json" + opts_file.parent.mkdir(parents=True, exist_ok=True) + opts_file.write_text("{bad json") + + assert load_init_options(project_dir) == {} + + +class TestPresetSkills: + """Tests for preset skill registration and unregistration.""" + + def _write_init_options(self, project_dir, ai="claude", ai_skills=True): + from specify_cli import save_init_options + + save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills}) + + def _create_skill(self, skills_dir, skill_name, body="original body"): + skill_dir = skills_dir / skill_name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text( + f"---\nname: {skill_name}\n---\n\n{body}\n" + ) + return skill_dir + + def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): + """When --ai-skills was used, a preset command override should update the skill.""" + # Simulate --ai-skills having been used: write init-options + create skill + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + # Also create the claude commands dir so commands get registered + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + # Install self-test preset (has a command override for speckit.specify) + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "preset:self-test" in content, "Skill should reference preset source" + + # Verify it was recorded in registry + metadata = manager.registry.get("self-test") + assert "speckit-specify" in metadata.get("registered_skills", []) + + def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir): + """When --ai-skills was NOT used, preset install should not touch skills.""" + self._write_init_options(project_dir, ai="claude", ai_skills=False) + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="untouched") + + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + content = skill_file.read_text() + assert "untouched" in content, "Skill should not be modified when ai_skills=False" + + def test_skill_not_updated_without_init_options(self, project_dir, temp_dir): + """When no init-options.json exists, preset install should not touch skills.""" + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="untouched") + + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + content = skill_file.read_text() + assert "untouched" in content + + def test_skill_restored_on_preset_remove(self, project_dir, temp_dir): + """When a preset is removed, skills should be restored from core templates.""" + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + + # Verify preset content is in the skill + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:self-test" in skill_file.read_text() + + # Remove the preset + manager.remove("self-test") + + # Skill should be restored (core specify.md template exists) + assert skill_file.exists(), "Skill should still exist after preset removal" + content = skill_file.read_text() + assert "preset:self-test" not in content, "Preset content should be gone" + assert "templates/commands/specify.md" in content, "Should reference core template" + + def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_dir): + """Skills should not be created when no existing skill dir is found.""" + self._write_init_options(project_dir, ai="claude") + # Don't create skills dir — simulate --ai-skills never created them + + (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" + manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + + metadata = manager.registry.get("self-test") + assert metadata.get("registered_skills", []) == []