feat(presets): propagate command overrides to skills via init-options

- Add save_init_options() / load_init_options() helpers that persist
  CLI flags from 'specify init' to .specify/init-options.json
- PresetManager._register_skills() overwrites SKILL.md files when
  --ai-skills was used during init and corresponding skill dirs exist
- PresetManager._unregister_skills() restores core template content
  on preset removal
- registered_skills stored in preset registry metadata
- 8 new tests covering skill override, skip conditions, and restore
This commit is contained in:
Manfred Riem
2026-03-10 14:31:17 -05:00
parent 914a06a89f
commit 35ced30747
4 changed files with 407 additions and 1 deletions

View File

@@ -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

View File

@@ -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 <agent_folder>/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))

View File

@@ -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)

View File

@@ -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", []) == []