Skip to content

Commit ca46095

Browse files
committed
fix: review comments
1 parent 443288a commit ca46095

File tree

2 files changed

+138
-48
lines changed

2 files changed

+138
-48
lines changed

src/specify_cli/__init__.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,6 +1112,9 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
11121112
"""
11131113

11141114
skill_file = skill_dir / "SKILL.md"
1115+
if skill_file.exists():
1116+
# Do not overwrite user-customized skills on re-runs
1117+
continue
11151118
skill_file.write_text(skill_content, encoding="utf-8")
11161119
installed_count += 1
11171120

@@ -1331,25 +1334,27 @@ def init(
13311334

13321335
download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token)
13331336

1334-
# When --ai-skills is used on a NEW project, remove the command
1335-
# files that the template archive just created. Skills replace
1336-
# commands, so keeping both would be confusing. For --here on an
1337-
# existing repo we leave pre-existing commands untouched to avoid
1338-
# a breaking change.
1339-
if ai_skills and not here:
1340-
agent_cfg = AGENT_CONFIG.get(selected_ai, {})
1341-
agent_folder = agent_cfg.get("folder", "")
1342-
if agent_folder:
1343-
cmds_dir = project_path / agent_folder.rstrip("/") / "commands"
1344-
if cmds_dir.exists():
1345-
shutil.rmtree(cmds_dir)
1346-
13471337
ensure_executable_scripts(project_path, tracker=tracker)
13481338

13491339
ensure_constitution_from_template(project_path, tracker=tracker)
13501340

13511341
if ai_skills:
1352-
install_ai_skills(project_path, selected_ai, tracker=tracker)
1342+
skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker)
1343+
1344+
# When --ai-skills is used on a NEW project and skills were
1345+
# successfully installed, remove the command files that the
1346+
# template archive just created. Skills replace commands, so
1347+
# keeping both would be confusing. For --here on an existing
1348+
# repo we leave pre-existing commands untouched to avoid a
1349+
# breaking change. We only delete AFTER skills succeed so the
1350+
# project always has at least one of {commands, skills}.
1351+
if skills_ok and not here:
1352+
agent_cfg = AGENT_CONFIG.get(selected_ai, {})
1353+
agent_folder = agent_cfg.get("folder", "")
1354+
if agent_folder:
1355+
cmds_dir = project_path / agent_folder.rstrip("/") / "commands"
1356+
if cmds_dir.exists():
1357+
shutil.rmtree(cmds_dir)
13531358

13541359
if not no_git:
13551360
tracker.start("git")

tests/test_ai_skills.py

Lines changed: 119 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -482,60 +482,145 @@ def test_no_commands_dir_no_error(self, project_dir, templates_dir):
482482
class 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\nUser-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

541626
class TestSkillDescriptions:

0 commit comments

Comments
 (0)