Skip to content

Commit 894a0cc

Browse files
fix: strategy wrap correctness
1 parent 885d435 commit 894a0cc

File tree

4 files changed

+256
-22
lines changed

4 files changed

+256
-22
lines changed

src/specify_cli/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1273,7 +1273,6 @@ def init(
12731273
"integration": resolved_integration.key,
12741274
"branch_numbering": branch_numbering or "sequential",
12751275
"here": here,
1276-
"preset": preset,
12771276
"script": selected_script,
12781277
"speckit_version": get_speckit_version(),
12791278
}

src/specify_cli/agents.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -416,10 +416,12 @@ 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 = _substitute_core_template(body, short_name, project_root, self)
419+
body, core_frontmatter = _substitute_core_template(body, cmd_name, project_root, self)
420+
frontmatter = dict(frontmatter)
421+
for key in ("scripts", "agent_scripts"):
422+
if key not in frontmatter and key in core_frontmatter:
423+
frontmatter[key] = core_frontmatter[key]
424+
frontmatter.pop("strategy", None)
423425

424426
frontmatter = self._adjust_script_paths(frontmatter)
425427

@@ -442,8 +444,12 @@ def register_commands(
442444
agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root
443445
)
444446
elif agent_config["format"] == "markdown":
447+
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
448+
body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"])
445449
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
446450
elif agent_config["format"] == "toml":
451+
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
452+
body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"])
447453
output = self.render_toml_command(frontmatter, body, source_id)
448454
else:
449455
raise ValueError(f"Unsupported format: {agent_config['format']}")

src/specify_cli/presets.py

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
import shutil
1717
from dataclasses import dataclass
1818
from pathlib import Path
19-
from typing import Optional, Dict, List, Any
19+
from typing import TYPE_CHECKING, Optional, Dict, List, Any
20+
21+
if TYPE_CHECKING:
22+
from .agents import CommandRegistrar
2023
from datetime import datetime, timezone
2124
import re
2225

@@ -29,31 +32,44 @@
2932

3033
def _substitute_core_template(
3134
body: str,
32-
short_name: str,
35+
cmd_name: str,
3336
project_root: "Path",
3437
registrar: "CommandRegistrar",
35-
) -> str:
38+
) -> "tuple[str, dict]":
3639
"""Substitute {CORE_TEMPLATE} with the body of the installed core command template.
3740
3841
Args:
3942
body: Preset command body (may contain {CORE_TEMPLATE} placeholder).
40-
short_name: Short command name (e.g. "specify" from "speckit.specify").
43+
cmd_name: Full command name (e.g. "speckit.git.feature" or "speckit.specify").
4144
project_root: Project root path.
4245
registrar: CommandRegistrar instance for parse_frontmatter.
4346
4447
Returns:
45-
Body with {CORE_TEMPLATE} replaced by core template body, or body unchanged
46-
if the placeholder is absent or the core template file does not exist.
48+
A tuple of (body, core_frontmatter) where body has {CORE_TEMPLATE} replaced
49+
by the core template body and core_frontmatter holds the core template's parsed
50+
frontmatter (so callers can inherit scripts/agent_scripts from it). Both are
51+
unchanged / empty when the placeholder is absent or the core template file does
52+
not exist.
4753
"""
4854
if "{CORE_TEMPLATE}" not in body:
49-
return body
55+
return body, {}
56+
57+
# Derive the short name (strip "speckit." prefix) used by core command templates.
58+
short_name = cmd_name
59+
if short_name.startswith("speckit."):
60+
short_name = short_name[len("speckit."):]
5061

51-
core_file = project_root / ".specify" / "templates" / "commands" / f"{short_name}.md"
52-
if not core_file.exists():
53-
return body
62+
resolver = PresetResolver(project_root)
63+
# Try the full command name first so extension commands
64+
# (e.g. speckit.git.feature -> extensions/git/commands/speckit.git.feature.md)
65+
# are found before falling back to the short name used by core commands
66+
# (e.g. specify -> templates/commands/specify.md).
67+
core_file = resolver.resolve(cmd_name, "command") or resolver.resolve(short_name, "command")
68+
if core_file is None:
69+
return body, {}
5470

55-
_, core_body = registrar.parse_frontmatter(core_file.read_text(encoding="utf-8"))
56-
return body.replace("{CORE_TEMPLATE}", core_body)
71+
core_frontmatter, core_body = registrar.parse_frontmatter(core_file.read_text(encoding="utf-8"))
72+
return body.replace("{CORE_TEMPLATE}", core_body), core_frontmatter
5773

5874

5975
@dataclass
@@ -789,7 +805,11 @@ def _register_skills(
789805
frontmatter, body = registrar.parse_frontmatter(content)
790806

791807
if frontmatter.get("strategy") == "wrap":
792-
body = _substitute_core_template(body, short_name, self.project_root, registrar)
808+
body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar)
809+
frontmatter = dict(frontmatter)
810+
for key in ("scripts", "agent_scripts"):
811+
if key not in frontmatter and key in core_frontmatter:
812+
frontmatter[key] = core_frontmatter[key]
793813

794814
original_desc = frontmatter.get("description", "")
795815
enhanced_desc = SKILL_DESCRIPTIONS.get(

tests/test_presets.py

Lines changed: 213 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3043,6 +3043,7 @@ def test_bundled_preset_missing_locally_cli_error(self, project_dir):
30433043
assert result.exit_code == 1
30443044
output = strip_ansi(result.output).lower()
30453045
assert "bundled" in output, result.output
3046+
assert "reinstall" in output, result.output
30463047

30473048

30483049
class TestWrapStrategy:
@@ -3062,12 +3063,13 @@ def test_substitute_core_template_replaces_placeholder(self, project_dir):
30623063

30633064
registrar = CommandRegistrar()
30643065
body = "## Pre-Logic\n\nBefore stuff.\n\n{CORE_TEMPLATE}\n\n## Post-Logic\n\nAfter stuff.\n"
3065-
result = _substitute_core_template(body, "specify", project_dir, registrar)
3066+
result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar)
30663067

30673068
assert "{CORE_TEMPLATE}" not in result
30683069
assert "# Core Specify" in result
30693070
assert "## Pre-Logic" in result
30703071
assert "## Post-Logic" in result
3072+
assert core_fm.get("description") == "core"
30713073

30723074
def test_substitute_core_template_no_op_when_placeholder_absent(self, project_dir):
30733075
"""Returns body unchanged when {CORE_TEMPLATE} is not present."""
@@ -3080,8 +3082,9 @@ def test_substitute_core_template_no_op_when_placeholder_absent(self, project_di
30803082

30813083
registrar = CommandRegistrar()
30823084
body = "## No placeholder here.\n"
3083-
result = _substitute_core_template(body, "specify", project_dir, registrar)
3085+
result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar)
30843086
assert result == body
3087+
assert core_fm == {}
30853088

30863089
def test_substitute_core_template_no_op_when_core_missing(self, project_dir):
30873090
"""Returns body unchanged when core template file does not exist."""
@@ -3090,9 +3093,10 @@ def test_substitute_core_template_no_op_when_core_missing(self, project_dir):
30903093

30913094
registrar = CommandRegistrar()
30923095
body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n"
3093-
result = _substitute_core_template(body, "nonexistent", project_dir, registrar)
3096+
result, core_fm = _substitute_core_template(body, "nonexistent", project_dir, registrar)
30943097
assert result == body
30953098
assert "{CORE_TEMPLATE}" in result
3099+
assert core_fm == {}
30963100

30973101
def test_register_commands_substitutes_core_template_for_wrap_strategy(self, project_dir):
30983102
"""register_commands substitutes {CORE_TEMPLATE} when strategy: wrap."""
@@ -3136,7 +3140,8 @@ def test_register_commands_substitutes_core_template_for_wrap_strategy(self, pro
31363140
project_dir / "preset", project_dir
31373141
)
31383142
finally:
3139-
registrar.AGENT_CONFIGS = original
3143+
CommandRegistrar.AGENT_CONFIGS.clear()
3144+
CommandRegistrar.AGENT_CONFIGS.update(original)
31403145

31413146
written = (agent_dir / "speckit.specify.md").read_text()
31423147
assert "{CORE_TEMPLATE}" not in written
@@ -3176,3 +3181,207 @@ def test_end_to_end_wrap_via_self_test_preset(self, project_dir):
31763181
assert "# Core Wrap-Test Body" in written
31773182
assert "preset:self-test wrap-pre" in written
31783183
assert "preset:self-test wrap-post" in written
3184+
3185+
def test_substitute_core_template_returns_core_scripts(self, project_dir):
3186+
"""core_frontmatter in the returned tuple includes scripts/agent_scripts."""
3187+
from specify_cli.presets import _substitute_core_template
3188+
from specify_cli.agents import CommandRegistrar
3189+
3190+
core_dir = project_dir / ".specify" / "templates" / "commands"
3191+
core_dir.mkdir(parents=True, exist_ok=True)
3192+
(core_dir / "specify.md").write_text(
3193+
"---\ndescription: core\nscripts:\n sh: run.sh\nagent_scripts:\n sh: agent-run.sh\n---\n\n# Body\n"
3194+
)
3195+
3196+
registrar = CommandRegistrar()
3197+
body = "## Wrapper\n\n{CORE_TEMPLATE}\n"
3198+
result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar)
3199+
3200+
assert "# Body" in result
3201+
assert core_fm.get("scripts") == {"sh": "run.sh"}
3202+
assert core_fm.get("agent_scripts") == {"sh": "agent-run.sh"}
3203+
3204+
def test_register_skills_inherits_scripts_from_core_when_preset_omits_them(self, project_dir):
3205+
"""_register_skills merges scripts/agent_scripts from core when preset lacks them."""
3206+
from specify_cli.presets import PresetManager
3207+
import json
3208+
3209+
# Core template with scripts
3210+
core_dir = project_dir / ".specify" / "templates" / "commands"
3211+
core_dir.mkdir(parents=True, exist_ok=True)
3212+
(core_dir / "wrap-test.md").write_text(
3213+
"---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh\n---\n\n"
3214+
"Run: {SCRIPT}\n"
3215+
)
3216+
3217+
# Skills dir for claude
3218+
skills_dir = project_dir / ".claude" / "skills"
3219+
skills_dir.mkdir(parents=True, exist_ok=True)
3220+
skill_subdir = skills_dir / "speckit-wrap-test"
3221+
skill_subdir.mkdir()
3222+
(skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold\n")
3223+
3224+
(project_dir / ".specify" / "init-options.json").write_text(
3225+
json.dumps({"ai": "claude", "ai_skills": True})
3226+
)
3227+
3228+
manager = PresetManager(project_dir)
3229+
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
3230+
3231+
written = (skill_subdir / "SKILL.md").read_text()
3232+
# {SCRIPT} should have been resolved (not left as a literal placeholder)
3233+
assert "{SCRIPT}" not in written
3234+
3235+
def test_register_skills_preset_scripts_take_precedence_over_core(self, project_dir):
3236+
"""preset-defined scripts/agent_scripts are not overwritten by core frontmatter."""
3237+
from specify_cli.presets import _substitute_core_template
3238+
from specify_cli.agents import CommandRegistrar
3239+
3240+
core_dir = project_dir / ".specify" / "templates" / "commands"
3241+
core_dir.mkdir(parents=True, exist_ok=True)
3242+
(core_dir / "specify.md").write_text(
3243+
"---\ndescription: core\nscripts:\n sh: core-run.sh\n---\n\nCore body.\n"
3244+
)
3245+
3246+
registrar = CommandRegistrar()
3247+
body = "{CORE_TEMPLATE}"
3248+
_, core_fm = _substitute_core_template(body, "specify", project_dir, registrar)
3249+
3250+
# Simulate preset frontmatter that already defines scripts
3251+
preset_fm = {"description": "preset", "strategy": "wrap", "scripts": {"sh": "preset-run.sh"}}
3252+
for key in ("scripts", "agent_scripts"):
3253+
if key not in preset_fm and key in core_fm:
3254+
preset_fm[key] = core_fm[key]
3255+
3256+
# Preset's scripts must not be overwritten by core
3257+
assert preset_fm["scripts"] == {"sh": "preset-run.sh"}
3258+
3259+
def test_register_commands_inherits_scripts_from_core(self, project_dir):
3260+
"""register_commands merges scripts/agent_scripts from core and normalizes paths."""
3261+
from specify_cli.agents import CommandRegistrar
3262+
import copy
3263+
3264+
core_dir = project_dir / ".specify" / "templates" / "commands"
3265+
core_dir.mkdir(parents=True, exist_ok=True)
3266+
(core_dir / "specify.md").write_text(
3267+
"---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n"
3268+
"Run: {SCRIPT}\n"
3269+
)
3270+
3271+
cmd_dir = project_dir / "preset" / "commands"
3272+
cmd_dir.mkdir(parents=True, exist_ok=True)
3273+
# Preset has strategy: wrap but no scripts of its own
3274+
(cmd_dir / "speckit.specify.md").write_text(
3275+
"---\ndescription: wrap no scripts\nstrategy: wrap\n---\n\n"
3276+
"## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
3277+
)
3278+
3279+
agent_dir = project_dir / ".claude" / "commands"
3280+
agent_dir.mkdir(parents=True, exist_ok=True)
3281+
3282+
registrar = CommandRegistrar()
3283+
original = copy.deepcopy(registrar.AGENT_CONFIGS)
3284+
registrar.AGENT_CONFIGS["test-agent"] = {
3285+
"dir": str(agent_dir.relative_to(project_dir)),
3286+
"format": "markdown",
3287+
"args": "$ARGUMENTS",
3288+
"extension": ".md",
3289+
"strip_frontmatter_keys": [],
3290+
}
3291+
try:
3292+
registrar.register_commands(
3293+
"test-agent",
3294+
[{"name": "speckit.specify", "file": "commands/speckit.specify.md"}],
3295+
"test-preset",
3296+
project_dir / "preset",
3297+
project_dir,
3298+
)
3299+
finally:
3300+
CommandRegistrar.AGENT_CONFIGS.clear()
3301+
CommandRegistrar.AGENT_CONFIGS.update(original)
3302+
3303+
written = (agent_dir / "speckit.specify.md").read_text()
3304+
assert "{CORE_TEMPLATE}" not in written
3305+
assert "Run:" in written
3306+
assert "scripts:" in written
3307+
assert "run.sh" in written
3308+
3309+
def test_register_commands_toml_resolves_inherited_scripts(self, project_dir):
3310+
"""TOML agents resolve {SCRIPT} from inherited core scripts when preset omits them."""
3311+
from specify_cli.agents import CommandRegistrar
3312+
import copy
3313+
3314+
core_dir = project_dir / ".specify" / "templates" / "commands"
3315+
core_dir.mkdir(parents=True, exist_ok=True)
3316+
(core_dir / "specify.md").write_text(
3317+
"---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n"
3318+
"Run: {SCRIPT}\n"
3319+
)
3320+
3321+
cmd_dir = project_dir / "preset" / "commands"
3322+
cmd_dir.mkdir(parents=True, exist_ok=True)
3323+
(cmd_dir / "speckit.specify.md").write_text(
3324+
"---\ndescription: toml wrap\nstrategy: wrap\n---\n\n"
3325+
"## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
3326+
)
3327+
3328+
toml_dir = project_dir / ".gemini" / "commands"
3329+
toml_dir.mkdir(parents=True, exist_ok=True)
3330+
3331+
registrar = CommandRegistrar()
3332+
original = copy.deepcopy(registrar.AGENT_CONFIGS)
3333+
registrar.AGENT_CONFIGS["test-toml-agent"] = {
3334+
"dir": str(toml_dir.relative_to(project_dir)),
3335+
"format": "toml",
3336+
"args": "{{args}}",
3337+
"extension": ".toml",
3338+
"strip_frontmatter_keys": [],
3339+
}
3340+
try:
3341+
registrar.register_commands(
3342+
"test-toml-agent",
3343+
[{"name": "speckit.specify", "file": "commands/speckit.specify.md"}],
3344+
"test-preset",
3345+
project_dir / "preset",
3346+
project_dir,
3347+
)
3348+
finally:
3349+
CommandRegistrar.AGENT_CONFIGS.clear()
3350+
CommandRegistrar.AGENT_CONFIGS.update(original)
3351+
3352+
written = (toml_dir / "speckit.specify.toml").read_text()
3353+
assert "{CORE_TEMPLATE}" not in written
3354+
assert "{SCRIPT}" not in written
3355+
assert "run.sh" in written
3356+
# args token must use TOML format, not the intermediate $ARGUMENTS
3357+
assert "$ARGUMENTS" not in written
3358+
assert "{{args}}" in written
3359+
3360+
def test_extension_command_resolves_via_extension_directory(self, project_dir):
3361+
"""Extension commands (e.g. speckit.git.feature) resolve from the extension directory.
3362+
3363+
Both _register_skills and register_commands pass the full cmd_name to
3364+
_substitute_core_template, which tries the full name first via PresetResolver
3365+
and finds speckit.git.feature.md in the extension commands directory.
3366+
"""
3367+
from specify_cli.presets import _substitute_core_template
3368+
from specify_cli.agents import CommandRegistrar
3369+
3370+
# Place the template where a real extension would install it
3371+
ext_cmd_dir = project_dir / ".specify" / "extensions" / "git" / "commands"
3372+
ext_cmd_dir.mkdir(parents=True, exist_ok=True)
3373+
(ext_cmd_dir / "speckit.git.feature.md").write_text(
3374+
"---\ndescription: git feature core\n---\n\n# Git Feature Core\n"
3375+
)
3376+
# Ensure a hyphenated or dot-separated fallback does NOT exist
3377+
assert not (project_dir / ".specify" / "templates" / "commands" / "git.feature.md").exists()
3378+
assert not (project_dir / ".specify" / "templates" / "commands" / "git-feature.md").exists()
3379+
3380+
registrar = CommandRegistrar()
3381+
body = "## Wrapper\n\n{CORE_TEMPLATE}\n"
3382+
3383+
# Both call sites now pass the full cmd_name
3384+
result, _ = _substitute_core_template(body, "speckit.git.feature", project_dir, registrar)
3385+
3386+
assert "# Git Feature Core" in result
3387+
assert "{CORE_TEMPLATE}" not in result

0 commit comments

Comments
 (0)