@@ -3043,6 +3043,7 @@ def test_bundled_preset_missing_locally_cli_error(self, project_dir):
30433043 assert result .exit_code == 1
30443044 output = strip_ansi (result .output ).lower ()
30453045 assert "bundled" in output , result .output
3046+ assert "reinstall" in output , result .output
30463047
30473048
30483049class TestWrapStrategy :
@@ -3062,12 +3063,13 @@ def test_substitute_core_template_replaces_placeholder(self, project_dir):
30623063
30633064 registrar = CommandRegistrar ()
30643065 body = "## Pre-Logic\n \n Before stuff.\n \n {CORE_TEMPLATE}\n \n ## Post-Logic\n \n After stuff.\n "
3065- result = _substitute_core_template (body , "specify" , project_dir , registrar )
3066+ result , core_fm = _substitute_core_template (body , "specify" , project_dir , registrar )
30663067
30673068 assert "{CORE_TEMPLATE}" not in result
30683069 assert "# Core Specify" in result
30693070 assert "## Pre-Logic" in result
30703071 assert "## Post-Logic" in result
3072+ assert core_fm .get ("description" ) == "core"
30713073
30723074 def test_substitute_core_template_no_op_when_placeholder_absent (self , project_dir ):
30733075 """Returns body unchanged when {CORE_TEMPLATE} is not present."""
@@ -3080,8 +3082,9 @@ def test_substitute_core_template_no_op_when_placeholder_absent(self, project_di
30803082
30813083 registrar = CommandRegistrar ()
30823084 body = "## No placeholder here.\n "
3083- result = _substitute_core_template (body , "specify" , project_dir , registrar )
3085+ result , core_fm = _substitute_core_template (body , "specify" , project_dir , registrar )
30843086 assert result == body
3087+ assert core_fm == {}
30853088
30863089 def test_substitute_core_template_no_op_when_core_missing (self , project_dir ):
30873090 """Returns body unchanged when core template file does not exist."""
@@ -3090,9 +3093,10 @@ def test_substitute_core_template_no_op_when_core_missing(self, project_dir):
30903093
30913094 registrar = CommandRegistrar ()
30923095 body = "Pre.\n \n {CORE_TEMPLATE}\n \n Post.\n "
3093- result = _substitute_core_template (body , "nonexistent" , project_dir , registrar )
3096+ result , core_fm = _substitute_core_template (body , "nonexistent" , project_dir , registrar )
30943097 assert result == body
30953098 assert "{CORE_TEMPLATE}" in result
3099+ assert core_fm == {}
30963100
30973101 def test_register_commands_substitutes_core_template_for_wrap_strategy (self , project_dir ):
30983102 """register_commands substitutes {CORE_TEMPLATE} when strategy: wrap."""
@@ -3136,7 +3140,8 @@ def test_register_commands_substitutes_core_template_for_wrap_strategy(self, pro
31363140 project_dir / "preset" , project_dir
31373141 )
31383142 finally :
3139- registrar .AGENT_CONFIGS = original
3143+ CommandRegistrar .AGENT_CONFIGS .clear ()
3144+ CommandRegistrar .AGENT_CONFIGS .update (original )
31403145
31413146 written = (agent_dir / "speckit.specify.md" ).read_text ()
31423147 assert "{CORE_TEMPLATE}" not in written
@@ -3176,3 +3181,207 @@ def test_end_to_end_wrap_via_self_test_preset(self, project_dir):
31763181 assert "# Core Wrap-Test Body" in written
31773182 assert "preset:self-test wrap-pre" in written
31783183 assert "preset:self-test wrap-post" in written
3184+
3185+ def test_substitute_core_template_returns_core_scripts (self , project_dir ):
3186+ """core_frontmatter in the returned tuple includes scripts/agent_scripts."""
3187+ from specify_cli .presets import _substitute_core_template
3188+ from specify_cli .agents import CommandRegistrar
3189+
3190+ core_dir = project_dir / ".specify" / "templates" / "commands"
3191+ core_dir .mkdir (parents = True , exist_ok = True )
3192+ (core_dir / "specify.md" ).write_text (
3193+ "---\n description: core\n scripts:\n sh: run.sh\n agent_scripts:\n sh: agent-run.sh\n ---\n \n # Body\n "
3194+ )
3195+
3196+ registrar = CommandRegistrar ()
3197+ body = "## Wrapper\n \n {CORE_TEMPLATE}\n "
3198+ result , core_fm = _substitute_core_template (body , "specify" , project_dir , registrar )
3199+
3200+ assert "# Body" in result
3201+ assert core_fm .get ("scripts" ) == {"sh" : "run.sh" }
3202+ assert core_fm .get ("agent_scripts" ) == {"sh" : "agent-run.sh" }
3203+
3204+ def test_register_skills_inherits_scripts_from_core_when_preset_omits_them (self , project_dir ):
3205+ """_register_skills merges scripts/agent_scripts from core when preset lacks them."""
3206+ from specify_cli .presets import PresetManager
3207+ import json
3208+
3209+ # Core template with scripts
3210+ core_dir = project_dir / ".specify" / "templates" / "commands"
3211+ core_dir .mkdir (parents = True , exist_ok = True )
3212+ (core_dir / "wrap-test.md" ).write_text (
3213+ "---\n description: core\n scripts:\n sh: .specify/scripts/run.sh\n ---\n \n "
3214+ "Run: {SCRIPT}\n "
3215+ )
3216+
3217+ # Skills dir for claude
3218+ skills_dir = project_dir / ".claude" / "skills"
3219+ skills_dir .mkdir (parents = True , exist_ok = True )
3220+ skill_subdir = skills_dir / "speckit-wrap-test"
3221+ skill_subdir .mkdir ()
3222+ (skill_subdir / "SKILL.md" ).write_text ("---\n name: speckit-wrap-test\n ---\n \n old\n " )
3223+
3224+ (project_dir / ".specify" / "init-options.json" ).write_text (
3225+ json .dumps ({"ai" : "claude" , "ai_skills" : True })
3226+ )
3227+
3228+ manager = PresetManager (project_dir )
3229+ manager .install_from_directory (SELF_TEST_PRESET_DIR , "0.1.5" )
3230+
3231+ written = (skill_subdir / "SKILL.md" ).read_text ()
3232+ # {SCRIPT} should have been resolved (not left as a literal placeholder)
3233+ assert "{SCRIPT}" not in written
3234+
3235+ def test_register_skills_preset_scripts_take_precedence_over_core (self , project_dir ):
3236+ """preset-defined scripts/agent_scripts are not overwritten by core frontmatter."""
3237+ from specify_cli .presets import _substitute_core_template
3238+ from specify_cli .agents import CommandRegistrar
3239+
3240+ core_dir = project_dir / ".specify" / "templates" / "commands"
3241+ core_dir .mkdir (parents = True , exist_ok = True )
3242+ (core_dir / "specify.md" ).write_text (
3243+ "---\n description: core\n scripts:\n sh: core-run.sh\n ---\n \n Core body.\n "
3244+ )
3245+
3246+ registrar = CommandRegistrar ()
3247+ body = "{CORE_TEMPLATE}"
3248+ _ , core_fm = _substitute_core_template (body , "specify" , project_dir , registrar )
3249+
3250+ # Simulate preset frontmatter that already defines scripts
3251+ preset_fm = {"description" : "preset" , "strategy" : "wrap" , "scripts" : {"sh" : "preset-run.sh" }}
3252+ for key in ("scripts" , "agent_scripts" ):
3253+ if key not in preset_fm and key in core_fm :
3254+ preset_fm [key ] = core_fm [key ]
3255+
3256+ # Preset's scripts must not be overwritten by core
3257+ assert preset_fm ["scripts" ] == {"sh" : "preset-run.sh" }
3258+
3259+ def test_register_commands_inherits_scripts_from_core (self , project_dir ):
3260+ """register_commands merges scripts/agent_scripts from core and normalizes paths."""
3261+ from specify_cli .agents import CommandRegistrar
3262+ import copy
3263+
3264+ core_dir = project_dir / ".specify" / "templates" / "commands"
3265+ core_dir .mkdir (parents = True , exist_ok = True )
3266+ (core_dir / "specify.md" ).write_text (
3267+ "---\n description: core\n scripts:\n sh: .specify/scripts/run.sh {ARGS}\n ---\n \n "
3268+ "Run: {SCRIPT}\n "
3269+ )
3270+
3271+ cmd_dir = project_dir / "preset" / "commands"
3272+ cmd_dir .mkdir (parents = True , exist_ok = True )
3273+ # Preset has strategy: wrap but no scripts of its own
3274+ (cmd_dir / "speckit.specify.md" ).write_text (
3275+ "---\n description: wrap no scripts\n strategy: wrap\n ---\n \n "
3276+ "## Pre\n \n {CORE_TEMPLATE}\n \n ## Post\n "
3277+ )
3278+
3279+ agent_dir = project_dir / ".claude" / "commands"
3280+ agent_dir .mkdir (parents = True , exist_ok = True )
3281+
3282+ registrar = CommandRegistrar ()
3283+ original = copy .deepcopy (registrar .AGENT_CONFIGS )
3284+ registrar .AGENT_CONFIGS ["test-agent" ] = {
3285+ "dir" : str (agent_dir .relative_to (project_dir )),
3286+ "format" : "markdown" ,
3287+ "args" : "$ARGUMENTS" ,
3288+ "extension" : ".md" ,
3289+ "strip_frontmatter_keys" : [],
3290+ }
3291+ try :
3292+ registrar .register_commands (
3293+ "test-agent" ,
3294+ [{"name" : "speckit.specify" , "file" : "commands/speckit.specify.md" }],
3295+ "test-preset" ,
3296+ project_dir / "preset" ,
3297+ project_dir ,
3298+ )
3299+ finally :
3300+ CommandRegistrar .AGENT_CONFIGS .clear ()
3301+ CommandRegistrar .AGENT_CONFIGS .update (original )
3302+
3303+ written = (agent_dir / "speckit.specify.md" ).read_text ()
3304+ assert "{CORE_TEMPLATE}" not in written
3305+ assert "Run:" in written
3306+ assert "scripts:" in written
3307+ assert "run.sh" in written
3308+
3309+ def test_register_commands_toml_resolves_inherited_scripts (self , project_dir ):
3310+ """TOML agents resolve {SCRIPT} from inherited core scripts when preset omits them."""
3311+ from specify_cli .agents import CommandRegistrar
3312+ import copy
3313+
3314+ core_dir = project_dir / ".specify" / "templates" / "commands"
3315+ core_dir .mkdir (parents = True , exist_ok = True )
3316+ (core_dir / "specify.md" ).write_text (
3317+ "---\n description: core\n scripts:\n sh: .specify/scripts/run.sh {ARGS}\n ---\n \n "
3318+ "Run: {SCRIPT}\n "
3319+ )
3320+
3321+ cmd_dir = project_dir / "preset" / "commands"
3322+ cmd_dir .mkdir (parents = True , exist_ok = True )
3323+ (cmd_dir / "speckit.specify.md" ).write_text (
3324+ "---\n description: toml wrap\n strategy: wrap\n ---\n \n "
3325+ "## Pre\n \n {CORE_TEMPLATE}\n \n ## Post\n "
3326+ )
3327+
3328+ toml_dir = project_dir / ".gemini" / "commands"
3329+ toml_dir .mkdir (parents = True , exist_ok = True )
3330+
3331+ registrar = CommandRegistrar ()
3332+ original = copy .deepcopy (registrar .AGENT_CONFIGS )
3333+ registrar .AGENT_CONFIGS ["test-toml-agent" ] = {
3334+ "dir" : str (toml_dir .relative_to (project_dir )),
3335+ "format" : "toml" ,
3336+ "args" : "{{args}}" ,
3337+ "extension" : ".toml" ,
3338+ "strip_frontmatter_keys" : [],
3339+ }
3340+ try :
3341+ registrar .register_commands (
3342+ "test-toml-agent" ,
3343+ [{"name" : "speckit.specify" , "file" : "commands/speckit.specify.md" }],
3344+ "test-preset" ,
3345+ project_dir / "preset" ,
3346+ project_dir ,
3347+ )
3348+ finally :
3349+ CommandRegistrar .AGENT_CONFIGS .clear ()
3350+ CommandRegistrar .AGENT_CONFIGS .update (original )
3351+
3352+ written = (toml_dir / "speckit.specify.toml" ).read_text ()
3353+ assert "{CORE_TEMPLATE}" not in written
3354+ assert "{SCRIPT}" not in written
3355+ assert "run.sh" in written
3356+ # args token must use TOML format, not the intermediate $ARGUMENTS
3357+ assert "$ARGUMENTS" not in written
3358+ assert "{{args}}" in written
3359+
3360+ def test_extension_command_resolves_via_extension_directory (self , project_dir ):
3361+ """Extension commands (e.g. speckit.git.feature) resolve from the extension directory.
3362+
3363+ Both _register_skills and register_commands pass the full cmd_name to
3364+ _substitute_core_template, which tries the full name first via PresetResolver
3365+ and finds speckit.git.feature.md in the extension commands directory.
3366+ """
3367+ from specify_cli .presets import _substitute_core_template
3368+ from specify_cli .agents import CommandRegistrar
3369+
3370+ # Place the template where a real extension would install it
3371+ ext_cmd_dir = project_dir / ".specify" / "extensions" / "git" / "commands"
3372+ ext_cmd_dir .mkdir (parents = True , exist_ok = True )
3373+ (ext_cmd_dir / "speckit.git.feature.md" ).write_text (
3374+ "---\n description: git feature core\n ---\n \n # Git Feature Core\n "
3375+ )
3376+ # Ensure a hyphenated or dot-separated fallback does NOT exist
3377+ assert not (project_dir / ".specify" / "templates" / "commands" / "git.feature.md" ).exists ()
3378+ assert not (project_dir / ".specify" / "templates" / "commands" / "git-feature.md" ).exists ()
3379+
3380+ registrar = CommandRegistrar ()
3381+ body = "## Wrapper\n \n {CORE_TEMPLATE}\n "
3382+
3383+ # Both call sites now pass the full cmd_name
3384+ result , _ = _substitute_core_template (body , "speckit.git.feature" , project_dir , registrar )
3385+
3386+ assert "# Git Feature Core" in result
3387+ assert "{CORE_TEMPLATE}" not in result
0 commit comments