Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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"
7 changes: 7 additions & 0 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,13 @@ 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
short_name = cmd_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit."):]
body = _substitute_core_template(body, short_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
45 changes: 45 additions & 0 deletions src/specify_cli/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,48 @@
from .extensions import ExtensionRegistry, normalize_priority


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

Args:
body: Preset command body (may contain {CORE_TEMPLATE} placeholder).
short_name: Short command name (e.g. "specify" from "speckit.specify").
project_root: Project root path.
registrar: CommandRegistrar instance for parse_frontmatter.

Returns:
Body with {CORE_TEMPLATE} replaced by core template body, or body unchanged
if the placeholder is absent or the core template file does not exist.
"""
if "{CORE_TEMPLATE}" not in body:
return body

# Prefer the project's installed core template so project-level commands and
# customisations are wrapped correctly. Fall back to the bundled core_pack or
# repo templates for commands that ship with the package.
core_file = project_root / ".specify" / "templates" / "commands" / f"{short_name}.md"
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"{short_name}.md"
else:
# Source / editable install: look relative to the package root
repo_root = Path(__file__).parent.parent.parent
core_file = repo_root / "templates" / "commands" / f"{short_name}.md"

if not core_file.exists():
return body

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


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

if frontmatter.get("strategy") == "wrap":
body = _substitute_core_template(body, short_name, self.project_root, registrar)

original_desc = frontmatter.get("description", "")
enhanced_desc = SKILL_DESCRIPTIONS.get(
short_name,
Expand Down
136 changes: 134 additions & 2 deletions 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 @@ -3043,4 +3043,136 @@ def test_bundled_preset_missing_locally_cli_error(self, project_dir):
assert result.exit_code == 1
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, "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, "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 = _substitute_core_template(body, "nonexistent", project_dir, registrar)
assert result == body
assert "{CORE_TEMPLATE}" in result

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:
registrar.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