Skip to content

Commit 3793b45

Browse files
committed
fix(codex): skip legacy prompts and fallback when bundled skills missing
1 parent bf33980 commit 3793b45

File tree

2 files changed

+61
-21
lines changed

2 files changed

+61
-21
lines changed

src/specify_cli/__init__.py

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -948,7 +948,19 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
948948
}
949949
return zip_path, metadata
950950

951-
def download_and_extract_template(project_path: Path, ai_assistant: str, script_type: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Path:
951+
def download_and_extract_template(
952+
project_path: Path,
953+
ai_assistant: str,
954+
script_type: str,
955+
is_current_dir: bool = False,
956+
*,
957+
skip_legacy_codex_prompts: bool = False,
958+
verbose: bool = True,
959+
tracker: StepTracker | None = None,
960+
client: httpx.Client = None,
961+
debug: bool = False,
962+
github_token: str = None,
963+
) -> Path:
952964
"""Download the latest release and extract it to create a new project.
953965
Returns project_path. Uses tracker if provided (with keys: fetch, download, extract, cleanup)
954966
"""
@@ -1019,6 +1031,10 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
10191031
console.print("[cyan]Found nested directory structure[/cyan]")
10201032

10211033
for item in source_dir.iterdir():
1034+
# Codex skills mode should not materialize legacy prompt files
1035+
# from older template archives.
1036+
if skip_legacy_codex_prompts and ai_assistant == "codex" and item.name == ".codex":
1037+
continue
10221038
dest_path = project_path / item.name
10231039
if item.is_dir():
10241040
if dest_path.exists():
@@ -1069,6 +1085,11 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
10691085
elif verbose:
10701086
console.print("[cyan]Flattened nested directory structure[/cyan]")
10711087

1088+
if skip_legacy_codex_prompts and ai_assistant == "codex":
1089+
legacy_codex_dir = project_path / ".codex"
1090+
if legacy_codex_dir.is_dir():
1091+
shutil.rmtree(legacy_codex_dir, ignore_errors=True)
1092+
10721093
except Exception as e:
10731094
if tracker:
10741095
tracker.error("extract", str(e))
@@ -1994,7 +2015,18 @@ def init(
19942015

19952016
if use_github:
19962017
with httpx.Client(verify=local_ssl_context) as local_client:
1997-
download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token)
2018+
download_and_extract_template(
2019+
project_path,
2020+
selected_ai,
2021+
selected_script,
2022+
here,
2023+
skip_legacy_codex_prompts=(selected_ai == "codex" and ai_skills),
2024+
verbose=False,
2025+
tracker=tracker,
2026+
client=local_client,
2027+
debug=debug,
2028+
github_token=github_token,
2029+
)
19982030
else:
19992031
scaffold_ok = scaffold_from_core_pack(project_path, selected_ai, selected_script, here, tracker=tracker)
20002032
if not scaffold_ok:
@@ -2013,7 +2045,6 @@ def init(
20132045
if not here and project_path.exists():
20142046
shutil.rmtree(project_path)
20152047
raise typer.Exit(1)
2016-
20172048
# For generic agent, rename placeholder directory to user-specified path
20182049
if selected_ai == "generic" and ai_commands_dir:
20192050
placeholder_dir = project_path / ".speckit" / "commands"
@@ -2033,16 +2064,25 @@ def init(
20332064
if ai_skills:
20342065
if selected_ai in NATIVE_SKILLS_AGENTS:
20352066
skills_dir = _get_skills_dir(project_path, selected_ai)
2036-
if not _has_bundled_skills(project_path, selected_ai):
2037-
raise RuntimeError(
2038-
f"Expected bundled agent skills in {skills_dir.relative_to(project_path)}, "
2039-
"but none were found. Re-run with an up-to-date template."
2040-
)
2041-
if tracker:
2042-
tracker.start("ai-skills")
2043-
tracker.complete("ai-skills", f"bundled skills → {skills_dir.relative_to(project_path)}")
2067+
bundled_found = _has_bundled_skills(project_path, selected_ai)
2068+
if bundled_found:
2069+
if tracker:
2070+
tracker.start("ai-skills")
2071+
tracker.complete("ai-skills", f"bundled skills → {skills_dir.relative_to(project_path)}")
2072+
else:
2073+
console.print(f"[green]✓[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/")
20442074
else:
2045-
console.print(f"[green]✓[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/")
2075+
# Compatibility fallback: convert command templates to skills
2076+
# when an older template archive does not include native skills.
2077+
# This keeps `specify init --here --ai codex --ai-skills` usable
2078+
# in repos that already contain unrelated skills under .agents/skills.
2079+
fallback_ok = install_ai_skills(project_path, selected_ai, tracker=tracker)
2080+
if not fallback_ok:
2081+
raise RuntimeError(
2082+
f"Expected bundled agent skills in {skills_dir.relative_to(project_path)}, "
2083+
"but none were found and fallback conversion failed. "
2084+
"Re-run with an up-to-date template."
2085+
)
20462086
else:
20472087
skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker)
20482088

tests/test_ai_skills.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -720,8 +720,8 @@ def fake_download(project_path, *args, **kwargs):
720720
mock_skills.assert_not_called()
721721
assert (target / ".agents" / "skills" / "speckit-specify" / "SKILL.md").exists()
722722

723-
def test_codex_native_skills_missing_fails_clearly(self, tmp_path):
724-
"""Codex native skills init should fail if bundled skills are missing."""
723+
def test_codex_native_skills_missing_falls_back_then_fails_cleanly(self, tmp_path):
724+
"""Codex should attempt fallback conversion when bundled skills are missing."""
725725
from typer.testing import CliRunner
726726

727727
runner = CliRunner()
@@ -730,7 +730,7 @@ def test_codex_native_skills_missing_fails_clearly(self, tmp_path):
730730
with patch("specify_cli.download_and_extract_template", lambda *args, **kwargs: None), \
731731
patch("specify_cli.ensure_executable_scripts"), \
732732
patch("specify_cli.ensure_constitution_from_template"), \
733-
patch("specify_cli.install_ai_skills") as mock_skills, \
733+
patch("specify_cli.install_ai_skills", return_value=False) as mock_skills, \
734734
patch("specify_cli.is_git_repo", return_value=False), \
735735
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
736736
result = runner.invoke(
@@ -739,11 +739,12 @@ def test_codex_native_skills_missing_fails_clearly(self, tmp_path):
739739
)
740740

741741
assert result.exit_code == 1
742-
mock_skills.assert_not_called()
742+
mock_skills.assert_called_once()
743743
assert "Expected bundled agent skills" in result.output
744+
assert "fallback conversion failed" in result.output
744745

745746
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."""
747+
"""Non-spec-kit SKILL.md files should trigger fallback conversion, not hard-fail."""
747748
from typer.testing import CliRunner
748749

749750
runner = CliRunner()
@@ -757,17 +758,16 @@ def fake_download(project_path, *args, **kwargs):
757758
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
758759
patch("specify_cli.ensure_executable_scripts"), \
759760
patch("specify_cli.ensure_constitution_from_template"), \
760-
patch("specify_cli.install_ai_skills") as mock_skills, \
761+
patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \
761762
patch("specify_cli.is_git_repo", return_value=False), \
762763
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
763764
result = runner.invoke(
764765
app,
765766
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
766767
)
767768

768-
assert result.exit_code == 1
769-
mock_skills.assert_not_called()
770-
assert "Expected bundled agent skills" in result.output
769+
assert result.exit_code == 0
770+
mock_skills.assert_called_once()
771771

772772
def test_commands_preserved_when_skills_fail(self, tmp_path):
773773
"""If skills fail, commands should NOT be removed (safety net)."""

0 commit comments

Comments
 (0)