1111"""
1212
1313import re
14+ import zipfile
1415import pytest
1516import tempfile
1617import shutil
1718import yaml
19+ import typer
1820from pathlib import Path
1921from unittest .mock import patch
2022
@@ -720,8 +722,8 @@ def fake_download(project_path, *args, **kwargs):
720722 mock_skills .assert_not_called ()
721723 assert (target / ".agents" / "skills" / "speckit-specify" / "SKILL.md" ).exists ()
722724
723- def test_codex_native_skills_missing_fails_clearly (self , tmp_path ):
724- """Codex native skills init should fail if bundled skills are missing."""
725+ def test_codex_native_skills_missing_falls_back_then_fails_cleanly (self , tmp_path ):
726+ """Codex should attempt fallback conversion when bundled skills are missing."""
725727 from typer .testing import CliRunner
726728
727729 runner = CliRunner ()
@@ -730,7 +732,7 @@ def test_codex_native_skills_missing_fails_clearly(self, tmp_path):
730732 with patch ("specify_cli.download_and_extract_template" , lambda * args , ** kwargs : None ), \
731733 patch ("specify_cli.ensure_executable_scripts" ), \
732734 patch ("specify_cli.ensure_constitution_from_template" ), \
733- patch ("specify_cli.install_ai_skills" ) as mock_skills , \
735+ patch ("specify_cli.install_ai_skills" , return_value = False ) as mock_skills , \
734736 patch ("specify_cli.is_git_repo" , return_value = False ), \
735737 patch ("specify_cli.shutil.which" , return_value = "/usr/bin/codex" ):
736738 result = runner .invoke (
@@ -739,11 +741,13 @@ def test_codex_native_skills_missing_fails_clearly(self, tmp_path):
739741 )
740742
741743 assert result .exit_code == 1
742- mock_skills .assert_not_called ()
744+ mock_skills .assert_called_once ()
745+ assert mock_skills .call_args .kwargs .get ("overwrite_existing" ) is True
743746 assert "Expected bundled agent skills" in result .output
747+ assert "fallback conversion failed" in result .output
744748
745749 def test_codex_native_skills_ignores_non_speckit_skill_dirs (self , tmp_path ):
746- """Non-spec-kit SKILL.md files should not satisfy Codex bundled-skills validation ."""
750+ """Non-spec-kit SKILL.md files should trigger fallback conversion, not hard-fail ."""
747751 from typer .testing import CliRunner
748752
749753 runner = CliRunner ()
@@ -757,17 +761,108 @@ def fake_download(project_path, *args, **kwargs):
757761 with patch ("specify_cli.download_and_extract_template" , side_effect = fake_download ), \
758762 patch ("specify_cli.ensure_executable_scripts" ), \
759763 patch ("specify_cli.ensure_constitution_from_template" ), \
760- patch ("specify_cli.install_ai_skills" ) as mock_skills , \
764+ patch ("specify_cli.install_ai_skills" , return_value = True ) as mock_skills , \
761765 patch ("specify_cli.is_git_repo" , return_value = False ), \
762766 patch ("specify_cli.shutil.which" , return_value = "/usr/bin/codex" ):
763767 result = runner .invoke (
764768 app ,
765769 ["init" , str (target ), "--ai" , "codex" , "--ai-skills" , "--script" , "sh" , "--no-git" ],
766770 )
767771
768- assert result .exit_code == 1
769- mock_skills .assert_not_called ()
770- assert "Expected bundled agent skills" in result .output
772+ assert result .exit_code == 0
773+ mock_skills .assert_called_once ()
774+ assert mock_skills .call_args .kwargs .get ("overwrite_existing" ) is True
775+
776+ def test_codex_ai_skills_here_mode_preserves_existing_codex_dir (self , tmp_path , monkeypatch ):
777+ """Codex --here skills init should not delete a pre-existing .codex directory."""
778+ from typer .testing import CliRunner
779+
780+ runner = CliRunner ()
781+ target = tmp_path / "codex-preserve-here"
782+ target .mkdir ()
783+ existing_prompts = target / ".codex" / "prompts"
784+ existing_prompts .mkdir (parents = True )
785+ (existing_prompts / "custom.md" ).write_text ("custom" )
786+ monkeypatch .chdir (target )
787+
788+ with patch ("specify_cli.download_and_extract_template" , return_value = target ), \
789+ patch ("specify_cli.ensure_executable_scripts" ), \
790+ patch ("specify_cli.ensure_constitution_from_template" ), \
791+ patch ("specify_cli.install_ai_skills" , return_value = True ), \
792+ patch ("specify_cli.is_git_repo" , return_value = True ), \
793+ patch ("specify_cli.shutil.which" , return_value = "/usr/bin/codex" ):
794+ result = runner .invoke (
795+ app ,
796+ ["init" , "--here" , "--ai" , "codex" , "--ai-skills" , "--script" , "sh" , "--no-git" ],
797+ input = "y\n " ,
798+ )
799+
800+ assert result .exit_code == 0
801+ assert (target / ".codex" ).exists ()
802+ assert (existing_prompts / "custom.md" ).exists ()
803+
804+ def test_codex_ai_skills_fresh_dir_does_not_create_codex_dir (self , tmp_path ):
805+ """Fresh-directory Codex skills init should not leave legacy .codex from archive."""
806+ target = tmp_path / "fresh-codex-proj"
807+ archive = tmp_path / "codex-template.zip"
808+
809+ with zipfile .ZipFile (archive , "w" ) as zf :
810+ zf .writestr ("template-root/.codex/prompts/speckit.specify.md" , "legacy" )
811+ zf .writestr ("template-root/.specify/templates/constitution-template.md" , "constitution" )
812+
813+ fake_meta = {
814+ "filename" : archive .name ,
815+ "size" : archive .stat ().st_size ,
816+ "release" : "vtest" ,
817+ "asset_url" : "https://example.invalid/template.zip" ,
818+ }
819+
820+ with patch ("specify_cli.download_template_from_github" , return_value = (archive , fake_meta )):
821+ specify_cli .download_and_extract_template (
822+ target ,
823+ "codex" ,
824+ "sh" ,
825+ is_current_dir = False ,
826+ skip_legacy_codex_prompts = True ,
827+ verbose = False ,
828+ )
829+
830+ assert target .exists ()
831+ assert (target / ".specify" ).exists ()
832+ assert not (target / ".codex" ).exists ()
833+
834+ @pytest .mark .parametrize ("is_current_dir" , [False , True ])
835+ def test_download_and_extract_template_blocks_zip_path_traversal (self , tmp_path , monkeypatch , is_current_dir ):
836+ """Extraction should reject ZIP members escaping the target directory."""
837+ target = (tmp_path / "here-proj" ) if is_current_dir else (tmp_path / "new-proj" )
838+ if is_current_dir :
839+ target .mkdir ()
840+ monkeypatch .chdir (target )
841+
842+ archive = tmp_path / "malicious-template.zip"
843+ with zipfile .ZipFile (archive , "w" ) as zf :
844+ zf .writestr ("../evil.txt" , "pwned" )
845+ zf .writestr ("template-root/.specify/templates/constitution-template.md" , "constitution" )
846+
847+ fake_meta = {
848+ "filename" : archive .name ,
849+ "size" : archive .stat ().st_size ,
850+ "release" : "vtest" ,
851+ "asset_url" : "https://example.invalid/template.zip" ,
852+ }
853+
854+ with patch ("specify_cli.download_template_from_github" , return_value = (archive , fake_meta )):
855+ with pytest .raises (typer .Exit ):
856+ specify_cli .download_and_extract_template (
857+ target ,
858+ "codex" ,
859+ "sh" ,
860+ is_current_dir = is_current_dir ,
861+ skip_legacy_codex_prompts = True ,
862+ verbose = False ,
863+ )
864+
865+ assert not (tmp_path / "evil.txt" ).exists ()
771866
772867 def test_commands_preserved_when_skills_fail (self , tmp_path ):
773868 """If skills fail, commands should NOT be removed (safety net)."""
@@ -859,6 +954,21 @@ def test_fresh_install_writes_all_skills(self, project_dir, templates_dir):
859954 # All 4 templates should produce skills (specify, plan, tasks, empty_fm)
860955 assert len (skill_dirs ) == 4
861956
957+ def test_existing_skill_overwritten_when_enabled (self , project_dir , templates_dir ):
958+ """When overwrite_existing=True, pre-existing SKILL.md should be replaced."""
959+ skill_dir = project_dir / ".claude" / "skills" / "speckit-specify"
960+ skill_dir .mkdir (parents = True )
961+ custom_content = "# My Custom Specify Skill\n User-modified content\n "
962+ skill_file = skill_dir / "SKILL.md"
963+ skill_file .write_text (custom_content )
964+
965+ result = install_ai_skills (project_dir , "claude" , overwrite_existing = True )
966+
967+ assert result is True
968+ updated_content = skill_file .read_text ()
969+ assert updated_content != custom_content
970+ assert "name: speckit-specify" in updated_content
971+
862972
863973# ===== SKILL_DESCRIPTIONS Coverage Tests =====
864974
0 commit comments