from __future__ import annotations import argparse import curses import os import subprocess import sys import tempfile import unittest from pathlib import Path from unittest.mock import Mock, patch from skiff import cli from skiff import yaml_io from skiff.registry import external_repo_path, external_skill_path from skiff.selector import SkillChoice, filter_choices, select_skills REPO_ROOT = Path(__file__).resolve().parents[1] class SelectorTests(unittest.TestCase): def test_manifest_entry_targets_round_trip(self) -> None: data = { "skills": [ { "name": "external-one", "source": "registry", "targets": ["codex"], } ] } self.assertEqual(yaml_io.safe_load(yaml_io.safe_dump(data)), data) def test_filter_matches_name_kind_and_description(self) -> None: choices = [ SkillChoice("frontend-design", "external", "创建界面"), SkillChoice("discussion-notes", "owned", "维护讨论笔记"), ] self.assertEqual([c.name for c in filter_choices(choices, "front")], ["frontend-design"]) self.assertEqual([c.name for c in filter_choices(choices, "owned")], ["discussion-notes"]) self.assertEqual([c.name for c in filter_choices(choices, "界面")], ["frontend-design"]) def test_selector_preserves_preselected_items(self) -> None: choices = [ SkillChoice("already-there", "owned", installed=True), SkillChoice("new-skill", "external"), ] screen = Mock() screen.getmaxyx.return_value = (24, 100) screen.get_wch.return_value = "\n" with patch.object(curses, "curs_set"): selected = select_skills( choices, wrapper=lambda draw: draw(screen), ) self.assertEqual(selected, {"already-there"}) def test_shared_repo_path_is_same_for_different_skill_entries(self) -> None: first = {"repo": "https://example.test/skills.git", "ref": "main", "path": "a"} second = {"repo": "https://example.test/skills.git", "ref": "main", "path": "b"} self.assertEqual(external_repo_path(first), external_repo_path(second)) def test_external_skill_path_rejects_checkout_escape(self) -> None: entry = { "repo": "https://example.test/skills.git", "ref": "main", "path": "../../outside", } with self.assertRaisesRegex(SystemExit, "超出外部仓库"): external_skill_path("unsafe-skill", entry) def test_collection_discovery_ignores_symlinked_skill(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) checkout = root / "checkout" collection = checkout / "skills" outside = root / "outside" collection.mkdir(parents=True) outside.mkdir() outside.joinpath("SKILL.md").write_text("---\n", encoding="utf-8") collection.joinpath("escaped").symlink_to(outside, target_is_directory=True) entry = { "repo": "https://example.test/skills.git", "ref": "main", "path": "skills", } with patch("skiff.registry.external_checkout_path", return_value=checkout): from skiff.registry import discover_external_skills self.assertEqual(discover_external_skills("unsafe", entry), {}) class SelectCommandTests(unittest.TestCase): def test_non_tty_exits_with_add_guidance(self) -> None: with tempfile.TemporaryDirectory() as temp: home = Path(temp) (home / ".skills" / "skills").mkdir(parents=True) env = os.environ.copy() env["HOME"] = str(home) env["PYTHONPATH"] = str(REPO_ROOT) result = subprocess.run( [sys.executable, "-m", "skiff", "select"], cwd=REPO_ROOT, env=env, text=True, capture_output=True, check=False, ) self.assertNotEqual(result.returncode, 0) self.assertIn("skiff add ", result.stderr) def test_project_selection_installs_new_and_records_all_selected(self) -> None: with tempfile.TemporaryDirectory() as temp: project = Path(temp) external = project / "external-one" external.mkdir() external.joinpath("SKILL.md").write_text("---\n", encoding="utf-8") args = argparse.Namespace( agents=[["codex"]], global_scope=False, project=str(project), yes=False, ) stdin = Mock() stdout = Mock() stdin.isatty.return_value = True stdout.isatty.return_value = True installed: list[str] = [] with ( patch.object(cli.sys, "stdin", stdin), patch.object(cli.sys, "stdout", stdout), patch.object(cli, "ensure_skills_home"), patch.object(cli, "list_owned_skills", return_value=["owned-one"]), patch.object(cli, "skill_description", return_value="owned"), patch.object( cli, "load_registry", return_value={ "external-one": { "repo": "https://example.test/skills.git", "ref": "main", "path": "external-one", } }, ), patch.object(cli, "_registry_skill_names", return_value=["external-one"]), patch.object(cli, "external_skill_path", return_value=external), patch.object(cli, "_list_fully_installed_names", return_value=["owned-one"]), patch.object( cli, "_is_fully_installed", side_effect=lambda name, expected, project_root, targets: name == "owned-one", ), patch.object( cli, "select_skills", return_value={"owned-one", "external-one"}, ), patch.object( cli, "_install_skill", side_effect=lambda name, targets, project_root, **kwargs: installed.append(name), ), ): cli.cmd_select(args) manifest = (project / ".skills.yaml").read_text(encoding="utf-8") self.assertEqual(installed, ["external-one"]) self.assertIn("owned-one", manifest) self.assertIn('name: "external-one"', manifest) self.assertIn("source: registry", manifest) self.assertIn("targets:", manifest) self.assertIn("codex", manifest) def test_select_rejects_invalid_registry_name_before_rendering(self) -> None: args = argparse.Namespace( agents=None, global_scope=True, project=None, yes=False, ) stdin = Mock() stdout = Mock() stdin.isatty.return_value = True stdout.isatty.return_value = True with ( patch.object(cli.sys, "stdin", stdin), patch.object(cli.sys, "stdout", stdout), patch.object(cli, "ensure_skills_home"), patch.object(cli, "list_owned_skills", return_value=[]), patch.object( cli, "load_registry", return_value={"../../victim": {"repo": "https://example.test/repo.git"}}, ), patch.object(cli, "select_skills") as selector, ): with self.assertRaisesRegex(SystemExit, "skill 名称无效"): cli.cmd_select(args) selector.assert_not_called() def test_select_expands_registry_collection_choices(self) -> None: args = argparse.Namespace( agents=[["codex"]], global_scope=True, project=None, yes=False, ) stdin = Mock() stdout = Mock() stdin.isatty.return_value = True stdout.isatty.return_value = True selected_choices: list[SkillChoice] = [] installed: list[tuple[str, str | None]] = [] def choose(choices: list[SkillChoice]) -> set[str]: selected_choices.extend(choices) return {"waza/think"} with ( patch.object(cli.sys, "stdin", stdin), patch.object(cli.sys, "stdout", stdout), patch.object(cli, "ensure_skills_home"), patch.object(cli, "list_owned_skills", return_value=[]), patch.object( cli, "load_registry", return_value={ "waza": { "repo": "https://example.test/waza.git", "ref": "main", "path": "skills", } }, ), patch.object( cli, "_registry_skill_names", return_value=["think", "ui"], ), patch.object(cli, "_list_fully_installed_names", return_value=[]), patch.object(cli, "_is_fully_installed", return_value=False), patch.object(cli, "select_skills", side_effect=choose), patch.object( cli, "_install_skill", side_effect=lambda name, targets, project_root, **kwargs: installed.append( (name, kwargs.get("source")) ), ), ): cli.cmd_select(args) self.assertEqual([choice.name for choice in selected_choices], ["waza/think", "waza/ui"]) self.assertEqual(installed, [("think", "registry:waza")]) def test_install_rolls_back_earlier_target_when_later_target_fails(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) skill = root / "source" skill.mkdir() skill.joinpath("SKILL.md").write_text("---\\n", encoding="utf-8") links = { "cursor": root / "cursor" / "demo", "claude": root / "claude" / "demo", } links["claude"].mkdir(parents=True) with ( patch.object(cli, "_ensure_source_fetched"), patch.object(cli, "resolve_skill_source", return_value=(skill, "owned")), patch.object( cli, "agent_skill_dir", side_effect=lambda target, project_root=None: links[target].parent, ), ): with self.assertRaises(FileExistsError): cli._install_skill("demo", ["cursor", "claude"]) self.assertFalse(links["cursor"].exists()) self.assertTrue(links["claude"].is_dir()) if __name__ == "__main__": unittest.main()