feat: add interactive skill selector

This commit is contained in:
2026-07-27 18:07:39 +08:00
parent 86cd1fa36d
commit b9963b66de
10 changed files with 540 additions and 21 deletions
+214
View File
@@ -0,0 +1,214 @@
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)
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 <name>", result.stderr)
def test_project_selection_installs_new_and_records_all_selected(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
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, "_list_fully_installed_names", return_value=["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: 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_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_external_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()