Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/specify_cli/presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2743,7 +2743,7 @@ def _register_skills(
if frontmatter.get("strategy") == "wrap":
body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar)
frontmatter = dict(frontmatter)
for key in ("scripts", "agent_scripts"):
for key in ("scripts", "agent_scripts", "argument-hint"):
if key not in frontmatter and key in core_frontmatter:
frontmatter[key] = core_frontmatter[key]

Expand Down Expand Up @@ -5725,7 +5725,7 @@ def _parse_fm_yaml(fm_block: str) -> dict:
# Inherit scripts/agent_scripts from base frontmatter if missing
if base_frontmatter_text and base_frontmatter_text != top_frontmatter_text:
base_fm = _parse_fm_yaml(base_frontmatter_text)
for key in ("scripts", "agent_scripts"):
for key in ("scripts", "agent_scripts", "argument-hint"):
if key not in top_fm and key in base_fm:
top_fm[key] = base_fm[key]

Expand Down
157 changes: 157 additions & 0 deletions tests/test_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -4727,6 +4727,163 @@ def test_argument_hint_not_added_for_non_claude_preset_command(self, project_dir
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
assert "argument-hint" not in parsed

def test_wrap_preset_inherits_argument_hint_from_core(self, project_dir, temp_dir):
"""A wrap-strategy preset that omits argument-hint must inherit it from the core template.
Comment on lines +4730 to +4731

Regression for issue #3991: the wrap-composition path in _register_skills
previously inherited only scripts/agent_scripts from core_frontmatter,
silently discarding argument-hint and leaking its value into description.
"""
core_arg_hint = "Describe the feature you want to specify"
preset_description = "Wrapped speckit.specify — extra project context added"
self._write_init_options(project_dir, ai="claude")
skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-specify")

# Place a core template that declares argument-hint
core_cmds = project_dir / ".specify" / "templates" / "commands"
core_cmds.mkdir(parents=True, exist_ok=True)
(core_cmds / "specify.md").write_text(
"---\n"
"description: Core specify description.\n"
f'argument-hint: "{core_arg_hint}"\n'
"---\n\n"
"Core specify body.\n",
encoding="utf-8",
)

# Wrap preset: only declares description (no argument-hint)
preset_dir = temp_dir / "wrap-hint-preset"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.specify.md").write_text(
"---\n"
f'description: "{preset_description}"\n'
"strategy: wrap\n"
"---\n\n"
"{CORE_TEMPLATE}\n",
encoding="utf-8",
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "wrap-hint-preset",
"name": "Wrap Hint Preset",
"version": "1.0.0",
"description": "Test wrap hint inheritance",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.specify",
"file": "commands/speckit.specify.md",
"strategy": "wrap",
}
]
},
}
import yaml as _yaml
with open(preset_dir / "preset.yml", "w") as f:
_yaml.dump(manifest_data, f)

manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "1.0.0")

skill_file = skills_dir / "speckit-specify" / "SKILL.md"
assert skill_file.exists()
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
# argument-hint must be inherited from core, not dropped
assert parsed.get("argument-hint") == core_arg_hint, (
f"argument-hint was not inherited from core; parsed={parsed}"
)
# description must be exactly the preset's declared value, not concatenated
assert parsed["description"] == preset_description, (
f"description was corrupted; parsed={parsed}"
)

def test_wrap_preset_inherits_argument_hint_for_unmapped_command(self, project_dir, temp_dir):
"""Wrap inheritance must carry argument-hint for a command NOT in ARGUMENT_HINTS.

Regression guard for issue #3991. The companion test above wraps
``speckit.specify``, whose stem is in Claude's ``ARGUMENT_HINTS`` map, so
the string-injection fallback in ``post_process_skill_content`` re-adds
``argument-hint`` even when wrap composition drops it — masking the bug.
This test wraps an extension-like command (``speckit.myfeature``) that is
absent from that map, so the *only* thing that can carry the hint into the
SKILL.md is the wrap-composition inheritance fix itself. Without the fix
the key is dropped and this test fails.
"""
core_arg_hint = "Custom hint that lives only on the core template"
preset_description = "Wrapped speckit.myfeature — extra project context added"
self._write_init_options(project_dir, ai="claude")
skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-myfeature")

# Place a core template (extension-like command) that declares argument-hint
core_cmds = project_dir / ".specify" / "templates" / "commands"
core_cmds.mkdir(parents=True, exist_ok=True)
(core_cmds / "myfeature.md").write_text(
"---\n"
"description: Core myfeature description.\n"
f'argument-hint: "{core_arg_hint}"\n'
"---\n\n"
"Core myfeature body.\n",
encoding="utf-8",
)

# Wrap preset: only declares description (no argument-hint)
preset_dir = temp_dir / "wrap-hint-preset-unmapped"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.myfeature.md").write_text(
"---\n"
f'description: "{preset_description}"\n'
"strategy: wrap\n"
"---\n\n"
"{CORE_TEMPLATE}\n",
encoding="utf-8",
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "wrap-hint-preset-unmapped",
"name": "Wrap Hint Preset Unmapped",
"version": "1.0.0",
"description": "Test wrap hint inheritance for an unmapped command",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.myfeature",
"file": "commands/speckit.myfeature.md",
"strategy": "wrap",
}
]
},
}
import yaml as _yaml
with open(preset_dir / "preset.yml", "w") as f:
_yaml.dump(manifest_data, f)

manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "1.0.0")

skill_file = skills_dir / "speckit-myfeature" / "SKILL.md"
assert skill_file.exists()
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
# argument-hint must be inherited from core, not dropped
assert parsed.get("argument-hint") == core_arg_hint, (
f"argument-hint was not inherited from core; parsed={parsed}"
)
# description must be exactly the preset's declared value, not concatenated
assert parsed["description"] == preset_description, (
f"description was corrupted; parsed={parsed}"
)

def test_register_skills_resolves_command_refs(self, project_dir, temp_dir):
"""Preset skill overrides must resolve __SPECKIT_COMMAND_*__ tokens (issue #2717).

Expand Down