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
2 changes: 1 addition & 1 deletion presets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,5 @@ The following enhancements are under consideration for future releases:
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
| **script** | ✓ (default) | — | — | ✓ |

For artifacts and commands (which are LLM directives), `wrap` would inject preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder. For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable.
For artifacts and commands (which are LLM directives), `wrap` injects preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder (implemented). For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable (not yet implemented).
- **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: "wrap"` option could allow presets to run custom logic before/after the core script without fully replacing it.
14 changes: 14 additions & 0 deletions presets/self-test/commands/speckit.wrap-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
description: "Self-test wrap command — pre/post around core"
strategy: wrap
---

## Preset Pre-Logic

preset:self-test wrap-pre

{CORE_TEMPLATE}

## Preset Post-Logic

preset:self-test wrap-post
5 changes: 5 additions & 0 deletions presets/self-test/preset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ provides:
description: "Self-test override of the specify command"
replaces: "speckit.specify"

- type: "command"
name: "speckit.wrap-test"
file: "commands/speckit.wrap-test.md"
description: "Self-test wrap strategy command"

tags:
- "testing"
- "self-test"
4 changes: 4 additions & 0 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,10 @@ def register_commands(
content = source_file.read_text(encoding="utf-8")
frontmatter, body = self.parse_frontmatter(content)

if frontmatter.get("strategy") == "wrap":
from .presets import _substitute_core_template
body, _ = _substitute_core_template(body, cmd_name, project_root, self)

Comment on lines +419 to +420
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When strategy: wrap is used here, _substitute_core_template returns the wrapped core template’s frontmatter as the second tuple element, but it’s discarded. For SKILL-target agents that call resolve_skill_placeholders (e.g. codex/kimi), any {SCRIPT}/{AGENT_SCRIPT} placeholders coming from the substituted core body will remain unresolved unless scripts/agent_scripts are inherited into frontmatter (similar to what _register_skills now does). Consider merging scripts and agent_scripts from the core frontmatter when the preset frontmatter doesn’t define them, before _adjust_script_paths runs so paths get normalized too.

Suggested change
body, _ = _substitute_core_template(body, cmd_name, project_root, self)
body, core_frontmatter = _substitute_core_template(
body, cmd_name, project_root, self
)
for key in ("scripts", "agent_scripts"):
if key not in frontmatter and key in core_frontmatter:
frontmatter[key] = deepcopy(core_frontmatter[key])

Copilot uses AI. Check for mistakes.
frontmatter = self._adjust_script_paths(frontmatter)

for key in agent_config.get("strip_frontmatter_keys", []):
Expand Down
69 changes: 69 additions & 0 deletions src/specify_cli/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,64 @@
from .extensions import ExtensionRegistry, normalize_priority


def _substitute_core_template(
body: str,
cmd_name: str,
project_root: "Path",
registrar: "CommandRegistrar",
) -> "tuple[str, dict]":
"""Substitute {CORE_TEMPLATE} with the body of the installed core command template.

Args:
body: Preset command body (may contain {CORE_TEMPLATE} placeholder).
cmd_name: Full command name (e.g. "speckit.specify" or "speckit.git.feature").
project_root: Project root path.
registrar: CommandRegistrar instance for parse_frontmatter.

Returns:
Tuple of (substituted_body, core_frontmatter). Returns (body, {}) unchanged
when the placeholder is absent or the core template file cannot be found.
"""
if "{CORE_TEMPLATE}" not in body:
return body, {}

# Derive the filename stem used by core-pack and project template lookups.
# "speckit.specify" → "specify"
# "speckit.git.feature" → "git-feature"
raw = cmd_name[len("speckit."):] if cmd_name.startswith("speckit.") else cmd_name
file_stem = raw.replace(".", "-")

# 1. Project-installed core template (always takes priority).
core_file = project_root / ".specify" / "templates" / "commands" / f"{file_stem}.md"

# 2. Extension command: speckit.<ext>.<cmd> → .specify/extensions/<ext>/commands/<cmd_name>.md
if not core_file.exists():
parts = cmd_name.split(".")
if len(parts) >= 3 and parts[0] == "speckit":
ext_id = parts[1]
ext_file = (
project_root / ".specify" / "extensions" / ext_id / "commands" / f"{cmd_name}.md"
)
if ext_file.exists():
core_file = ext_file

# 3. Bundled core pack, then source/editable-install repo templates.
if not core_file.exists():
from specify_cli import _locate_core_pack
core_pack = _locate_core_pack()
if core_pack is not None:
core_file = core_pack / "commands" / f"{file_stem}.md"
else:
repo_root = Path(__file__).parent.parent.parent
core_file = repo_root / "templates" / "commands" / f"{file_stem}.md"

if not core_file.exists():
return body, {}

core_frontmatter, core_body = registrar.parse_frontmatter(core_file.read_text(encoding="utf-8"))
return body.replace("{CORE_TEMPLATE}", core_body), core_frontmatter


@dataclass
class PresetCatalogEntry:
"""Represents a single entry in the preset catalog stack."""
Expand Down Expand Up @@ -759,6 +817,17 @@ def _register_skills(
content = source_file.read_text(encoding="utf-8")
frontmatter, body = registrar.parse_frontmatter(content)

if frontmatter.get("strategy") == "wrap":
body, core_fm = _substitute_core_template(body, cmd_name, self.project_root, registrar)
# Inherit script keys from the core template when the preset doesn't
# define its own — required so {SCRIPT}/{AGENT_SCRIPT} placeholders
# embedded in the substituted core body still resolve correctly.
if core_fm:
frontmatter = dict(frontmatter)
for key in ("scripts", "agent_scripts"):
if key not in frontmatter and key in core_fm:
frontmatter[key] = core_fm[key]

original_desc = frontmatter.get("description", "")
enhanced_desc = SKILL_DESCRIPTIONS.get(
short_name,
Expand Down
238 changes: 237 additions & 1 deletion tests/test_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1667,7 +1667,7 @@ def test_self_test_manifest_valid(self):
assert manifest.id == "self-test"
assert manifest.name == "Self-Test Preset"
assert manifest.version == "1.0.0"
assert len(manifest.templates) == 7 # 6 templates + 1 command
assert len(manifest.templates) == 8 # 6 templates + 2 commands

def test_self_test_provides_all_core_templates(self):
"""Verify the self-test preset provides an override for every core template."""
Expand Down Expand Up @@ -3044,3 +3044,239 @@ def test_bundled_preset_missing_locally_cli_error(self, project_dir):
output = strip_ansi(result.output).lower()
assert "bundled" in output, result.output
assert "reinstall" in output, result.output


class TestWrapStrategy:
"""Tests for strategy: wrap preset command substitution."""

def test_substitute_core_template_replaces_placeholder(self, project_dir):
"""Core template body replaces {CORE_TEMPLATE} in preset command body."""
from specify_cli.presets import _substitute_core_template
from specify_cli.agents import CommandRegistrar

# Set up a core command template
core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "specify.md").write_text(
"---\ndescription: core\n---\n\n# Core Specify\n\nDo the thing.\n"
)

registrar = CommandRegistrar()
body = "## Pre-Logic\n\nBefore stuff.\n\n{CORE_TEMPLATE}\n\n## Post-Logic\n\nAfter stuff.\n"
result, _ = _substitute_core_template(body, "speckit.specify", project_dir, registrar)

assert "{CORE_TEMPLATE}" not in result
assert "# Core Specify" in result
assert "## Pre-Logic" in result
assert "## Post-Logic" in result

def test_substitute_core_template_no_op_when_placeholder_absent(self, project_dir):
"""Returns body unchanged when {CORE_TEMPLATE} is not present."""
from specify_cli.presets import _substitute_core_template
from specify_cli.agents import CommandRegistrar

core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCore body.\n")

registrar = CommandRegistrar()
body = "## No placeholder here.\n"
result, _ = _substitute_core_template(body, "speckit.specify", project_dir, registrar)
assert result == body

def test_substitute_core_template_no_op_when_core_missing(self, project_dir):
"""Returns body unchanged when core template file does not exist."""
from specify_cli.presets import _substitute_core_template
from specify_cli.agents import CommandRegistrar

registrar = CommandRegistrar()
body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n"
result, core_fm = _substitute_core_template(body, "speckit.nonexistent", project_dir, registrar)
assert result == body
assert "{CORE_TEMPLATE}" in result
assert core_fm == {}

def test_register_commands_substitutes_core_template_for_wrap_strategy(self, project_dir):
"""register_commands substitutes {CORE_TEMPLATE} when strategy: wrap."""
from specify_cli.agents import CommandRegistrar

# Set up core command template
core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "specify.md").write_text(
"---\ndescription: core\n---\n\n# Core Specify\n\nCore body here.\n"
)

# Create a preset command dir with a wrap-strategy command
cmd_dir = project_dir / "preset" / "commands"
cmd_dir.mkdir(parents=True, exist_ok=True)
(cmd_dir / "speckit.specify.md").write_text(
"---\ndescription: wrap test\nstrategy: wrap\n---\n\n"
"## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
)

commands = [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}]
registrar = CommandRegistrar()

# Use a generic agent that writes markdown to commands/
agent_dir = project_dir / ".claude" / "commands"
agent_dir.mkdir(parents=True, exist_ok=True)

# Patch AGENT_CONFIGS to use a simple markdown agent pointing at our dir
import copy
original = copy.deepcopy(registrar.AGENT_CONFIGS)
registrar.AGENT_CONFIGS["test-agent"] = {
"dir": str(agent_dir.relative_to(project_dir)),
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
"strip_frontmatter_keys": [],
}
try:
registrar.register_commands(
"test-agent", commands, "test-preset",
project_dir / "preset", project_dir
)
finally:
CommandRegistrar.AGENT_CONFIGS = original

written = (agent_dir / "speckit.specify.md").read_text()
assert "{CORE_TEMPLATE}" not in written
assert "# Core Specify" in written
assert "## Pre" in written
assert "## Post" in written

def test_end_to_end_wrap_via_self_test_preset(self, project_dir):
"""Installing self-test preset with a wrap command substitutes {CORE_TEMPLATE}."""
from specify_cli.presets import PresetManager

# Install a core template that wrap-test will wrap around
core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "wrap-test.md").write_text(
"---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n"
)

# Set up skills dir (simulating --ai claude)
skills_dir = project_dir / ".claude" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
skill_subdir = skills_dir / "speckit-wrap-test"
skill_subdir.mkdir()
(skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold content\n")

# Write init-options so _register_skills finds the claude skills dir
import json
(project_dir / ".specify" / "init-options.json").write_text(
json.dumps({"ai": "claude", "ai_skills": True})
)

manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")

written = (skill_subdir / "SKILL.md").read_text()
assert "{CORE_TEMPLATE}" not in written
assert "# Core Wrap-Test Body" in written
assert "preset:self-test wrap-pre" in written
assert "preset:self-test wrap-post" in written

def test_substitute_core_template_returns_core_frontmatter(self, project_dir):
"""_substitute_core_template returns core frontmatter as second tuple element."""
from specify_cli.presets import _substitute_core_template
from specify_cli.agents import CommandRegistrar

core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "specify.md").write_text(
"---\ndescription: core\nscripts:\n sh: scripts/bash/setup.sh\n---\n\n# Core Specify\n"
)

registrar = CommandRegistrar()
body = "{CORE_TEMPLATE}\n\n## Post\n"
result_body, core_fm = _substitute_core_template(body, "speckit.specify", project_dir, registrar)

assert "{CORE_TEMPLATE}" not in result_body
assert "# Core Specify" in result_body
assert core_fm.get("scripts") == {"sh": "scripts/bash/setup.sh"}

def test_substitute_core_template_no_core_returns_empty_frontmatter(self, project_dir):
"""Returns empty dict as frontmatter when core template does not exist."""
from specify_cli.presets import _substitute_core_template
from specify_cli.agents import CommandRegistrar

registrar = CommandRegistrar()
body = "{CORE_TEMPLATE}\n\n## Post\n"
result_body, core_fm = _substitute_core_template(body, "speckit.nonexistent", project_dir, registrar)

assert result_body == body
assert core_fm == {}

def test_substitute_core_template_resolves_extension_command(self, project_dir):
"""_substitute_core_template resolves {CORE_TEMPLATE} from an extension command directory."""
from specify_cli.presets import _substitute_core_template
from specify_cli.agents import CommandRegistrar

# Simulate an installed extension
ext_cmd_dir = project_dir / ".specify" / "extensions" / "git" / "commands"
ext_cmd_dir.mkdir(parents=True, exist_ok=True)
(ext_cmd_dir / "speckit.git.feature.md").write_text(
"---\ndescription: git feature core\nhandoffs:\n - label: Next\n agent: speckit.plan\n---\n\n# Core Git Feature\n"
)

registrar = CommandRegistrar()
body = "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
result_body, core_fm = _substitute_core_template(
body, "speckit.git.feature", project_dir, registrar
)

assert "{CORE_TEMPLATE}" not in result_body
assert "# Core Git Feature" in result_body
assert core_fm.get("handoffs") is not None

def test_wrap_strategy_inherits_core_scripts_in_skills(self, project_dir):
"""_register_skills merges core scripts into preset frontmatter for {SCRIPT} resolution."""
import json
from specify_cli.presets import PresetManager
from pathlib import Path

# Set up core command template with scripts frontmatter
core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "specify.md").write_text(
"---\ndescription: core specify\nscripts:\n sh: scripts/bash/check.sh\n---\n\n"
"Run: {SCRIPT}\n\n# Core Specify\n"
)

# Write init-options
(project_dir / ".specify" / "init-options.json").write_text(
json.dumps({"ai": "claude", "ai_skills": True})
)

# Create a preset with a wrap command that has no scripts of its own
preset_dir = project_dir / "test-preset"
(preset_dir / "commands").mkdir(parents=True)
(preset_dir / "commands" / "speckit.specify.md").write_text(
"---\ndescription: preset wrap\nstrategy: wrap\n---\n\n{CORE_TEMPLATE}\n\n## Post\n"
)
(preset_dir / "preset.yml").write_text(
"schema_version: '1.0'\n"
"preset:\n id: test-scripts-preset\n name: Test\n version: 1.0.0\n"
" description: Test\n author: test\n"
"requires:\n speckit_version: '>=0.5.0'\n"
"provides:\n templates:\n"
" - type: command\n name: speckit.specify\n"
" file: commands/speckit.specify.md\n description: wrap\n"
)

# Create the skill dir so _register_skills will overwrite it
skills_dir = project_dir / ".claude" / "skills" / "speckit-specify"
skills_dir.mkdir(parents=True)
(skills_dir / "SKILL.md").write_text("---\nname: speckit-specify\n---\n\nold\n")

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

written = (skills_dir / "SKILL.md").read_text()
# {SCRIPT} should have been resolved using the core template's scripts
assert "{SCRIPT}" not in written
assert "scripts/bash/check.sh" in written