Skip to content

Commit 4ba8216

Browse files
feat: implement strategy: wrap
1 parent c118c1c commit 4ba8216

File tree

6 files changed

+193
-3
lines changed

6 files changed

+193
-3
lines changed

presets/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,5 +116,5 @@ The following enhancements are under consideration for future releases:
116116
| **command** | ✓ (default) ||||
117117
| **script** | ✓ (default) ||||
118118

119-
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.
119+
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).
120120
- **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.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
description: "Self-test wrap command — pre/post around core"
3+
strategy: wrap
4+
---
5+
6+
## Preset Pre-Logic
7+
8+
preset:self-test wrap-pre
9+
10+
{CORE_TEMPLATE}
11+
12+
## Preset Post-Logic
13+
14+
preset:self-test wrap-post

presets/self-test/preset.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ provides:
5656
description: "Self-test override of the specify command"
5757
replaces: "speckit.specify"
5858

59+
- type: "command"
60+
name: "speckit.wrap-test"
61+
file: "commands/speckit.wrap-test.md"
62+
description: "Self-test wrap strategy command"
63+
5964
tags:
6065
- "testing"
6166
- "self-test"

src/specify_cli/agents.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,13 @@ def register_commands(
445445
content = source_file.read_text(encoding="utf-8")
446446
frontmatter, body = self.parse_frontmatter(content)
447447

448+
if frontmatter.get("strategy") == "wrap":
449+
from .presets import _substitute_core_template
450+
short_name = cmd_name
451+
if short_name.startswith("speckit."):
452+
short_name = short_name[len("speckit."):]
453+
body = _substitute_core_template(body, short_name, project_root, self)
454+
448455
frontmatter = self._adjust_script_paths(frontmatter)
449456

450457
for key in agent_config.get("strip_frontmatter_keys", []):

src/specify_cli/presets.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,35 @@
2727
from .extensions import ExtensionRegistry, normalize_priority
2828

2929

30+
def _substitute_core_template(
31+
body: str,
32+
short_name: str,
33+
project_root: "Path",
34+
registrar: "CommandRegistrar",
35+
) -> str:
36+
"""Substitute {CORE_TEMPLATE} with the body of the installed core command template.
37+
38+
Args:
39+
body: Preset command body (may contain {CORE_TEMPLATE} placeholder).
40+
short_name: Short command name (e.g. "specify" from "speckit.specify").
41+
project_root: Project root path.
42+
registrar: CommandRegistrar instance for parse_frontmatter.
43+
44+
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.
47+
"""
48+
if "{CORE_TEMPLATE}" not in body:
49+
return body
50+
51+
core_file = project_root / ".specify" / "templates" / "commands" / f"{short_name}.md"
52+
if not core_file.exists():
53+
return body
54+
55+
_, core_body = registrar.parse_frontmatter(core_file.read_text(encoding="utf-8"))
56+
return body.replace("{CORE_TEMPLATE}", core_body)
57+
58+
3059
@dataclass
3160
class PresetCatalogEntry:
3261
"""Represents a single entry in the preset catalog stack."""
@@ -761,6 +790,9 @@ def _register_skills(
761790
content = source_file.read_text(encoding="utf-8")
762791
frontmatter, body = registrar.parse_frontmatter(content)
763792

793+
if frontmatter.get("strategy") == "wrap":
794+
body = _substitute_core_template(body, short_name, self.project_root, registrar)
795+
764796
original_desc = frontmatter.get("description", "")
765797
enhanced_desc = SKILL_DESCRIPTIONS.get(
766798
short_name,

tests/test_presets.py

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1665,7 +1665,7 @@ def test_self_test_manifest_valid(self):
16651665
assert manifest.id == "self-test"
16661666
assert manifest.name == "Self-Test Preset"
16671667
assert manifest.version == "1.0.0"
1668-
assert len(manifest.templates) == 7 # 6 templates + 1 command
1668+
assert len(manifest.templates) == 8 # 6 templates + 2 commands
16691669

16701670
def test_self_test_provides_all_core_templates(self):
16711671
"""Verify the self-test preset provides an override for every core template."""
@@ -3040,4 +3040,136 @@ def test_bundled_preset_missing_locally_cli_error(self, project_dir):
30403040
assert result.exit_code == 1
30413041
output = strip_ansi(result.output).lower()
30423042
assert "bundled" in output, result.output
3043-
assert "reinstall" in output, result.output
3043+
3044+
3045+
class TestWrapStrategy:
3046+
"""Tests for strategy: wrap preset command substitution."""
3047+
3048+
def test_substitute_core_template_replaces_placeholder(self, project_dir):
3049+
"""Core template body replaces {CORE_TEMPLATE} in preset command body."""
3050+
from specify_cli.presets import _substitute_core_template
3051+
from specify_cli.agents import CommandRegistrar
3052+
3053+
# Set up a core command template
3054+
core_dir = project_dir / ".specify" / "templates" / "commands"
3055+
core_dir.mkdir(parents=True, exist_ok=True)
3056+
(core_dir / "specify.md").write_text(
3057+
"---\ndescription: core\n---\n\n# Core Specify\n\nDo the thing.\n"
3058+
)
3059+
3060+
registrar = CommandRegistrar()
3061+
body = "## Pre-Logic\n\nBefore stuff.\n\n{CORE_TEMPLATE}\n\n## Post-Logic\n\nAfter stuff.\n"
3062+
result = _substitute_core_template(body, "specify", project_dir, registrar)
3063+
3064+
assert "{CORE_TEMPLATE}" not in result
3065+
assert "# Core Specify" in result
3066+
assert "## Pre-Logic" in result
3067+
assert "## Post-Logic" in result
3068+
3069+
def test_substitute_core_template_no_op_when_placeholder_absent(self, project_dir):
3070+
"""Returns body unchanged when {CORE_TEMPLATE} is not present."""
3071+
from specify_cli.presets import _substitute_core_template
3072+
from specify_cli.agents import CommandRegistrar
3073+
3074+
core_dir = project_dir / ".specify" / "templates" / "commands"
3075+
core_dir.mkdir(parents=True, exist_ok=True)
3076+
(core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCore body.\n")
3077+
3078+
registrar = CommandRegistrar()
3079+
body = "## No placeholder here.\n"
3080+
result = _substitute_core_template(body, "specify", project_dir, registrar)
3081+
assert result == body
3082+
3083+
def test_substitute_core_template_no_op_when_core_missing(self, project_dir):
3084+
"""Returns body unchanged when core template file does not exist."""
3085+
from specify_cli.presets import _substitute_core_template
3086+
from specify_cli.agents import CommandRegistrar
3087+
3088+
registrar = CommandRegistrar()
3089+
body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n"
3090+
result = _substitute_core_template(body, "nonexistent", project_dir, registrar)
3091+
assert result == body
3092+
assert "{CORE_TEMPLATE}" in result
3093+
3094+
def test_register_commands_substitutes_core_template_for_wrap_strategy(self, project_dir):
3095+
"""register_commands substitutes {CORE_TEMPLATE} when strategy: wrap."""
3096+
from specify_cli.agents import CommandRegistrar
3097+
3098+
# Set up core command template
3099+
core_dir = project_dir / ".specify" / "templates" / "commands"
3100+
core_dir.mkdir(parents=True, exist_ok=True)
3101+
(core_dir / "specify.md").write_text(
3102+
"---\ndescription: core\n---\n\n# Core Specify\n\nCore body here.\n"
3103+
)
3104+
3105+
# Create a preset command dir with a wrap-strategy command
3106+
cmd_dir = project_dir / "preset" / "commands"
3107+
cmd_dir.mkdir(parents=True, exist_ok=True)
3108+
(cmd_dir / "speckit.specify.md").write_text(
3109+
"---\ndescription: wrap test\nstrategy: wrap\n---\n\n"
3110+
"## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
3111+
)
3112+
3113+
commands = [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}]
3114+
registrar = CommandRegistrar()
3115+
3116+
# Use a generic agent that writes markdown to commands/
3117+
agent_dir = project_dir / ".claude" / "commands"
3118+
agent_dir.mkdir(parents=True, exist_ok=True)
3119+
3120+
# Patch AGENT_CONFIGS to use a simple markdown agent pointing at our dir
3121+
import copy
3122+
original = copy.deepcopy(registrar.AGENT_CONFIGS)
3123+
registrar.AGENT_CONFIGS["test-agent"] = {
3124+
"dir": str(agent_dir.relative_to(project_dir)),
3125+
"format": "markdown",
3126+
"args": "$ARGUMENTS",
3127+
"extension": ".md",
3128+
"strip_frontmatter_keys": [],
3129+
}
3130+
try:
3131+
registrar.register_commands(
3132+
"test-agent", commands, "test-preset",
3133+
project_dir / "preset", project_dir
3134+
)
3135+
finally:
3136+
registrar.AGENT_CONFIGS = original
3137+
3138+
written = (agent_dir / "speckit.specify.md").read_text()
3139+
assert "{CORE_TEMPLATE}" not in written
3140+
assert "# Core Specify" in written
3141+
assert "## Pre" in written
3142+
assert "## Post" in written
3143+
3144+
def test_end_to_end_wrap_via_self_test_preset(self, project_dir):
3145+
"""Installing self-test preset with a wrap command substitutes {CORE_TEMPLATE}."""
3146+
from specify_cli.presets import PresetManager
3147+
3148+
# Install a core template that wrap-test will wrap around
3149+
core_dir = project_dir / ".specify" / "templates" / "commands"
3150+
core_dir.mkdir(parents=True, exist_ok=True)
3151+
(core_dir / "wrap-test.md").write_text(
3152+
"---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n"
3153+
)
3154+
3155+
# Set up skills dir (simulating --ai claude)
3156+
skills_dir = project_dir / ".claude" / "skills"
3157+
skills_dir.mkdir(parents=True, exist_ok=True)
3158+
skill_subdir = skills_dir / "speckit-wrap-test"
3159+
skill_subdir.mkdir()
3160+
(skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold content\n")
3161+
3162+
# Write init-options so _register_skills finds the claude skills dir
3163+
import json
3164+
(project_dir / ".specify" / "init-options.json").write_text(
3165+
json.dumps({"ai": "claude", "ai_skills": True})
3166+
)
3167+
3168+
manager = PresetManager(project_dir)
3169+
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
3170+
3171+
written = (skill_subdir / "SKILL.md").read_text()
3172+
assert "{CORE_TEMPLATE}" not in written
3173+
assert "# Core Wrap-Test Body" in written
3174+
assert "preset:self-test wrap-pre" in written
3175+
assert "preset:self-test wrap-post" in written

0 commit comments

Comments
 (0)