Skip to content

Commit 3336fd4

Browse files
fix: use PresetResolver for core template lookup in strategy: wrap
_substitute_core_template now accepts the full cmd_name and resolves the core template file via PresetResolver, which walks the full priority stack (overrides -> presets -> extensions -> core templates). It tries the full command name first so extension commands (e.g. speckit.git.feature -> extensions/git/commands/speckit.git.feature.md) are found correctly, then falls back to the short name for core commands (e.g. specify -> templates/commands/specify.md). Both call sites (register_commands and _register_skills) now pass cmd_name directly, removing the short-name derivation from the callers.
1 parent 9b32c61 commit 3336fd4

File tree

3 files changed

+33
-38
lines changed

3 files changed

+33
-38
lines changed

src/specify_cli/agents.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -416,10 +416,7 @@ def register_commands(
416416

417417
if frontmatter.get("strategy") == "wrap":
418418
from .presets import _substitute_core_template
419-
short_name = cmd_name
420-
if short_name.startswith("speckit."):
421-
short_name = short_name[len("speckit."):]
422-
body, core_frontmatter = _substitute_core_template(body, short_name, project_root, self)
419+
body, core_frontmatter = _substitute_core_template(body, cmd_name, project_root, self)
423420
frontmatter = dict(frontmatter)
424421
for key in ("scripts", "agent_scripts"):
425422
if key not in frontmatter and key in core_frontmatter:

src/specify_cli/presets.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@
2929

3030
def _substitute_core_template(
3131
body: str,
32-
short_name: str,
32+
cmd_name: str,
3333
project_root: "Path",
3434
registrar: "CommandRegistrar",
3535
) -> "tuple[str, dict]":
3636
"""Substitute {CORE_TEMPLATE} with the body of the installed core command template.
3737
3838
Args:
3939
body: Preset command body (may contain {CORE_TEMPLATE} placeholder).
40-
short_name: Short command name (e.g. "specify" from "speckit.specify").
40+
cmd_name: Full command name (e.g. "speckit.git.feature" or "speckit.specify").
4141
project_root: Project root path.
4242
registrar: CommandRegistrar instance for parse_frontmatter.
4343
@@ -51,10 +51,17 @@ def _substitute_core_template(
5151
if "{CORE_TEMPLATE}" not in body:
5252
return body, {}
5353

54-
core_templates_dir = project_root / ".specify" / "templates" / "commands"
55-
core_file = core_templates_dir / f"{short_name}.md" if core_templates_dir.exists() else None
56-
if core_file and not core_file.exists():
57-
core_file = None
54+
# Derive the short name (strip "speckit." prefix) used by core command templates.
55+
short_name = cmd_name
56+
if short_name.startswith("speckit."):
57+
short_name = short_name[len("speckit."):]
58+
59+
resolver = PresetResolver(project_root)
60+
# Try the full command name first so extension commands
61+
# (e.g. speckit.git.feature -> extensions/git/commands/speckit.git.feature.md)
62+
# are found before falling back to the short name used by core commands
63+
# (e.g. specify -> templates/commands/specify.md).
64+
core_file = resolver.resolve(cmd_name, "command") or resolver.resolve(short_name, "command")
5865
if core_file is None:
5966
return body, {}
6067

@@ -795,7 +802,7 @@ def _register_skills(
795802
frontmatter, body = registrar.parse_frontmatter(content)
796803

797804
if frontmatter.get("strategy") == "wrap":
798-
body, core_frontmatter = _substitute_core_template(body, raw_short_name, self.project_root, registrar)
805+
body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar)
799806
frontmatter = dict(frontmatter)
800807
for key in ("scripts", "agent_scripts"):
801808
if key not in frontmatter and key in core_frontmatter:

tests/test_presets.py

Lines changed: 18 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3354,40 +3354,31 @@ def test_register_commands_toml_resolves_inherited_scripts(self, project_dir):
33543354
assert "$ARGUMENTS" not in written
33553355
assert "{{args}}" in written
33563356

3357-
def test_extension_command_resolves_dot_separated_template(self, project_dir):
3358-
"""Extension command names like speckit.git.feature look up git.feature.md, not git-feature.md.
3357+
def test_extension_command_resolves_via_extension_directory(self, project_dir):
3358+
"""Extension commands (e.g. speckit.git.feature) resolve from the extension directory.
33593359
3360-
Covers both _register_skills (via _substitute_core_template with raw_short_name)
3361-
and register_commands (via short_name stripped of speckit. prefix).
3360+
Both _register_skills and register_commands pass the full cmd_name to
3361+
_substitute_core_template, which tries the full name first via PresetResolver
3362+
and finds speckit.git.feature.md in the extension commands directory.
33623363
"""
33633364
from specify_cli.presets import _substitute_core_template
33643365
from specify_cli.agents import CommandRegistrar
33653366

3366-
core_dir = project_dir / ".specify" / "templates" / "commands"
3367-
core_dir.mkdir(parents=True, exist_ok=True)
3368-
# Template uses dot-separated name — must NOT be named git-feature.md
3369-
(core_dir / "git.feature.md").write_text(
3370-
"---\ndescription: core git feature\n---\n\n# Git Feature Core\n"
3367+
# Place the template where a real extension would install it
3368+
ext_cmd_dir = project_dir / ".specify" / "extensions" / "git" / "commands"
3369+
ext_cmd_dir.mkdir(parents=True, exist_ok=True)
3370+
(ext_cmd_dir / "speckit.git.feature.md").write_text(
3371+
"---\ndescription: git feature core\n---\n\n# Git Feature Core\n"
33713372
)
3372-
# Ensure the hyphenated variant does NOT exist to prove we pick the right file
3373-
assert not (core_dir / "git-feature.md").exists()
3373+
# Ensure a hyphenated or dot-separated fallback does NOT exist
3374+
assert not (project_dir / ".specify" / "templates" / "commands" / "git.feature.md").exists()
3375+
assert not (project_dir / ".specify" / "templates" / "commands" / "git-feature.md").exists()
33743376

33753377
registrar = CommandRegistrar()
33763378
body = "## Wrapper\n\n{CORE_TEMPLATE}\n"
33773379

3378-
# Simulate register_commands: strips "speckit." only
3379-
short_name_commands = "speckit.git.feature"
3380-
if short_name_commands.startswith("speckit."):
3381-
short_name_commands = short_name_commands[len("speckit."):]
3382-
result_commands, _ = _substitute_core_template(body, short_name_commands, project_dir, registrar)
3383-
3384-
# Simulate _register_skills: strips "speckit." then hyphenates for skill dirs,
3385-
# but passes raw_short_name (pre-hyphenation) to _substitute_core_template
3386-
raw_short_name = "speckit.git.feature"
3387-
if raw_short_name.startswith("speckit."):
3388-
raw_short_name = raw_short_name[len("speckit."):]
3389-
result_skills, _ = _substitute_core_template(body, raw_short_name, project_dir, registrar)
3390-
3391-
assert "# Git Feature Core" in result_commands, "register_commands path did not resolve extension template"
3392-
assert "# Git Feature Core" in result_skills, "_register_skills path did not resolve extension template"
3393-
assert result_commands == result_skills, "register_commands and _register_skills resolved differently"
3380+
# Both call sites now pass the full cmd_name
3381+
result, _ = _substitute_core_template(body, "speckit.git.feature", project_dir, registrar)
3382+
3383+
assert "# Git Feature Core" in result
3384+
assert "{CORE_TEMPLATE}" not in result

0 commit comments

Comments
 (0)