Skip to content

Commit 35ced30

Browse files
committed
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
1 parent 914a06a commit 35ced30

4 files changed

Lines changed: 407 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424
- Preset catalog files (`presets/catalog.json`, `presets/catalog.community.json`)
2525
- Preset scaffold directory (`presets/scaffold/`)
2626
- Scripts updated to use template resolution instead of hardcoded paths
27+
- feat(presets): Preset command overrides now propagate to agent skills when `--ai-skills` was used during init
28+
- feat: `specify init` persists CLI options to `.specify/init-options.json` for downstream operations
2729
- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781)
2830

2931
## [0.2.0] - 2026-03-09

src/specify_cli/__init__.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
import json
3535
import yaml
3636
from pathlib import Path
37-
from typing import Optional, Tuple
37+
from typing import Any, Optional, Tuple
3838

3939
import typer
4040
import httpx
@@ -1060,6 +1060,36 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker |
10601060
else:
10611061
console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]")
10621062

1063+
1064+
INIT_OPTIONS_FILE = ".specify/init-options.json"
1065+
1066+
1067+
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
1068+
"""Persist the CLI options used during ``specify init``.
1069+
1070+
Writes a small JSON file to ``.specify/init-options.json`` so that
1071+
later operations (e.g. preset install) can adapt their behaviour
1072+
without scanning the filesystem.
1073+
"""
1074+
dest = project_path / INIT_OPTIONS_FILE
1075+
dest.parent.mkdir(parents=True, exist_ok=True)
1076+
dest.write_text(json.dumps(options, indent=2, sort_keys=True))
1077+
1078+
1079+
def load_init_options(project_path: Path) -> dict[str, Any]:
1080+
"""Load the init options previously saved by ``specify init``.
1081+
1082+
Returns an empty dict if the file does not exist or cannot be parsed.
1083+
"""
1084+
path = project_path / INIT_OPTIONS_FILE
1085+
if not path.exists():
1086+
return {}
1087+
try:
1088+
return json.loads(path.read_text())
1089+
except (json.JSONDecodeError, OSError):
1090+
return {}
1091+
1092+
10631093
# Agent-specific skill directory overrides for agents whose skills directory
10641094
# doesn't follow the standard <agent_folder>/skills/ pattern
10651095
AGENT_SKILLS_DIR_OVERRIDES = {
@@ -1565,6 +1595,18 @@ def init(
15651595
except Exception as preset_err:
15661596
console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
15671597

1598+
# Persist the CLI options so later operations (e.g. preset add)
1599+
# can adapt their behaviour without re-scanning the filesystem.
1600+
save_init_options(project_path, {
1601+
"ai": selected_ai,
1602+
"ai_skills": ai_skills,
1603+
"ai_commands_dir": ai_commands_dir,
1604+
"here": here,
1605+
"preset": preset,
1606+
"script": selected_script,
1607+
"speckit_version": get_speckit_version(),
1608+
})
1609+
15681610
tracker.complete("final", "project ready")
15691611
except Exception as e:
15701612
tracker.error("final", str(e))

src/specify_cli/presets.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,217 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non
419419
registrar = CommandRegistrar()
420420
registrar.unregister_commands(registered_commands, self.project_root)
421421

422+
def _get_skills_dir(self) -> Optional[Path]:
423+
"""Return the skills directory if ``--ai-skills`` was used during init.
424+
425+
Reads ``.specify/init-options.json`` to determine whether skills
426+
are enabled and which agent was selected, then delegates to
427+
``_get_skills_dir()`` for the concrete path.
428+
429+
Returns:
430+
The skills directory ``Path``, or ``None`` if skills were not
431+
enabled or the init-options file is missing.
432+
"""
433+
from . import load_init_options, _get_skills_dir
434+
435+
opts = load_init_options(self.project_root)
436+
if not opts.get("ai_skills"):
437+
return None
438+
439+
agent = opts.get("ai")
440+
if not agent:
441+
return None
442+
443+
skills_dir = _get_skills_dir(self.project_root, agent)
444+
if not skills_dir.is_dir():
445+
return None
446+
447+
return skills_dir
448+
449+
def _register_skills(
450+
self,
451+
manifest: "PresetManifest",
452+
preset_dir: Path,
453+
) -> List[str]:
454+
"""Generate SKILL.md files for preset command overrides.
455+
456+
For every command template in the preset, checks whether a
457+
corresponding skill already exists in any detected skills
458+
directory. If so, the skill is overwritten with content derived
459+
from the preset's command file. This ensures that presets that
460+
override commands also propagate to the agentskills.io skill
461+
layer when ``--ai-skills`` was used during project initialisation.
462+
463+
Args:
464+
manifest: Preset manifest.
465+
preset_dir: Installed preset directory.
466+
467+
Returns:
468+
List of skill names that were written (for registry storage).
469+
"""
470+
command_templates = [
471+
t for t in manifest.templates if t.get("type") == "command"
472+
]
473+
if not command_templates:
474+
return []
475+
476+
skills_dir = self._get_skills_dir()
477+
if not skills_dir:
478+
return []
479+
480+
from . import SKILL_DESCRIPTIONS
481+
482+
written: List[str] = []
483+
484+
for cmd_tmpl in command_templates:
485+
cmd_name = cmd_tmpl["name"]
486+
cmd_file_rel = cmd_tmpl["file"]
487+
source_file = preset_dir / cmd_file_rel
488+
if not source_file.exists():
489+
continue
490+
491+
# Derive the short command name (e.g. "specify" from "speckit.specify")
492+
short_name = cmd_name
493+
if short_name.startswith("speckit."):
494+
short_name = short_name[len("speckit."):]
495+
skill_name = f"speckit-{short_name}"
496+
497+
# Only overwrite if the skill already exists (i.e. --ai-skills was used)
498+
skill_subdir = skills_dir / skill_name
499+
if not skill_subdir.exists():
500+
continue
501+
502+
# Parse the command file
503+
content = source_file.read_text(encoding="utf-8")
504+
if content.startswith("---"):
505+
parts = content.split("---", 2)
506+
if len(parts) >= 3:
507+
frontmatter = yaml.safe_load(parts[1])
508+
if not isinstance(frontmatter, dict):
509+
frontmatter = {}
510+
body = parts[2].strip()
511+
else:
512+
frontmatter = {}
513+
body = content
514+
else:
515+
frontmatter = {}
516+
body = content
517+
518+
original_desc = frontmatter.get("description", "")
519+
enhanced_desc = SKILL_DESCRIPTIONS.get(
520+
short_name,
521+
original_desc or f"Spec-kit workflow command: {short_name}",
522+
)
523+
524+
frontmatter_data = {
525+
"name": skill_name,
526+
"description": enhanced_desc,
527+
"compatibility": "Requires spec-kit project structure with .specify/ directory",
528+
"metadata": {
529+
"author": "github-spec-kit",
530+
"source": f"preset:{manifest.id}",
531+
},
532+
}
533+
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
534+
skill_content = (
535+
f"---\n"
536+
f"{frontmatter_text}\n"
537+
f"---\n\n"
538+
f"# Speckit {short_name.title()} Skill\n\n"
539+
f"{body}\n"
540+
)
541+
542+
skill_file = skill_subdir / "SKILL.md"
543+
skill_file.write_text(skill_content, encoding="utf-8")
544+
written.append(skill_name)
545+
546+
return written
547+
548+
def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
549+
"""Restore original SKILL.md files after a preset is removed.
550+
551+
For each skill that was overridden by the preset, attempts to
552+
regenerate the skill from the core command template. If no core
553+
template exists, the skill directory is removed.
554+
555+
Args:
556+
skill_names: List of skill names written by the preset.
557+
preset_dir: The preset's installed directory (may already be deleted).
558+
"""
559+
if not skill_names:
560+
return
561+
562+
skills_dir = self._get_skills_dir()
563+
if not skills_dir:
564+
return
565+
566+
from . import SKILL_DESCRIPTIONS
567+
568+
# Locate core command templates
569+
script_dir = Path(__file__).parent.parent.parent # up from src/specify_cli/
570+
core_templates_dir = script_dir / "templates" / "commands"
571+
572+
for skill_name in skill_names:
573+
# Derive command name from skill name (speckit-specify -> specify)
574+
short_name = skill_name
575+
if short_name.startswith("speckit-"):
576+
short_name = short_name[len("speckit-"):]
577+
578+
skill_subdir = skills_dir / skill_name
579+
skill_file = skill_subdir / "SKILL.md"
580+
if not skill_file.exists():
581+
continue
582+
583+
# Try to find the core command template
584+
core_file = core_templates_dir / f"{short_name}.md" if core_templates_dir.exists() else None
585+
if core_file and not core_file.exists():
586+
core_file = None
587+
588+
if core_file:
589+
# Restore from core template
590+
content = core_file.read_text(encoding="utf-8")
591+
if content.startswith("---"):
592+
parts = content.split("---", 2)
593+
if len(parts) >= 3:
594+
frontmatter = yaml.safe_load(parts[1])
595+
if not isinstance(frontmatter, dict):
596+
frontmatter = {}
597+
body = parts[2].strip()
598+
else:
599+
frontmatter = {}
600+
body = content
601+
else:
602+
frontmatter = {}
603+
body = content
604+
605+
original_desc = frontmatter.get("description", "")
606+
enhanced_desc = SKILL_DESCRIPTIONS.get(
607+
short_name,
608+
original_desc or f"Spec-kit workflow command: {short_name}",
609+
)
610+
611+
frontmatter_data = {
612+
"name": skill_name,
613+
"description": enhanced_desc,
614+
"compatibility": "Requires spec-kit project structure with .specify/ directory",
615+
"metadata": {
616+
"author": "github-spec-kit",
617+
"source": f"templates/commands/{short_name}.md",
618+
},
619+
}
620+
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
621+
skill_content = (
622+
f"---\n"
623+
f"{frontmatter_text}\n"
624+
f"---\n\n"
625+
f"# Speckit {short_name.title()} Skill\n\n"
626+
f"{body}\n"
627+
)
628+
skill_file.write_text(skill_content, encoding="utf-8")
629+
else:
630+
# No core template — remove the skill entirely
631+
shutil.rmtree(skill_subdir)
632+
422633
def install_from_directory(
423634
self,
424635
source_dir: Path,
@@ -459,13 +670,17 @@ def install_from_directory(
459670
# Register command overrides with AI agents
460671
registered_commands = self._register_commands(manifest, dest_dir)
461672

673+
# Update corresponding skills when --ai-skills was previously used
674+
registered_skills = self._register_skills(manifest, dest_dir)
675+
462676
self.registry.add(manifest.id, {
463677
"version": manifest.version,
464678
"source": "local",
465679
"manifest_hash": manifest.get_hash(),
466680
"enabled": True,
467681
"priority": priority,
468682
"registered_commands": registered_commands,
683+
"registered_skills": registered_skills,
469684
})
470685

471686
return manifest
@@ -539,7 +754,12 @@ def remove(self, pack_id: str) -> bool:
539754
if registered_commands:
540755
self._unregister_commands(registered_commands)
541756

757+
# Restore original skills when preset is removed
758+
registered_skills = metadata.get("registered_skills", []) if metadata else []
542759
pack_dir = self.presets_dir / pack_id
760+
if registered_skills:
761+
self._unregister_skills(registered_skills, pack_dir)
762+
543763
if pack_dir.exists():
544764
shutil.rmtree(pack_dir)
545765

0 commit comments

Comments
 (0)