Skip to content

Commit 655476b

Browse files
committed
fix: handle project override skills and extension context in reconciliation
1 parent 0ec81eb commit 655476b

1 file changed

Lines changed: 69 additions & 15 deletions

File tree

src/specify_cli/presets.py

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -731,17 +731,29 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None:
731731
if not registered:
732732
# Top layer is a non-preset source (extension, core, or
733733
# project override). Register directly from the layer path.
734-
# Extract stable source_id from display label
735734
source = layers[0]["source"]
736735
if source.startswith("extension:"):
737-
# "extension:foo v1.0" → "foo"
738-
source_id = source.split(":", 1)[1].split(" ", 1)[0]
739-
else:
740-
source_id = source
741-
self._register_command_from_path(
742-
registrar, cmd_name, top_path,
743-
source_id=source_id,
744-
)
736+
# Use extension's own registration to preserve context formatting
737+
ext_id = source.split(":", 1)[1].split(" ", 1)[0]
738+
ext_dir = self.project_root / ".specify" / "extensions" / ext_id
739+
ext_manifest_path = ext_dir / "extension.yml"
740+
if ext_manifest_path.exists():
741+
try:
742+
from .extensions import ExtensionManifest
743+
ext_manifest = ExtensionManifest(ext_manifest_path)
744+
registrar.register_commands_for_non_skill_agents(
745+
ext_manifest.commands, ext_id, ext_dir,
746+
self.project_root,
747+
)
748+
registered = True
749+
except Exception:
750+
pass
751+
if not registered:
752+
source_id = source.split(":", 1)[1].split(" ", 1)[0] if source.startswith("extension:") else source
753+
self._register_command_from_path(
754+
registrar, cmd_name, top_path,
755+
source_id=source_id,
756+
)
745757
else:
746758
# Composed command — resolve from full stack
747759
composed = resolver.resolve_content(cmd_name, "command")
@@ -831,7 +843,7 @@ def _register_command_from_path(
831843
if source_id and not source_id.startswith("preset:"):
832844
try:
833845
from .extensions import ExtensionManifest
834-
for ext_dir in self.extensions_dir.iterdir():
846+
for ext_dir in (self.project_root / ".specify" / "extensions").iterdir():
835847
if not ext_dir.is_dir():
836848
continue
837849
if cmd_path.is_relative_to(ext_dir):
@@ -956,14 +968,56 @@ def _reconcile_skills(self, command_names: List[str]) -> None:
956968
break
957969
if not found_preset:
958970
# Winner is a non-preset source (core/extension/override).
959-
# Restore skill from that source using _unregister_skills logic.
971+
# Track the winning layer path for skill restoration.
960972
skill_name, _ = self._skill_names_for_command(cmd_name)
961-
non_preset_skills.append(skill_name)
973+
non_preset_skills.append((skill_name, cmd_name, layers[0]))
962974

963-
# Restore skills for commands whose winner is non-preset
975+
# Restore skills for commands whose winner is non-preset.
976+
# Use _unregister_skills which restores from core/extension, but
977+
# also handles project overrides by reading the winning layer directly.
964978
if non_preset_skills and skills_dir:
965-
# Use a dummy preset_dir (won't be read for core/extension restore)
966-
self._unregister_skills(non_preset_skills, self.presets_dir)
979+
skill_names_only = [s[0] for s in non_preset_skills]
980+
self._unregister_skills(skill_names_only, self.presets_dir)
981+
# For project overrides, _unregister_skills restores from core.
982+
# Re-write from the actual winning layer if it's an override.
983+
for skill_name, cmd_name, top_layer in non_preset_skills:
984+
if top_layer["source"] == "project override":
985+
skill_subdir = skills_dir / skill_name
986+
if skill_subdir.is_dir():
987+
skill_file = skill_subdir / "SKILL.md"
988+
try:
989+
from .agents import CommandRegistrar
990+
from . import SKILL_DESCRIPTIONS, load_init_options
991+
registrar = CommandRegistrar()
992+
content = top_layer["path"].read_text(encoding="utf-8")
993+
fm, body = registrar.parse_frontmatter(content)
994+
short_name = cmd_name
995+
if short_name.startswith("speckit."):
996+
short_name = short_name[len("speckit."):]
997+
desc = SKILL_DESCRIPTIONS.get(
998+
short_name.replace(".", "-"),
999+
fm.get("description", f"Command: {short_name}"),
1000+
)
1001+
init_opts = load_init_options(self.project_root)
1002+
selected_ai = init_opts.get("ai") if isinstance(init_opts, dict) else ""
1003+
if isinstance(selected_ai, str):
1004+
body = registrar.resolve_skill_placeholders(
1005+
selected_ai, fm, body, self.project_root
1006+
)
1007+
fm_data = registrar.build_skill_frontmatter(
1008+
selected_ai if isinstance(selected_ai, str) else "",
1009+
skill_name, desc,
1010+
f"override:{cmd_name}",
1011+
)
1012+
fm_text = yaml.safe_dump(fm_data, sort_keys=False).strip()
1013+
skill_title = self._skill_title_from_command(cmd_name)
1014+
skill_content = (
1015+
f"---\n{fm_text}\n---\n\n"
1016+
f"# Speckit {skill_title} Skill\n\n{body}\n"
1017+
)
1018+
skill_file.write_text(skill_content, encoding="utf-8")
1019+
except Exception:
1020+
pass # best-effort override skill restoration
9671021

9681022
# Register skills only for the specific commands being reconciled,
9691023
# not all commands in each winning preset's manifest.

0 commit comments

Comments
 (0)