Skip to content

Commit 49c83d2

Browse files
fix: inherit scripts/agent_scripts from core frontmatter for strategy: wrap
_substitute_core_template now returns a (body, core_frontmatter) tuple so callers can merge scripts and agent_scripts from the core template into the preset frontmatter when the preset does not define them. Both _register_skills and register_commands perform this merge before _adjust_script_paths runs so inherited paths are normalised. The TOML render path also calls resolve_skill_placeholders so {SCRIPT}/{AGENT_SCRIPT} placeholders are expanded for Gemini/Tabnine agents.
1 parent 6a24268 commit 49c83d2

File tree

3 files changed

+201
-12
lines changed

3 files changed

+201
-12
lines changed

src/specify_cli/agents.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,11 @@ def register_commands(
419419
short_name = cmd_name
420420
if short_name.startswith("speckit."):
421421
short_name = short_name[len("speckit."):]
422-
body = _substitute_core_template(body, short_name, project_root, self)
422+
body, core_frontmatter = _substitute_core_template(body, short_name, project_root, self)
423+
frontmatter = dict(frontmatter)
424+
for key in ("scripts", "agent_scripts"):
425+
if key not in frontmatter and key in core_frontmatter:
426+
frontmatter[key] = core_frontmatter[key]
423427

424428
frontmatter = self._adjust_script_paths(frontmatter)
425429

@@ -444,6 +448,8 @@ def register_commands(
444448
elif agent_config["format"] == "markdown":
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: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def _substitute_core_template(
3232
short_name: str,
3333
project_root: "Path",
3434
registrar: "CommandRegistrar",
35-
) -> str:
35+
) -> "tuple[str, dict]":
3636
"""Substitute {CORE_TEMPLATE} with the body of the installed core command template.
3737
3838
Args:
@@ -42,18 +42,21 @@ def _substitute_core_template(
4242
registrar: CommandRegistrar instance for parse_frontmatter.
4343
4444
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.
45+
A tuple of (body, core_frontmatter) where body has {CORE_TEMPLATE} replaced
46+
by the core template body and core_frontmatter holds the core template's parsed
47+
frontmatter (so callers can inherit scripts/agent_scripts from it). Both are
48+
unchanged / empty when the placeholder is absent or the core template file does
49+
not exist.
4750
"""
4851
if "{CORE_TEMPLATE}" not in body:
49-
return body
52+
return body, {}
5053

5154
core_file = project_root / ".specify" / "templates" / "commands" / f"{short_name}.md"
5255
if not core_file.exists():
53-
return body
56+
return body, {}
5457

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

5861

5962
@dataclass
@@ -789,7 +792,11 @@ def _register_skills(
789792
frontmatter, body = registrar.parse_frontmatter(content)
790793

791794
if frontmatter.get("strategy") == "wrap":
792-
body = _substitute_core_template(body, short_name, self.project_root, registrar)
795+
body, core_frontmatter = _substitute_core_template(body, short_name, self.project_root, registrar)
796+
frontmatter = dict(frontmatter)
797+
for key in ("scripts", "agent_scripts"):
798+
if key not in frontmatter and key in core_frontmatter:
799+
frontmatter[key] = core_frontmatter[key]
793800

794801
original_desc = frontmatter.get("description", "")
795802
enhanced_desc = SKILL_DESCRIPTIONS.get(

tests/test_presets.py

Lines changed: 179 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3062,12 +3062,13 @@ def test_substitute_core_template_replaces_placeholder(self, project_dir):
30623062

30633063
registrar = CommandRegistrar()
30643064
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)
3065+
result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar)
30663066

30673067
assert "{CORE_TEMPLATE}" not in result
30683068
assert "# Core Specify" in result
30693069
assert "## Pre-Logic" in result
30703070
assert "## Post-Logic" in result
3071+
assert core_fm.get("description") == "core"
30713072

30723073
def test_substitute_core_template_no_op_when_placeholder_absent(self, project_dir):
30733074
"""Returns body unchanged when {CORE_TEMPLATE} is not present."""
@@ -3080,8 +3081,9 @@ def test_substitute_core_template_no_op_when_placeholder_absent(self, project_di
30803081

30813082
registrar = CommandRegistrar()
30823083
body = "## No placeholder here.\n"
3083-
result = _substitute_core_template(body, "specify", project_dir, registrar)
3084+
result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar)
30843085
assert result == body
3086+
assert core_fm == {}
30853087

30863088
def test_substitute_core_template_no_op_when_core_missing(self, project_dir):
30873089
"""Returns body unchanged when core template file does not exist."""
@@ -3090,9 +3092,10 @@ def test_substitute_core_template_no_op_when_core_missing(self, project_dir):
30903092

30913093
registrar = CommandRegistrar()
30923094
body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n"
3093-
result = _substitute_core_template(body, "nonexistent", project_dir, registrar)
3095+
result, core_fm = _substitute_core_template(body, "nonexistent", project_dir, registrar)
30943096
assert result == body
30953097
assert "{CORE_TEMPLATE}" in result
3098+
assert core_fm == {}
30963099

30973100
def test_register_commands_substitutes_core_template_for_wrap_strategy(self, project_dir):
30983101
"""register_commands substitutes {CORE_TEMPLATE} when strategy: wrap."""
@@ -3176,3 +3179,176 @@ def test_end_to_end_wrap_via_self_test_preset(self, project_dir):
31763179
assert "# Core Wrap-Test Body" in written
31773180
assert "preset:self-test wrap-pre" in written
31783181
assert "preset:self-test wrap-post" in written
3182+
3183+
def test_substitute_core_template_returns_core_scripts(self, project_dir):
3184+
"""core_frontmatter in the returned tuple includes scripts/agent_scripts."""
3185+
from specify_cli.presets import _substitute_core_template
3186+
from specify_cli.agents import CommandRegistrar
3187+
3188+
core_dir = project_dir / ".specify" / "templates" / "commands"
3189+
core_dir.mkdir(parents=True, exist_ok=True)
3190+
(core_dir / "specify.md").write_text(
3191+
"---\ndescription: core\nscripts:\n sh: run.sh\nagent_scripts:\n sh: agent-run.sh\n---\n\n# Body\n"
3192+
)
3193+
3194+
registrar = CommandRegistrar()
3195+
body = "## Wrapper\n\n{CORE_TEMPLATE}\n"
3196+
result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar)
3197+
3198+
assert "# Body" in result
3199+
assert core_fm.get("scripts") == {"sh": "run.sh"}
3200+
assert core_fm.get("agent_scripts") == {"sh": "agent-run.sh"}
3201+
3202+
def test_register_skills_inherits_scripts_from_core_when_preset_omits_them(self, project_dir):
3203+
"""_register_skills merges scripts/agent_scripts from core when preset lacks them."""
3204+
from specify_cli.presets import PresetManager
3205+
import json
3206+
3207+
# Core template with scripts
3208+
core_dir = project_dir / ".specify" / "templates" / "commands"
3209+
core_dir.mkdir(parents=True, exist_ok=True)
3210+
(core_dir / "wrap-test.md").write_text(
3211+
"---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh\n---\n\n"
3212+
"Run: {SCRIPT}\n"
3213+
)
3214+
3215+
# Skills dir for claude
3216+
skills_dir = project_dir / ".claude" / "skills"
3217+
skills_dir.mkdir(parents=True, exist_ok=True)
3218+
skill_subdir = skills_dir / "speckit-wrap-test"
3219+
skill_subdir.mkdir()
3220+
(skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold\n")
3221+
3222+
(project_dir / ".specify" / "init-options.json").write_text(
3223+
json.dumps({"ai": "claude", "ai_skills": True})
3224+
)
3225+
3226+
manager = PresetManager(project_dir)
3227+
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
3228+
3229+
written = (skill_subdir / "SKILL.md").read_text()
3230+
# {SCRIPT} should have been resolved (not left as a literal placeholder)
3231+
assert "{SCRIPT}" not in written
3232+
3233+
def test_register_skills_preset_scripts_take_precedence_over_core(self, project_dir):
3234+
"""preset-defined scripts/agent_scripts are not overwritten by core frontmatter."""
3235+
from specify_cli.presets import _substitute_core_template
3236+
from specify_cli.agents import CommandRegistrar
3237+
3238+
core_dir = project_dir / ".specify" / "templates" / "commands"
3239+
core_dir.mkdir(parents=True, exist_ok=True)
3240+
(core_dir / "specify.md").write_text(
3241+
"---\ndescription: core\nscripts:\n sh: core-run.sh\n---\n\nCore body.\n"
3242+
)
3243+
3244+
registrar = CommandRegistrar()
3245+
body = "{CORE_TEMPLATE}"
3246+
_, core_fm = _substitute_core_template(body, "specify", project_dir, registrar)
3247+
3248+
# Simulate preset frontmatter that already defines scripts
3249+
preset_fm = {"description": "preset", "strategy": "wrap", "scripts": {"sh": "preset-run.sh"}}
3250+
for key in ("scripts", "agent_scripts"):
3251+
if key not in preset_fm and key in core_fm:
3252+
preset_fm[key] = core_fm[key]
3253+
3254+
# Preset's scripts must not be overwritten by core
3255+
assert preset_fm["scripts"] == {"sh": "preset-run.sh"}
3256+
3257+
def test_register_commands_inherits_scripts_from_core(self, project_dir):
3258+
"""register_commands merges scripts/agent_scripts from core and normalizes paths."""
3259+
from specify_cli.agents import CommandRegistrar
3260+
import copy
3261+
3262+
core_dir = project_dir / ".specify" / "templates" / "commands"
3263+
core_dir.mkdir(parents=True, exist_ok=True)
3264+
(core_dir / "specify.md").write_text(
3265+
"---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n"
3266+
"Run: {SCRIPT}\n"
3267+
)
3268+
3269+
cmd_dir = project_dir / "preset" / "commands"
3270+
cmd_dir.mkdir(parents=True, exist_ok=True)
3271+
# Preset has strategy: wrap but no scripts of its own
3272+
(cmd_dir / "speckit.specify.md").write_text(
3273+
"---\ndescription: wrap no scripts\nstrategy: wrap\n---\n\n"
3274+
"## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
3275+
)
3276+
3277+
agent_dir = project_dir / ".claude" / "commands"
3278+
agent_dir.mkdir(parents=True, exist_ok=True)
3279+
3280+
registrar = CommandRegistrar()
3281+
original = copy.deepcopy(registrar.AGENT_CONFIGS)
3282+
registrar.AGENT_CONFIGS["test-agent"] = {
3283+
"dir": str(agent_dir.relative_to(project_dir)),
3284+
"format": "markdown",
3285+
"args": "$ARGUMENTS",
3286+
"extension": ".md",
3287+
"strip_frontmatter_keys": [],
3288+
}
3289+
try:
3290+
registrar.register_commands(
3291+
"test-agent",
3292+
[{"name": "speckit.specify", "file": "commands/speckit.specify.md"}],
3293+
"test-preset",
3294+
project_dir / "preset",
3295+
project_dir,
3296+
)
3297+
finally:
3298+
registrar.AGENT_CONFIGS = original
3299+
3300+
written = (agent_dir / "speckit.specify.md").read_text()
3301+
assert "{CORE_TEMPLATE}" not in written
3302+
assert "Run:" in written
3303+
assert "scripts:" in written
3304+
assert "run.sh" in written
3305+
3306+
def test_register_commands_toml_resolves_inherited_scripts(self, project_dir):
3307+
"""TOML agents resolve {SCRIPT} from inherited core scripts when preset omits them."""
3308+
from specify_cli.agents import CommandRegistrar
3309+
import copy
3310+
3311+
core_dir = project_dir / ".specify" / "templates" / "commands"
3312+
core_dir.mkdir(parents=True, exist_ok=True)
3313+
(core_dir / "specify.md").write_text(
3314+
"---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n"
3315+
"Run: {SCRIPT}\n"
3316+
)
3317+
3318+
cmd_dir = project_dir / "preset" / "commands"
3319+
cmd_dir.mkdir(parents=True, exist_ok=True)
3320+
(cmd_dir / "speckit.specify.md").write_text(
3321+
"---\ndescription: toml wrap\nstrategy: wrap\n---\n\n"
3322+
"## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
3323+
)
3324+
3325+
toml_dir = project_dir / ".gemini" / "commands"
3326+
toml_dir.mkdir(parents=True, exist_ok=True)
3327+
3328+
registrar = CommandRegistrar()
3329+
original = copy.deepcopy(registrar.AGENT_CONFIGS)
3330+
registrar.AGENT_CONFIGS["test-toml-agent"] = {
3331+
"dir": str(toml_dir.relative_to(project_dir)),
3332+
"format": "toml",
3333+
"args": "{{args}}",
3334+
"extension": ".toml",
3335+
"strip_frontmatter_keys": [],
3336+
}
3337+
try:
3338+
registrar.register_commands(
3339+
"test-toml-agent",
3340+
[{"name": "speckit.specify", "file": "commands/speckit.specify.md"}],
3341+
"test-preset",
3342+
project_dir / "preset",
3343+
project_dir,
3344+
)
3345+
finally:
3346+
registrar.AGENT_CONFIGS = original
3347+
3348+
written = (toml_dir / "speckit.specify.toml").read_text()
3349+
assert "{CORE_TEMPLATE}" not in written
3350+
assert "{SCRIPT}" not in written
3351+
assert "run.sh" in written
3352+
# args token must use TOML format, not the intermediate $ARGUMENTS
3353+
assert "$ARGUMENTS" not in written
3354+
assert "{{args}}" in written

0 commit comments

Comments
 (0)