@@ -482,60 +482,145 @@ def test_no_commands_dir_no_error(self, project_dir, templates_dir):
482482class TestNewProjectCommandSkip :
483483 """Test that init() removes extracted commands for new projects only.
484484
485- The init() function removes the freshly-extracted commands directory
486- when --ai-skills is used and the project is NEW (not --here).
487- For --here on existing repos, commands are left untouched.
485+ These tests run init() end-to-end via CliRunner with
486+ download_and_extract_template patched to create local fixtures.
488487 """
489488
490- def test_new_project_commands_dir_removed (self , project_dir ):
491- """For new projects, the extracted commands directory should be removed."""
492- import shutil as _shutil
489+ def _fake_extract (self , agent , project_path , ** _kwargs ):
490+ """Simulate template extraction: create agent commands dir."""
491+ agent_cfg = AGENT_CONFIG .get (agent , {})
492+ agent_folder = agent_cfg .get ("folder" , "" )
493+ if agent_folder :
494+ cmds_dir = project_path / agent_folder .rstrip ("/" ) / "commands"
495+ cmds_dir .mkdir (parents = True , exist_ok = True )
496+ (cmds_dir / "speckit.specify.md" ).write_text ("# spec" )
497+
498+ def test_new_project_commands_removed_after_skills_succeed (self , tmp_path ):
499+ """For new projects, commands should be removed when skills succeed."""
500+ from typer .testing import CliRunner
493501
494- # Simulate what init() does: after extraction, if ai_skills and not here
495- agent_folder = AGENT_CONFIG [ "claude" ][ "folder" ]
496- cmds_dir = project_dir / agent_folder . rstrip ( "/" ) / "commands"
497- cmds_dir . mkdir ( parents = True )
498- ( cmds_dir / "speckit.specify.md" ). write_text ( "# spec" )
502+ runner = CliRunner ()
503+ target = tmp_path / "new-proj"
504+
505+ def fake_download ( project_path , * args , ** kwargs ):
506+ self . _fake_extract ( "claude" , project_path )
499507
500- ai_skills = True
501- here = False
508+ with patch ("specify_cli.download_and_extract_template" , side_effect = fake_download ), \
509+ patch ("specify_cli.ensure_executable_scripts" ), \
510+ patch ("specify_cli.ensure_constitution_from_template" ), \
511+ patch ("specify_cli.install_ai_skills" , return_value = True ) as mock_skills , \
512+ patch ("specify_cli.is_git_repo" , return_value = False ), \
513+ patch ("specify_cli.shutil.which" , return_value = "/usr/bin/git" ):
514+ result = runner .invoke (app , ["init" , str (target ), "--ai" , "claude" , "--ai-skills" , "--script" , "sh" , "--no-git" ])
502515
503- # Replicate the init() logic
504- if ai_skills and not here :
505- agent_cfg = AGENT_CONFIG .get ("claude" , {})
506- af = agent_cfg .get ("folder" , "" )
507- if af :
508- d = project_dir / af .rstrip ("/" ) / "commands"
509- if d .exists ():
510- _shutil .rmtree (d )
516+ # Skills should have been called
517+ mock_skills .assert_called_once ()
511518
519+ # Commands dir should have been removed after skills succeeded
520+ cmds_dir = target / ".claude" / "commands"
512521 assert not cmds_dir .exists ()
513522
514- def test_here_mode_commands_preserved (self , project_dir ):
523+ def test_commands_preserved_when_skills_fail (self , tmp_path ):
524+ """If skills fail, commands should NOT be removed (safety net)."""
525+ from typer .testing import CliRunner
526+
527+ runner = CliRunner ()
528+ target = tmp_path / "fail-proj"
529+
530+ def fake_download (project_path , * args , ** kwargs ):
531+ self ._fake_extract ("claude" , project_path )
532+
533+ with patch ("specify_cli.download_and_extract_template" , side_effect = fake_download ), \
534+ patch ("specify_cli.ensure_executable_scripts" ), \
535+ patch ("specify_cli.ensure_constitution_from_template" ), \
536+ patch ("specify_cli.install_ai_skills" , return_value = False ), \
537+ patch ("specify_cli.is_git_repo" , return_value = False ), \
538+ patch ("specify_cli.shutil.which" , return_value = "/usr/bin/git" ):
539+ result = runner .invoke (app , ["init" , str (target ), "--ai" , "claude" , "--ai-skills" , "--script" , "sh" , "--no-git" ])
540+
541+ # Commands should still exist since skills failed
542+ cmds_dir = target / ".claude" / "commands"
543+ assert cmds_dir .exists ()
544+ assert (cmds_dir / "speckit.specify.md" ).exists ()
545+
546+ def test_here_mode_commands_preserved (self , tmp_path , monkeypatch ):
515547 """For --here on existing repos, commands must NOT be removed."""
548+ from typer .testing import CliRunner
549+
550+ runner = CliRunner ()
551+ # Create a mock existing project with commands already present
552+ target = tmp_path / "existing"
553+ target .mkdir ()
516554 agent_folder = AGENT_CONFIG ["claude" ]["folder" ]
517- cmds_dir = project_dir / agent_folder .rstrip ("/" ) / "commands"
555+ cmds_dir = target / agent_folder .rstrip ("/" ) / "commands"
518556 cmds_dir .mkdir (parents = True )
519557 (cmds_dir / "speckit.specify.md" ).write_text ("# spec" )
520558
521- ai_skills = True
522- here = True
559+ # --here uses CWD, so chdir into the target
560+ monkeypatch . chdir ( target )
523561
524- # Replicate the init() logic
525- if ai_skills and not here :
526- import shutil as _shutil
527- agent_cfg = AGENT_CONFIG .get ("claude" , {})
528- af = agent_cfg .get ("folder" , "" )
529- if af :
530- d = project_dir / af .rstrip ("/" ) / "commands"
531- if d .exists ():
532- _shutil .rmtree (d )
562+ def fake_download (project_path , * args , ** kwargs ):
563+ pass # commands already exist, no need to re-create
564+
565+ with patch ("specify_cli.download_and_extract_template" , side_effect = fake_download ), \
566+ patch ("specify_cli.ensure_executable_scripts" ), \
567+ patch ("specify_cli.ensure_constitution_from_template" ), \
568+ patch ("specify_cli.install_ai_skills" , return_value = True ), \
569+ patch ("specify_cli.is_git_repo" , return_value = True ), \
570+ patch ("specify_cli.shutil.which" , return_value = "/usr/bin/git" ):
571+ result = runner .invoke (app , ["init" , "--here" , "--ai" , "claude" , "--ai-skills" , "--script" , "sh" , "--no-git" ])
533572
534573 # Commands must remain for --here
535574 assert cmds_dir .exists ()
536575 assert (cmds_dir / "speckit.specify.md" ).exists ()
537576
538577
578+ # ===== Skip-If-Exists Tests =====
579+
580+ class TestSkipIfExists :
581+ """Test that install_ai_skills does not overwrite existing SKILL.md files."""
582+
583+ def test_existing_skill_not_overwritten (self , project_dir , templates_dir ):
584+ """Pre-existing SKILL.md should not be replaced on re-run."""
585+ fake_init = templates_dir .parent .parent / "src" / "specify_cli" / "__init__.py"
586+ fake_init .parent .mkdir (parents = True , exist_ok = True )
587+ fake_init .touch ()
588+
589+ # Pre-create a custom SKILL.md for speckit-specify
590+ skill_dir = project_dir / ".claude" / "skills" / "speckit-specify"
591+ skill_dir .mkdir (parents = True )
592+ custom_content = "# My Custom Specify Skill\n User-modified content\n "
593+ (skill_dir / "SKILL.md" ).write_text (custom_content )
594+
595+ with patch .object (specify_cli , "__file__" , str (fake_init )):
596+ result = install_ai_skills (project_dir , "claude" )
597+
598+ # The custom SKILL.md should be untouched
599+ assert (skill_dir / "SKILL.md" ).read_text () == custom_content
600+
601+ # But other skills should still be installed
602+ assert result is True
603+ assert (project_dir / ".claude" / "skills" / "speckit-plan" / "SKILL.md" ).exists ()
604+ assert (project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md" ).exists ()
605+
606+ def test_fresh_install_writes_all_skills (self , project_dir , templates_dir ):
607+ """On first install (no pre-existing skills), all should be written."""
608+ fake_init = templates_dir .parent .parent / "src" / "specify_cli" / "__init__.py"
609+ fake_init .parent .mkdir (parents = True , exist_ok = True )
610+ fake_init .touch ()
611+
612+ with patch .object (specify_cli , "__file__" , str (fake_init )):
613+ result = install_ai_skills (project_dir , "claude" )
614+
615+ assert result is True
616+ skills_dir = project_dir / ".claude" / "skills"
617+ skill_dirs = [d .name for d in skills_dir .iterdir () if d .is_dir ()]
618+ # All 4 templates should produce skills (specify, plan, tasks, empty_fm)
619+ assert len (skill_dirs ) == 4
620+
621+
622+
623+
539624# ===== SKILL_DESCRIPTIONS Coverage Tests =====
540625
541626class TestSkillDescriptions :
0 commit comments