mirror of
https://github.com/github/spec-kit.git
synced 2026-03-28 00:03:08 +00:00
Unify Kimi/Codex skill naming and migrate legacy dotted Kimi dirs (#1971)
* fix: unify hyphenated skills and migrate legacy kimi dotted dirs * fix: preserve legacy kimi dotted preset skill overrides * fix: migrate kimi legacy dotted skills without ai-skills flag * fix: harden kimi migration and cache hook init options * fix: apply kimi preset skill overrides without ai-skills flag * fix: keep sequential branch numbering beyond 999 * test: align kimi scaffold skill path with hyphen naming * chore: align hook typing and preset skill comment * fix: restore AGENT_SKILLS_DIR_OVERRIDES compatibility export * refactor: remove AGENT_SKILLS_DIR_OVERRIDES and update callers * fix(ps1): support sequential branch numbers above 999 * fix: resolve preset skill placeholders for skills agents * Fix legacy kimi migration safety and preset skill dir checks * Harden TOML rendering and consolidate preset skill restore parsing * Fix PowerShell overflow and hook message fallback for empty invocations * Restore preset skills from extensions * Refine preset skill restore helpers * Harden skill path and preset checks * Guard non-dict init options * Avoid deleting unmanaged preset skill dirs * Unify extension skill naming with hooks * Harden extension native skill registration * Normalize preset skill titles
This commit is contained in:
@@ -24,8 +24,8 @@ import specify_cli
|
||||
|
||||
from specify_cli import (
|
||||
_get_skills_dir,
|
||||
_migrate_legacy_kimi_dotted_skills,
|
||||
install_ai_skills,
|
||||
AGENT_SKILLS_DIR_OVERRIDES,
|
||||
DEFAULT_SKILLS_DIR,
|
||||
SKILL_DESCRIPTIONS,
|
||||
AGENT_CONFIG,
|
||||
@@ -169,8 +169,8 @@ class TestGetSkillsDir:
|
||||
result = _get_skills_dir(project_dir, "copilot")
|
||||
assert result == project_dir / ".github" / "skills"
|
||||
|
||||
def test_codex_uses_override(self, project_dir):
|
||||
"""Codex should use the AGENT_SKILLS_DIR_OVERRIDES value."""
|
||||
def test_codex_skills_dir_from_agent_config(self, project_dir):
|
||||
"""Codex should resolve skills directory from AGENT_CONFIG folder."""
|
||||
result = _get_skills_dir(project_dir, "codex")
|
||||
assert result == project_dir / ".agents" / "skills"
|
||||
|
||||
@@ -203,12 +203,71 @@ class TestGetSkillsDir:
|
||||
# Should always end with "skills"
|
||||
assert result.name == "skills"
|
||||
|
||||
def test_override_takes_precedence_over_config(self, project_dir):
|
||||
"""AGENT_SKILLS_DIR_OVERRIDES should take precedence over AGENT_CONFIG."""
|
||||
for agent_key in AGENT_SKILLS_DIR_OVERRIDES:
|
||||
result = _get_skills_dir(project_dir, agent_key)
|
||||
expected = project_dir / AGENT_SKILLS_DIR_OVERRIDES[agent_key]
|
||||
assert result == expected
|
||||
class TestKimiLegacySkillMigration:
|
||||
"""Test temporary migration from Kimi dotted skill names to hyphenated names."""
|
||||
|
||||
def test_migrates_legacy_dotted_skill_directory(self, project_dir):
|
||||
skills_dir = project_dir / ".kimi" / "skills"
|
||||
legacy_dir = skills_dir / "speckit.plan"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
(legacy_dir / "SKILL.md").write_text("legacy")
|
||||
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
assert migrated == 1
|
||||
assert removed == 0
|
||||
assert not legacy_dir.exists()
|
||||
assert (skills_dir / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_removes_legacy_dir_when_hyphenated_target_exists_with_same_content(self, project_dir):
|
||||
skills_dir = project_dir / ".kimi" / "skills"
|
||||
legacy_dir = skills_dir / "speckit.plan"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
(legacy_dir / "SKILL.md").write_text("legacy")
|
||||
target_dir = skills_dir / "speckit-plan"
|
||||
target_dir.mkdir(parents=True)
|
||||
(target_dir / "SKILL.md").write_text("legacy")
|
||||
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
assert migrated == 0
|
||||
assert removed == 1
|
||||
assert not legacy_dir.exists()
|
||||
assert (target_dir / "SKILL.md").read_text() == "legacy"
|
||||
|
||||
def test_keeps_legacy_dir_when_hyphenated_target_differs(self, project_dir):
|
||||
skills_dir = project_dir / ".kimi" / "skills"
|
||||
legacy_dir = skills_dir / "speckit.plan"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
(legacy_dir / "SKILL.md").write_text("legacy")
|
||||
target_dir = skills_dir / "speckit-plan"
|
||||
target_dir.mkdir(parents=True)
|
||||
(target_dir / "SKILL.md").write_text("new")
|
||||
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
assert migrated == 0
|
||||
assert removed == 0
|
||||
assert legacy_dir.exists()
|
||||
assert (legacy_dir / "SKILL.md").read_text() == "legacy"
|
||||
assert (target_dir / "SKILL.md").read_text() == "new"
|
||||
|
||||
def test_keeps_legacy_dir_when_matching_target_but_extra_files_exist(self, project_dir):
|
||||
skills_dir = project_dir / ".kimi" / "skills"
|
||||
legacy_dir = skills_dir / "speckit.plan"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
(legacy_dir / "SKILL.md").write_text("legacy")
|
||||
(legacy_dir / "notes.txt").write_text("custom")
|
||||
target_dir = skills_dir / "speckit-plan"
|
||||
target_dir.mkdir(parents=True)
|
||||
(target_dir / "SKILL.md").write_text("legacy")
|
||||
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
assert migrated == 0
|
||||
assert removed == 0
|
||||
assert legacy_dir.exists()
|
||||
assert (legacy_dir / "notes.txt").read_text() == "custom"
|
||||
|
||||
|
||||
# ===== install_ai_skills Tests =====
|
||||
@@ -473,8 +532,7 @@ class TestInstallAiSkills:
|
||||
skills_dir = _get_skills_dir(proj, agent_key)
|
||||
assert skills_dir.exists()
|
||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
||||
# Kimi uses dotted skill names; other agents use hyphen-separated names.
|
||||
expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify"
|
||||
expected_skill_name = "speckit-specify"
|
||||
assert expected_skill_name in skill_dirs
|
||||
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
|
||||
|
||||
@@ -773,6 +831,32 @@ class TestNewProjectCommandSkip:
|
||||
mock_skills.assert_called_once()
|
||||
assert mock_skills.call_args.kwargs.get("overwrite_existing") is True
|
||||
|
||||
def test_kimi_legacy_migration_runs_without_ai_skills_flag(self, tmp_path):
|
||||
"""Kimi init should migrate dotted legacy skills even when --ai-skills is not set."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "kimi-legacy-no-ai-skills"
|
||||
|
||||
def fake_download(project_path, *args, **kwargs):
|
||||
legacy_dir = project_path / ".kimi" / "skills" / "speckit.plan"
|
||||
legacy_dir.mkdir(parents=True, exist_ok=True)
|
||||
(legacy_dir / "SKILL.md").write_text("---\nname: speckit.plan\n---\n\nlegacy\n")
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/kimi"):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--ai", "kimi", "--script", "sh", "--no-git"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert not (target / ".kimi" / "skills" / "speckit.plan").exists()
|
||||
assert (target / ".kimi" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_codex_ai_skills_here_mode_preserves_existing_codex_dir(self, tmp_path, monkeypatch):
|
||||
"""Codex --here skills init should not delete a pre-existing .codex directory."""
|
||||
from typer.testing import CliRunner
|
||||
@@ -1118,12 +1202,12 @@ class TestCliValidation:
|
||||
assert "Optional skills that you can use for your specs" in result.output
|
||||
|
||||
def test_kimi_next_steps_show_skill_invocation(self, monkeypatch):
|
||||
"""Kimi next-steps guidance should display /skill:speckit.* usage."""
|
||||
"""Kimi next-steps guidance should display /skill:speckit-* usage."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
def _fake_download(*args, **kwargs):
|
||||
project_path = Path(args[0])
|
||||
skill_dir = project_path / ".kimi" / "skills" / "speckit.specify"
|
||||
skill_dir = project_path / ".kimi" / "skills" / "speckit-specify"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
|
||||
|
||||
@@ -1137,7 +1221,7 @@ class TestCliValidation:
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "/skill:speckit.constitution" in result.output
|
||||
assert "/skill:speckit-constitution" in result.output
|
||||
assert "/speckit.constitution" not in result.output
|
||||
assert "Optional skills that you can use for your specs" in result.output
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ def _expected_cmd_dir(project_path: Path, agent: str) -> Path:
|
||||
|
||||
# Agents whose commands are laid out as <skills_dir>/<name>/SKILL.md.
|
||||
# Maps agent -> separator used in skill directory names.
|
||||
_SKILL_AGENTS: dict[str, str] = {"codex": "-", "kimi": "."}
|
||||
_SKILL_AGENTS: dict[str, str] = {"codex": "-", "kimi": "-"}
|
||||
|
||||
|
||||
def _expected_ext(agent: str) -> str:
|
||||
|
||||
@@ -41,17 +41,14 @@ def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool
|
||||
def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path:
|
||||
"""Create and return the expected skills directory for the given agent."""
|
||||
# Match the logic in _get_skills_dir() from specify_cli
|
||||
from specify_cli import AGENT_CONFIG, AGENT_SKILLS_DIR_OVERRIDES, DEFAULT_SKILLS_DIR
|
||||
from specify_cli import AGENT_CONFIG, DEFAULT_SKILLS_DIR
|
||||
|
||||
if ai in AGENT_SKILLS_DIR_OVERRIDES:
|
||||
skills_dir = project_root / AGENT_SKILLS_DIR_OVERRIDES[ai]
|
||||
agent_config = AGENT_CONFIG.get(ai, {})
|
||||
agent_folder = agent_config.get("folder", "")
|
||||
if agent_folder:
|
||||
skills_dir = project_root / agent_folder.rstrip("/") / "skills"
|
||||
else:
|
||||
agent_config = AGENT_CONFIG.get(ai, {})
|
||||
agent_folder = agent_config.get("folder", "")
|
||||
if agent_folder:
|
||||
skills_dir = project_root / agent_folder.rstrip("/") / "skills"
|
||||
else:
|
||||
skills_dir = project_root / DEFAULT_SKILLS_DIR
|
||||
skills_dir = project_root / DEFAULT_SKILLS_DIR
|
||||
|
||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
return skills_dir
|
||||
@@ -195,6 +192,24 @@ class TestExtensionManagerGetSkillsDir:
|
||||
result = manager._get_skills_dir()
|
||||
assert result is None
|
||||
|
||||
def test_returns_kimi_skills_dir_when_ai_skills_disabled(self, project_dir):
|
||||
"""Kimi should still use its native skills dir when ai_skills is false."""
|
||||
_create_init_options(project_dir, ai="kimi", ai_skills=False)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="kimi")
|
||||
manager = ExtensionManager(project_dir)
|
||||
result = manager._get_skills_dir()
|
||||
assert result == skills_dir
|
||||
|
||||
def test_returns_none_for_non_dict_init_options(self, project_dir):
|
||||
"""Corrupted-but-parseable init-options should not crash skill-dir lookup."""
|
||||
opts_file = project_dir / ".specify" / "init-options.json"
|
||||
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
opts_file.write_text("[]")
|
||||
_create_skills_dir(project_dir, ai="claude")
|
||||
manager = ExtensionManager(project_dir)
|
||||
result = manager._get_skills_dir()
|
||||
assert result is None
|
||||
|
||||
|
||||
# ===== Extension Skill Registration Tests =====
|
||||
|
||||
@@ -211,8 +226,8 @@ class TestExtensionSkillRegistration:
|
||||
|
||||
# Check that skill directories were created
|
||||
skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()])
|
||||
assert "speckit-test-ext.hello" in skill_dirs
|
||||
assert "speckit-test-ext.world" in skill_dirs
|
||||
assert "speckit-test-ext-hello" in skill_dirs
|
||||
assert "speckit-test-ext-world" in skill_dirs
|
||||
|
||||
def test_skill_md_content_correct(self, skills_project, extension_dir):
|
||||
"""SKILL.md should have correct agentskills.io structure."""
|
||||
@@ -222,13 +237,13 @@ class TestExtensionSkillRegistration:
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
skill_file = skills_dir / "speckit-test-ext.hello" / "SKILL.md"
|
||||
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
|
||||
# Check structure
|
||||
assert content.startswith("---\n")
|
||||
assert "name: speckit-test-ext.hello" in content
|
||||
assert "name: speckit-test-ext-hello" in content
|
||||
assert "description:" in content
|
||||
assert "Test hello command" in content
|
||||
assert "source: extension:test-ext" in content
|
||||
@@ -244,7 +259,7 @@ class TestExtensionSkillRegistration:
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
skill_file = skills_dir / "speckit-test-ext.hello" / "SKILL.md"
|
||||
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
|
||||
assert content.startswith("---\n")
|
||||
@@ -252,7 +267,7 @@ class TestExtensionSkillRegistration:
|
||||
assert len(parts) >= 3
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert isinstance(parsed, dict)
|
||||
assert parsed["name"] == "speckit-test-ext.hello"
|
||||
assert parsed["name"] == "speckit-test-ext-hello"
|
||||
assert "description" in parsed
|
||||
|
||||
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
|
||||
@@ -281,7 +296,7 @@ class TestExtensionSkillRegistration:
|
||||
project_dir, skills_dir = skills_project
|
||||
|
||||
# Pre-create a custom skill
|
||||
custom_dir = skills_dir / "speckit-test-ext.hello"
|
||||
custom_dir = skills_dir / "speckit-test-ext-hello"
|
||||
custom_dir.mkdir(parents=True)
|
||||
custom_content = "# My Custom Hello Skill\nUser-modified content\n"
|
||||
(custom_dir / "SKILL.md").write_text(custom_content)
|
||||
@@ -296,9 +311,9 @@ class TestExtensionSkillRegistration:
|
||||
|
||||
# But the other skill should still be created
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert "speckit-test-ext.world" in metadata["registered_skills"]
|
||||
assert "speckit-test-ext-world" in metadata["registered_skills"]
|
||||
# The pre-existing one should NOT be in registered_skills (it was skipped)
|
||||
assert "speckit-test-ext.hello" not in metadata["registered_skills"]
|
||||
assert "speckit-test-ext-hello" not in metadata["registered_skills"]
|
||||
|
||||
def test_registered_skills_in_registry(self, skills_project, extension_dir):
|
||||
"""Registry should contain registered_skills list."""
|
||||
@@ -311,11 +326,11 @@ class TestExtensionSkillRegistration:
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert "registered_skills" in metadata
|
||||
assert len(metadata["registered_skills"]) == 2
|
||||
assert "speckit-test-ext.hello" in metadata["registered_skills"]
|
||||
assert "speckit-test-ext.world" in metadata["registered_skills"]
|
||||
assert "speckit-test-ext-hello" in metadata["registered_skills"]
|
||||
assert "speckit-test-ext-world" in metadata["registered_skills"]
|
||||
|
||||
def test_kimi_uses_dot_notation(self, project_dir, temp_dir):
|
||||
"""Kimi agent should use dot notation for skill names."""
|
||||
def test_kimi_uses_hyphenated_skill_names(self, project_dir, temp_dir):
|
||||
"""Kimi agent should use the same hyphenated skill names as hooks."""
|
||||
_create_init_options(project_dir, ai="kimi", ai_skills=True)
|
||||
_create_skills_dir(project_dir, ai="kimi")
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
|
||||
@@ -326,9 +341,80 @@ class TestExtensionSkillRegistration:
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
# Kimi should use dots, not hyphens
|
||||
assert "speckit.test-ext.hello" in metadata["registered_skills"]
|
||||
assert "speckit.test-ext.world" in metadata["registered_skills"]
|
||||
assert "speckit-test-ext-hello" in metadata["registered_skills"]
|
||||
assert "speckit-test-ext-world" in metadata["registered_skills"]
|
||||
|
||||
def test_kimi_creates_skills_when_ai_skills_disabled(self, project_dir, temp_dir):
|
||||
"""Kimi should still auto-register extension skills in native-skills mode."""
|
||||
_create_init_options(project_dir, ai="kimi", ai_skills=False)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="kimi")
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert "speckit-test-ext-hello" in metadata["registered_skills"]
|
||||
assert "speckit-test-ext-world" in metadata["registered_skills"]
|
||||
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
||||
|
||||
def test_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir):
|
||||
"""Auto-registered extension skills should resolve script placeholders."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
|
||||
ext_dir = temp_dir / "scripted-ext"
|
||||
ext_dir.mkdir()
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "scripted-ext",
|
||||
"name": "Scripted Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.scripted-ext.plan",
|
||||
"file": "commands/plan.md",
|
||||
"description": "Scripted plan command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands").mkdir()
|
||||
(ext_dir / "commands" / "plan.md").write_text(
|
||||
"---\n"
|
||||
"description: Scripted plan command\n"
|
||||
"scripts:\n"
|
||||
" sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n"
|
||||
"agent_scripts:\n"
|
||||
" sh: ../../scripts/bash/update-agent-context.sh __AGENT__\n"
|
||||
"---\n\n"
|
||||
"Run {SCRIPT}\n"
|
||||
"Then {AGENT_SCRIPT}\n"
|
||||
"Review templates/checklist.md and memory/constitution.md for __AGENT__.\n"
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
content = (skills_dir / "speckit-scripted-ext-plan" / "SKILL.md").read_text()
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "{AGENT_SCRIPT}" not in content
|
||||
assert "{ARGS}" not in content
|
||||
assert "__AGENT__" not in content
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
assert ".specify/scripts/bash/update-agent-context.sh claude" in content
|
||||
assert ".specify/templates/checklist.md" in content
|
||||
assert ".specify/memory/constitution.md" in content
|
||||
|
||||
def test_missing_command_file_skipped(self, skills_project, temp_dir):
|
||||
"""Commands with missing source files should be skipped gracefully."""
|
||||
@@ -375,8 +461,8 @@ class TestExtensionSkillRegistration:
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert "speckit-missing-cmd-ext.exists" in metadata["registered_skills"]
|
||||
assert "speckit-missing-cmd-ext.ghost" not in metadata["registered_skills"]
|
||||
assert "speckit-missing-cmd-ext-exists" in metadata["registered_skills"]
|
||||
assert "speckit-missing-cmd-ext-ghost" not in metadata["registered_skills"]
|
||||
|
||||
|
||||
# ===== Extension Skill Unregistration Tests =====
|
||||
@@ -393,16 +479,16 @@ class TestExtensionSkillUnregistration:
|
||||
)
|
||||
|
||||
# Verify skills exist
|
||||
assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists()
|
||||
assert (skills_dir / "speckit-test-ext.world" / "SKILL.md").exists()
|
||||
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
||||
assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists()
|
||||
|
||||
# Remove extension
|
||||
result = manager.remove(manifest.id, keep_config=False)
|
||||
assert result is True
|
||||
|
||||
# Skills should be gone
|
||||
assert not (skills_dir / "speckit-test-ext.hello").exists()
|
||||
assert not (skills_dir / "speckit-test-ext.world").exists()
|
||||
assert not (skills_dir / "speckit-test-ext-hello").exists()
|
||||
assert not (skills_dir / "speckit-test-ext-world").exists()
|
||||
|
||||
def test_other_skills_preserved_on_remove(self, skills_project, extension_dir):
|
||||
"""Non-extension skills should not be affected by extension removal."""
|
||||
@@ -433,8 +519,8 @@ class TestExtensionSkillUnregistration:
|
||||
)
|
||||
|
||||
# Manually delete skill dirs before calling remove
|
||||
shutil.rmtree(skills_dir / "speckit-test-ext.hello")
|
||||
shutil.rmtree(skills_dir / "speckit-test-ext.world")
|
||||
shutil.rmtree(skills_dir / "speckit-test-ext-hello")
|
||||
shutil.rmtree(skills_dir / "speckit-test-ext-world")
|
||||
|
||||
# Should not raise
|
||||
result = manager.remove(manifest.id, keep_config=False)
|
||||
@@ -457,6 +543,21 @@ class TestExtensionSkillUnregistration:
|
||||
class TestExtensionSkillEdgeCases:
|
||||
"""Test edge cases in extension skill registration."""
|
||||
|
||||
def test_install_with_non_dict_init_options_does_not_crash(self, project_dir, extension_dir):
|
||||
"""Corrupted init-options payloads should disable skill registration, not crash install."""
|
||||
opts_file = project_dir / ".specify" / "init-options.json"
|
||||
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
opts_file.write_text("[]")
|
||||
_create_skills_dir(project_dir, ai="claude")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
def test_command_without_frontmatter(self, skills_project, temp_dir):
|
||||
"""Commands without YAML frontmatter should still produce valid skills."""
|
||||
project_dir, skills_dir = skills_project
|
||||
@@ -495,10 +596,10 @@ class TestExtensionSkillEdgeCases:
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
skill_file = skills_dir / "speckit-nofm-ext.plain" / "SKILL.md"
|
||||
skill_file = skills_dir / "speckit-nofm-ext-plain" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
assert "name: speckit-nofm-ext.plain" in content
|
||||
assert "name: speckit-nofm-ext-plain" in content
|
||||
# Fallback description when no frontmatter description
|
||||
assert "Extension command: speckit.nofm-ext.plain" in content
|
||||
assert "Body without frontmatter." in content
|
||||
@@ -515,8 +616,8 @@ class TestExtensionSkillEdgeCases:
|
||||
)
|
||||
|
||||
skills_dir = project_dir / ".gemini" / "skills"
|
||||
assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists()
|
||||
assert (skills_dir / "speckit-test-ext.world" / "SKILL.md").exists()
|
||||
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
||||
assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists()
|
||||
|
||||
def test_multiple_extensions_independent_skills(self, skills_project, temp_dir):
|
||||
"""Installing and removing different extensions should be independent."""
|
||||
@@ -534,15 +635,15 @@ class TestExtensionSkillEdgeCases:
|
||||
)
|
||||
|
||||
# Both should have skills
|
||||
assert (skills_dir / "speckit-ext-a.hello" / "SKILL.md").exists()
|
||||
assert (skills_dir / "speckit-ext-b.hello" / "SKILL.md").exists()
|
||||
assert (skills_dir / "speckit-ext-a-hello" / "SKILL.md").exists()
|
||||
assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists()
|
||||
|
||||
# Remove ext-a
|
||||
manager.remove("ext-a", keep_config=False)
|
||||
|
||||
# ext-a skills gone, ext-b skills preserved
|
||||
assert not (skills_dir / "speckit-ext-a.hello").exists()
|
||||
assert (skills_dir / "speckit-ext-b.hello" / "SKILL.md").exists()
|
||||
assert not (skills_dir / "speckit-ext-a-hello").exists()
|
||||
assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists()
|
||||
|
||||
def test_malformed_frontmatter_handled(self, skills_project, temp_dir):
|
||||
"""Commands with invalid YAML frontmatter should still produce valid skills."""
|
||||
@@ -591,7 +692,7 @@ class TestExtensionSkillEdgeCases:
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
skill_file = skills_dir / "speckit-badfm-ext.broken" / "SKILL.md"
|
||||
skill_file = skills_dir / "speckit-badfm-ext-broken" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
# Fallback description since frontmatter was invalid
|
||||
@@ -607,7 +708,7 @@ class TestExtensionSkillEdgeCases:
|
||||
)
|
||||
|
||||
# Verify skills exist
|
||||
assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists()
|
||||
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
||||
|
||||
# Delete init-options.json to simulate user change
|
||||
init_opts = project_dir / ".specify" / "init-options.json"
|
||||
@@ -616,8 +717,8 @@ class TestExtensionSkillEdgeCases:
|
||||
# Remove should still clean up via fallback scan
|
||||
result = manager.remove(manifest.id, keep_config=False)
|
||||
assert result is True
|
||||
assert not (skills_dir / "speckit-test-ext.hello").exists()
|
||||
assert not (skills_dir / "speckit-test-ext.world").exists()
|
||||
assert not (skills_dir / "speckit-test-ext-hello").exists()
|
||||
assert not (skills_dir / "speckit-test-ext-world").exists()
|
||||
|
||||
def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension_dir):
|
||||
"""Skills should be cleaned up even if ai_skills is toggled to false after install."""
|
||||
@@ -628,7 +729,7 @@ class TestExtensionSkillEdgeCases:
|
||||
)
|
||||
|
||||
# Verify skills exist
|
||||
assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists()
|
||||
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
||||
|
||||
# Toggle ai_skills to false
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=False)
|
||||
@@ -636,5 +737,5 @@ class TestExtensionSkillEdgeCases:
|
||||
# Remove should still clean up via fallback scan
|
||||
result = manager.remove(manifest.id, keep_config=False)
|
||||
assert result is True
|
||||
assert not (skills_dir / "speckit-test-ext.hello").exists()
|
||||
assert not (skills_dir / "speckit-test-ext.world").exists()
|
||||
assert not (skills_dir / "speckit-test-ext-hello").exists()
|
||||
assert not (skills_dir / "speckit-test-ext-world").exists()
|
||||
|
||||
@@ -22,6 +22,7 @@ from specify_cli.extensions import (
|
||||
ExtensionRegistry,
|
||||
ExtensionManager,
|
||||
CommandRegistrar,
|
||||
HookExecutor,
|
||||
ExtensionCatalog,
|
||||
ExtensionError,
|
||||
ValidationError,
|
||||
@@ -759,6 +760,81 @@ $ARGUMENTS
|
||||
assert "Prüfe Konformität" in output
|
||||
assert "\\u" not in output
|
||||
|
||||
def test_adjust_script_paths_does_not_mutate_input(self):
|
||||
"""Path adjustments should not mutate caller-owned frontmatter dicts."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
registrar = AgentCommandRegistrar()
|
||||
original = {
|
||||
"scripts": {
|
||||
"sh": "../../scripts/bash/setup-plan.sh {ARGS}",
|
||||
"ps": "../../scripts/powershell/setup-plan.ps1 {ARGS}",
|
||||
}
|
||||
}
|
||||
before = json.loads(json.dumps(original))
|
||||
|
||||
adjusted = registrar._adjust_script_paths(original)
|
||||
|
||||
assert original == before
|
||||
assert adjusted["scripts"]["sh"] == ".specify/scripts/bash/setup-plan.sh {ARGS}"
|
||||
assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}"
|
||||
|
||||
def test_adjust_script_paths_preserves_extension_local_paths(self):
|
||||
"""Extension-local script paths should not be rewritten into .specify/.specify."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
registrar = AgentCommandRegistrar()
|
||||
original = {
|
||||
"scripts": {
|
||||
"sh": ".specify/extensions/test-ext/scripts/setup.sh {ARGS}",
|
||||
"ps": "scripts/powershell/setup-plan.ps1 {ARGS}",
|
||||
}
|
||||
}
|
||||
|
||||
adjusted = registrar._adjust_script_paths(original)
|
||||
|
||||
assert adjusted["scripts"]["sh"] == ".specify/extensions/test-ext/scripts/setup.sh {ARGS}"
|
||||
assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}"
|
||||
|
||||
def test_rewrite_project_relative_paths_preserves_extension_local_body_paths(self):
|
||||
"""Body rewrites should preserve extension-local assets while fixing top-level refs."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
body = (
|
||||
"Read `.specify/extensions/test-ext/templates/spec.md`\n"
|
||||
"Run scripts/bash/setup-plan.sh\n"
|
||||
)
|
||||
|
||||
rewritten = AgentCommandRegistrar._rewrite_project_relative_paths(body)
|
||||
|
||||
assert ".specify/extensions/test-ext/templates/spec.md" in rewritten
|
||||
assert ".specify/scripts/bash/setup-plan.sh" in rewritten
|
||||
|
||||
def test_render_toml_command_handles_embedded_triple_double_quotes(self):
|
||||
"""TOML renderer should stay valid when body includes triple double-quotes."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
registrar = AgentCommandRegistrar()
|
||||
output = registrar.render_toml_command(
|
||||
{"description": "x"},
|
||||
'line1\n"""danger"""\nline2',
|
||||
"extension:test-ext",
|
||||
)
|
||||
|
||||
assert "prompt = '''" in output
|
||||
assert '"""danger"""' in output
|
||||
|
||||
def test_render_toml_command_escapes_when_both_triple_quote_styles_exist(self):
|
||||
"""If body has both triple quote styles, fall back to escaped basic string."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
registrar = AgentCommandRegistrar()
|
||||
output = registrar.render_toml_command(
|
||||
{"description": "x"},
|
||||
'a """ b\nc \'\'\' d',
|
||||
"extension:test-ext",
|
||||
)
|
||||
|
||||
assert 'prompt = "' in output
|
||||
assert "\\n" in output
|
||||
assert "\\\"\\\"\\\"" in output
|
||||
|
||||
def test_register_commands_for_claude(self, extension_dir, project_dir):
|
||||
"""Test registering commands for Claude agent."""
|
||||
# Create .claude directory
|
||||
@@ -875,11 +951,11 @@ $ARGUMENTS
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, extension_dir, project_dir)
|
||||
|
||||
skill_file = skills_dir / "speckit-test.hello" / "SKILL.md"
|
||||
skill_file = skills_dir / "speckit-test-hello" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
|
||||
content = skill_file.read_text()
|
||||
assert "name: speckit-test.hello" in content
|
||||
assert "name: speckit-test-hello" in content
|
||||
assert "description: Test hello command" in content
|
||||
assert "compatibility:" in content
|
||||
assert "metadata:" in content
|
||||
@@ -944,7 +1020,7 @@ Agent __AGENT__
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
skill_file = skills_dir / "speckit-test.plan" / "SKILL.md"
|
||||
skill_file = skills_dir / "speckit-test-plan" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
|
||||
content = skill_file.read_text()
|
||||
@@ -994,12 +1070,12 @@ Agent __AGENT__
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
primary = skills_dir / "speckit-alias.cmd" / "SKILL.md"
|
||||
primary = skills_dir / "speckit-alias-cmd" / "SKILL.md"
|
||||
alias = skills_dir / "speckit-shortcut" / "SKILL.md"
|
||||
|
||||
assert primary.exists()
|
||||
assert alias.exists()
|
||||
assert "name: speckit-alias.cmd" in primary.read_text()
|
||||
assert "name: speckit-alias-cmd" in primary.read_text()
|
||||
assert "name: speckit-shortcut" in alias.read_text()
|
||||
|
||||
def test_codex_skill_registration_uses_fallback_script_variant_without_init_options(
|
||||
@@ -1056,7 +1132,7 @@ Then {AGENT_SCRIPT}
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
skill_file = skills_dir / "speckit-fallback.plan" / "SKILL.md"
|
||||
skill_file = skills_dir / "speckit-fallback-plan" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
|
||||
content = skill_file.read_text()
|
||||
@@ -1065,6 +1141,62 @@ Then {AGENT_SCRIPT}
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
|
||||
|
||||
def test_codex_skill_registration_handles_non_dict_init_options(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Non-dict init-options payloads should not crash skill placeholder resolution."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / "ext-script-list-init"
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "commands").mkdir()
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "ext-script-list-init",
|
||||
"name": "List init options",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.list.plan",
|
||||
"file": "commands/plan.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands" / "plan.md").write_text(
|
||||
"""---
|
||||
description: "List init scripted command"
|
||||
scripts:
|
||||
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
|
||||
---
|
||||
|
||||
Run {SCRIPT}
|
||||
"""
|
||||
)
|
||||
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text("[]")
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
content = (skills_dir / "speckit-list-plan" / "SKILL.md").read_text()
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
|
||||
def test_codex_skill_registration_fallback_prefers_powershell_on_windows(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
@@ -1121,7 +1253,7 @@ Then {AGENT_SCRIPT}
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
skill_file = skills_dir / "speckit-windows.plan" / "SKILL.md"
|
||||
skill_file = skills_dir / "speckit-windows-plan" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
|
||||
content = skill_file.read_text()
|
||||
@@ -3231,3 +3363,128 @@ class TestExtensionPriorityBackwardsCompatibility:
|
||||
assert result[0][0] == "ext-with-priority"
|
||||
assert result[1][0] == "legacy-ext"
|
||||
assert result[2][0] == "ext-low-priority"
|
||||
|
||||
|
||||
class TestHookInvocationRendering:
|
||||
"""Test hook invocation formatting for different agent modes."""
|
||||
|
||||
def test_kimi_hooks_render_skill_invocation(self, project_dir):
|
||||
"""Kimi projects should render /skill:speckit-* invocations."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
|
||||
|
||||
hook_executor = HookExecutor(project_dir)
|
||||
message = hook_executor.format_hook_message(
|
||||
"before_plan",
|
||||
[
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "speckit.plan",
|
||||
"optional": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert "Executing: `/skill:speckit-plan`" in message
|
||||
assert "EXECUTE_COMMAND: speckit.plan" in message
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-plan" in message
|
||||
|
||||
def test_codex_hooks_render_dollar_skill_invocation(self, project_dir):
|
||||
"""Codex projects with --ai-skills should render $speckit-* invocations."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": "codex", "ai_skills": True}))
|
||||
|
||||
hook_executor = HookExecutor(project_dir)
|
||||
execution = hook_executor.execute_hook(
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "speckit.tasks",
|
||||
"optional": False,
|
||||
}
|
||||
)
|
||||
|
||||
assert execution["command"] == "speckit.tasks"
|
||||
assert execution["invocation"] == "$speckit-tasks"
|
||||
|
||||
def test_non_skill_command_keeps_slash_invocation(self, project_dir):
|
||||
"""Custom hook commands should keep slash invocation style."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
|
||||
|
||||
hook_executor = HookExecutor(project_dir)
|
||||
message = hook_executor.format_hook_message(
|
||||
"before_tasks",
|
||||
[
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "pre_tasks_test",
|
||||
"optional": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert "Executing: `/pre_tasks_test`" in message
|
||||
assert "EXECUTE_COMMAND: pre_tasks_test" in message
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /pre_tasks_test" in message
|
||||
|
||||
def test_extension_command_uses_hyphenated_skill_invocation(self, project_dir):
|
||||
"""Multi-segment extension command ids should map to hyphenated skills."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
|
||||
|
||||
hook_executor = HookExecutor(project_dir)
|
||||
message = hook_executor.format_hook_message(
|
||||
"after_tasks",
|
||||
[
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "speckit.test.hello",
|
||||
"optional": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert "Executing: `/skill:speckit-test-hello`" in message
|
||||
assert "EXECUTE_COMMAND: speckit.test.hello" in message
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-test-hello" in message
|
||||
|
||||
def test_hook_executor_caches_init_options_lookup(self, project_dir, monkeypatch):
|
||||
"""Init options should be loaded once per executor instance."""
|
||||
calls = {"count": 0}
|
||||
|
||||
def fake_load_init_options(_project_root):
|
||||
calls["count"] += 1
|
||||
return {"ai": "kimi", "ai_skills": False}
|
||||
|
||||
monkeypatch.setattr("specify_cli.load_init_options", fake_load_init_options)
|
||||
|
||||
hook_executor = HookExecutor(project_dir)
|
||||
assert hook_executor._render_hook_invocation("speckit.plan") == "/skill:speckit-plan"
|
||||
assert hook_executor._render_hook_invocation("speckit.tasks") == "/skill:speckit-tasks"
|
||||
assert calls["count"] == 1
|
||||
|
||||
def test_hook_message_falls_back_when_invocation_is_empty(self, project_dir):
|
||||
"""Hook messages should still render actionable command placeholders."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
|
||||
|
||||
hook_executor = HookExecutor(project_dir)
|
||||
message = hook_executor.format_hook_message(
|
||||
"after_tasks",
|
||||
[
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": None,
|
||||
"optional": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert "Executing: `/<missing command>`" in message
|
||||
assert "EXECUTE_COMMAND: <missing command>" in message
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /<missing command>" in message
|
||||
|
||||
@@ -1942,10 +1942,10 @@ class TestInitOptions:
|
||||
class TestPresetSkills:
|
||||
"""Tests for preset skill registration and unregistration."""
|
||||
|
||||
def _write_init_options(self, project_dir, ai="claude", ai_skills=True):
|
||||
def _write_init_options(self, project_dir, ai="claude", ai_skills=True, script="sh"):
|
||||
from specify_cli import save_init_options
|
||||
|
||||
save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills})
|
||||
save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills, "script": script})
|
||||
|
||||
def _create_skill(self, skills_dir, skill_name, body="original body"):
|
||||
skill_dir = skills_dir / skill_name
|
||||
@@ -1995,6 +1995,26 @@ class TestPresetSkills:
|
||||
content = skill_file.read_text()
|
||||
assert "untouched" in content, "Skill should not be modified when ai_skills=False"
|
||||
|
||||
def test_get_skills_dir_returns_none_for_non_string_ai(self, project_dir):
|
||||
"""Corrupted init-options ai values should not crash preset skill resolution."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text('{"ai":["codex"],"ai_skills":true,"script":"sh"}')
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
|
||||
assert manager._get_skills_dir() is None
|
||||
|
||||
def test_get_skills_dir_returns_none_for_non_dict_init_options(self, project_dir):
|
||||
"""Corrupted non-dict init-options payloads should fail closed."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text("[]")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
|
||||
assert manager._get_skills_dir() is None
|
||||
|
||||
def test_skill_not_updated_without_init_options(self, project_dir, temp_dir):
|
||||
"""When no init-options.json exists, preset install should not touch skills."""
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
@@ -2040,6 +2060,52 @@ class TestPresetSkills:
|
||||
assert "preset:self-test" not in content, "Preset content should be gone"
|
||||
assert "templates/commands/specify.md" in content, "Should reference core template"
|
||||
|
||||
def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir):
|
||||
"""Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths."""
|
||||
self._write_init_options(project_dir, ai="claude", ai_skills=True, script="sh")
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-specify", body="old")
|
||||
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
||||
core_cmds.mkdir(parents=True, exist_ok=True)
|
||||
(core_cmds / "specify.md").write_text(
|
||||
"---\n"
|
||||
"description: Core specify command\n"
|
||||
"scripts:\n"
|
||||
" sh: .specify/scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n"
|
||||
"---\n\n"
|
||||
"Run:\n"
|
||||
"{SCRIPT}\n"
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
||||
manager.remove("self-test")
|
||||
|
||||
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "{ARGS}" not in content
|
||||
assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content
|
||||
|
||||
def test_skill_not_overridden_when_skill_path_is_file(self, project_dir):
|
||||
"""Preset install should skip non-directory skill targets."""
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skills_dir / "speckit-specify").write_text("not-a-directory")
|
||||
|
||||
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
||||
|
||||
assert (skills_dir / "speckit-specify").is_file()
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert "speckit-specify" not in metadata.get("registered_skills", [])
|
||||
|
||||
def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_dir):
|
||||
"""Skills should not be created when no existing skill dir is found."""
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
@@ -2054,6 +2120,304 @@ class TestPresetSkills:
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert metadata.get("registered_skills", []) == []
|
||||
|
||||
def test_extension_skill_override_matches_hyphenated_multisegment_name(self, project_dir, temp_dir):
|
||||
"""Preset overrides for speckit.<ext>.<cmd> should target speckit-<ext>-<cmd> skills."""
|
||||
self._write_init_options(project_dir, ai="codex")
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-fakeext-cmd", body="untouched")
|
||||
(project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
preset_dir = temp_dir / "ext-skill-override"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
||||
"---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-override\n"
|
||||
)
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "ext-skill-override",
|
||||
"name": "Ext Skill Override",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.fakeext.cmd",
|
||||
"file": "commands/speckit.fakeext.cmd.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
assert "preset:ext-skill-override" in content
|
||||
assert "name: speckit-fakeext-cmd" in content
|
||||
assert "# Speckit Fakeext Cmd Skill" in content
|
||||
|
||||
metadata = manager.registry.get("ext-skill-override")
|
||||
assert "speckit-fakeext-cmd" in metadata.get("registered_skills", [])
|
||||
|
||||
def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir):
|
||||
"""Preset removal should restore an extension-backed skill instead of deleting it."""
|
||||
self._write_init_options(project_dir, ai="codex")
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill")
|
||||
|
||||
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
|
||||
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
|
||||
(extension_dir / "commands" / "cmd.md").write_text(
|
||||
"---\n"
|
||||
"description: Extension fakeext cmd\n"
|
||||
"scripts:\n"
|
||||
" sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n"
|
||||
"---\n\n"
|
||||
"extension:fakeext\n"
|
||||
"Run {SCRIPT}\n"
|
||||
)
|
||||
extension_manifest = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "fakeext",
|
||||
"name": "Fake Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.fakeext.cmd",
|
||||
"file": "commands/cmd.md",
|
||||
"description": "Fake extension command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(extension_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(extension_manifest, f)
|
||||
|
||||
preset_dir = temp_dir / "ext-skill-restore"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
||||
"---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-restore\n"
|
||||
)
|
||||
preset_manifest = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "ext-skill-restore",
|
||||
"name": "Ext Skill Restore",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.fakeext.cmd",
|
||||
"file": "commands/speckit.fakeext.cmd.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(preset_manifest, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md"
|
||||
assert "preset:ext-skill-restore" in skill_file.read_text()
|
||||
|
||||
manager.remove("ext-skill-restore")
|
||||
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
assert "preset:ext-skill-restore" not in content
|
||||
assert "source: extension:fakeext" in content
|
||||
assert "extension:fakeext" in content
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
assert "# Fakeext Cmd Skill" in content
|
||||
|
||||
def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, temp_dir):
|
||||
"""Preset removal should not delete arbitrary directories missing SKILL.md."""
|
||||
self._write_init_options(project_dir, ai="codex")
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
stray_skill_dir = skills_dir / "speckit-fakeext-cmd"
|
||||
stray_skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
note_file = stray_skill_dir / "notes.txt"
|
||||
note_file.write_text("user content", encoding="utf-8")
|
||||
|
||||
preset_dir = temp_dir / "ext-skill-missing-file"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
||||
"---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-missing-file\n"
|
||||
)
|
||||
preset_manifest = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "ext-skill-missing-file",
|
||||
"name": "Ext Skill Missing File",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.fakeext.cmd",
|
||||
"file": "commands/speckit.fakeext.cmd.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(preset_manifest, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
installed_preset_dir = manager.presets_dir / "ext-skill-missing-file"
|
||||
shutil.copytree(preset_dir, installed_preset_dir)
|
||||
manager.registry.add(
|
||||
"ext-skill-missing-file",
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"source": str(preset_dir),
|
||||
"provides_templates": ["speckit.fakeext.cmd"],
|
||||
"registered_skills": ["speckit-fakeext-cmd"],
|
||||
"priority": 10,
|
||||
},
|
||||
)
|
||||
|
||||
manager.remove("ext-skill-missing-file")
|
||||
|
||||
assert stray_skill_dir.is_dir()
|
||||
assert note_file.read_text(encoding="utf-8") == "user content"
|
||||
|
||||
def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp_dir):
|
||||
"""Preset overrides should still target legacy dotted Kimi skill directories."""
|
||||
self._write_init_options(project_dir, ai="kimi")
|
||||
skills_dir = project_dir / ".kimi" / "skills"
|
||||
self._create_skill(skills_dir, "speckit.specify", body="untouched")
|
||||
|
||||
(project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(self_test_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit.specify" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
assert "preset:self-test" in content
|
||||
assert "name: speckit.specify" in content
|
||||
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert "speckit.specify" in metadata.get("registered_skills", [])
|
||||
|
||||
def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp_dir):
|
||||
"""Kimi presets should still propagate command overrides to existing skills."""
|
||||
self._write_init_options(project_dir, ai="kimi", ai_skills=False)
|
||||
skills_dir = project_dir / ".kimi" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
||||
|
||||
(project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(self_test_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
assert "preset:self-test" in content
|
||||
assert "name: speckit-specify" in content
|
||||
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert "speckit-specify" in metadata.get("registered_skills", [])
|
||||
|
||||
def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir):
|
||||
"""Kimi preset skill overrides should resolve placeholders and rewrite project paths."""
|
||||
self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh")
|
||||
skills_dir = project_dir / ".kimi" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
||||
(project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
preset_dir = temp_dir / "kimi-placeholder-override"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.specify.md").write_text(
|
||||
"---\n"
|
||||
"description: Kimi placeholder override\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n"
|
||||
"---\n\n"
|
||||
"Execute `{SCRIPT}` for __AGENT__\n"
|
||||
"Review templates/checklist.md and memory/constitution.md\n"
|
||||
)
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "kimi-placeholder-override",
|
||||
"name": "Kimi Placeholder Override",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.specify",
|
||||
"file": "commands/speckit.specify.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "__AGENT__" not in content
|
||||
assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content
|
||||
assert ".specify/templates/checklist.md" in content
|
||||
assert ".specify/memory/constitution.md" in content
|
||||
assert "for kimi" in content
|
||||
|
||||
def test_preset_skill_registration_handles_non_dict_init_options(self, project_dir, temp_dir):
|
||||
"""Non-dict init-options payloads should not crash preset install/remove flows."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text("[]")
|
||||
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
||||
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(self_test_dir, "0.1.5")
|
||||
|
||||
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
||||
assert "untouched" in content
|
||||
|
||||
|
||||
class TestPresetSetPriority:
|
||||
"""Test preset set-priority CLI command."""
|
||||
|
||||
@@ -14,6 +14,7 @@ import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
|
||||
CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
|
||||
|
||||
@@ -147,6 +148,24 @@ class TestSequentialBranch:
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
assert branch == "003-next-feat", f"expected 003-next-feat, got: {branch}"
|
||||
|
||||
def test_sequential_supports_four_digit_prefixes(self, git_repo: Path):
|
||||
"""Sequential numbering should continue past 999 without truncation."""
|
||||
(git_repo / "specs" / "999-last-3digit").mkdir(parents=True)
|
||||
(git_repo / "specs" / "1000-first-4digit").mkdir(parents=True)
|
||||
result = run_script(git_repo, "--short-name", "next-feat", "Next feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
branch = None
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("BRANCH_NAME:"):
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
assert branch == "1001-next-feat", f"expected 1001-next-feat, got: {branch}"
|
||||
|
||||
def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self):
|
||||
"""PowerShell scanner should parse large prefixes without [int] casts."""
|
||||
content = CREATE_FEATURE_PS.read_text(encoding="utf-8")
|
||||
assert "[long]::TryParse($matches[1], [ref]$num)" in content
|
||||
assert "$num = [int]$matches[1]" not in content
|
||||
|
||||
|
||||
# ── check_feature_branch Tests ───────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user