From 262c138bee65fe518451b36e494bc1fc025b269d Mon Sep 17 00:00:00 2001 From: laily Date: Wed, 29 Jul 2026 10:57:53 +0800 Subject: [PATCH] feat: add shallow fetch and grouped skill selection --- skiff/cli.py | 23 +++++++++- skiff/selector.py | 31 ++++++++++--- skiff/sources.py | 12 ++++- skills/skiff/SKILL.md | 3 +- tests/test_custom_sources.py | 28 ++++++++++++ tests/test_select.py | 87 +++++++++++++++++++++++++++++++++++- 6 files changed, 174 insertions(+), 10 deletions(-) diff --git a/skiff/cli.py b/skiff/cli.py index d81529b..23f217b 100644 --- a/skiff/cli.py +++ b/skiff/cli.py @@ -150,7 +150,7 @@ def _ensure_registry_fetched(name: str, entry: dict[str, object]) -> None: ) _print(f"拉取外部 skill: {name}") subprocess.run( - ["git", "clone", "--branch", ref, "--", repo, str(dest)], + ["git", "clone", "--depth", "1", "--branch", ref, "--", repo, str(dest)], check=True, ) if not discover_external_skills(name, entry): @@ -563,6 +563,7 @@ def cmd_select(args: argparse.Namespace) -> None: expected: Path, kind: str, description: str, + indent: int = 0, ) -> SkillChoice: return SkillChoice( name=name, @@ -579,6 +580,7 @@ def cmd_select(args: argparse.Namespace) -> None: if project_root is not None else "" ), + indent=indent, ) choices = [ @@ -628,9 +630,26 @@ def cmd_select(args: argparse.Namespace) -> None: expected=expected, kind=f"external:{package}", description=description, + indent=1, ) ) choice_requests[choice_name] = (skill_name, f"registry:{package}") + child_names = tuple( + f"{package}/{skill_name}" + for skill_name in skill_names + if f"{package}/{skill_name}" in choice_requests + ) + if child_names: + first_child = len(choices) - len(child_names) + choices.insert( + first_child, + SkillChoice( + name=package, + kind="repository", + description=str(entry.get("description", "")), + children=child_names, + ), + ) try: scope_label = ( @@ -807,7 +826,7 @@ def cmd_fetch(args: argparse.Namespace) -> None: else: _print(f"克隆: {repo} -> {dest}") subprocess.run( - ["git", "clone", "--branch", ref, "--", repo, str(dest)], + ["git", "clone", "--depth", "1", "--branch", ref, "--", repo, str(dest)], check=True, ) diff --git a/skiff/selector.py b/skiff/selector.py index 0fcca69..40177ba 100644 --- a/skiff/selector.py +++ b/skiff/selector.py @@ -14,6 +14,8 @@ class SkillChoice: description: str = "" installed: bool = False readonly_status: str = "" + children: tuple[str, ...] = () + indent: int = 0 def fit_to_width(text: str, width: int) -> str: @@ -74,7 +76,19 @@ def select_skills( except ImportError as exc: raise RuntimeError("当前 Python 环境不支持 curses,无法打开交互界面") from exc - selected = {choice.name for choice in choices if choice.installed} + selected = { + choice.name + for choice in choices + if choice.installed and not choice.children + } + + def mark_for(choice: SkillChoice) -> str: + if not choice.children: + return "x" if choice.name in selected else " " + selected_children = selected.intersection(choice.children) + if len(selected_children) == len(choice.children): + return "x" + return "-" if selected_children else " " def draw(stdscr: object) -> set[str] | None: curses.curs_set(0) @@ -122,11 +136,11 @@ def select_skills( for offset, choice in enumerate(visible[start : start + item_capacity]): index = start + offset row = 3 + offset * 3 - mark = "x" if choice.name in selected else " " + mark = mark_for(choice) description = f" {choice.description}" if choice.description else "" focus_attr = curses.A_REVERSE if index == current else curses.A_NORMAL parts = [ - (f"[{mark}] ", focus_attr), + (f"{' ' * choice.indent}[{mark}] ", focus_attr), (choice.name, focus_attr | curses.A_BOLD), (f" {choice.kind}", focus_attr | source_attr), ] @@ -171,8 +185,15 @@ def select_skills( elif key == curses.KEY_DOWN and visible: current = (current + 1) % len(visible) elif key == " " and visible: - name = visible[current].name - selected.symmetric_difference_update({name}) + choice = visible[current] + if choice.children: + children = set(choice.children) + if children.issubset(selected): + selected.difference_update(children) + else: + selected.update(children) + else: + selected.symmetric_difference_update({choice.name}) elif key == "/": curses.curs_set(1) query = _read_query(stdscr, curses, width, row=2) diff --git a/skiff/sources.py b/skiff/sources.py index bbc9811..276ab02 100644 --- a/skiff/sources.py +++ b/skiff/sources.py @@ -100,7 +100,17 @@ def fetch_source(name: str, entry: dict[str, Any]) -> Path: else: checkout.parent.mkdir(parents=True, exist_ok=True) subprocess.run( - ["git", "clone", "--branch", ref, "--", str(repo), str(checkout)], + [ + "git", + "clone", + "--depth", + "1", + "--branch", + ref, + "--", + str(repo), + str(checkout), + ], check=True, ) return checkout diff --git a/skills/skiff/SKILL.md b/skills/skiff/SKILL.md index 23973ee..c2e2b77 100644 --- a/skills/skiff/SKILL.md +++ b/skills/skiff/SKILL.md @@ -115,7 +115,8 @@ skiff add waza -a codex -g -y skiff add waza/think -a codex -g -y ``` -`skiff select` 会把 collection 展开为 `waza/think`、`waza/ui` 等候选项。 +`skiff select` 会把 collection 显示为两级菜单:选择 `waza` 仓库会选中其 +全部子 skill,也可以只选择 `waza/think`、`waza/ui` 中的若干项。 普通 `skiff select` 只向项目安装,并只读显示每个 Agent 的全局安装状态; `skiff select -g` 只向全局安装。取消已勾选项不会卸载,卸载继续使用 `skiff remove`。 diff --git a/tests/test_custom_sources.py b/tests/test_custom_sources.py index 9409c98..abf4a63 100644 --- a/tests/test_custom_sources.py +++ b/tests/test_custom_sources.py @@ -6,7 +6,9 @@ import sys import tempfile import unittest from pathlib import Path +from unittest.mock import patch +from skiff.sources import fetch_source REPO_ROOT = Path(__file__).resolve().parents[1] @@ -228,6 +230,32 @@ class CustomSourceTests(unittest.TestCase): self.assertTrue((checkout / ".git").is_dir()) self.assertTrue((checkout / "skills" / "release-check" / "SKILL.md").is_file()) + def test_git_source_clone_is_shallow(self) -> None: + checkout = self.home / "checkout" + entry = { + "repo": "https://example.test/company-skills.git", + "ref": "main", + "checkout": str(checkout), + } + + with patch("skiff.sources.subprocess.run") as run: + fetch_source("company", entry) + + run.assert_called_once_with( + [ + "git", + "clone", + "--depth", + "1", + "--branch", + "main", + "--", + "https://example.test/company-skills.git", + str(checkout), + ], + check=True, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_select.py b/tests/test_select.py index 2ef004f..3910cf6 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -135,6 +135,49 @@ class SelectorTests(unittest.TestCase): self.assertTrue(name_call.args[4] & curses.A_BOLD) self.assertTrue(description_call.args[4] & curses.A_DIM) + def test_repository_choice_toggles_all_child_skills(self) -> None: + choices = [ + SkillChoice( + "waza", + "repository", + children=("waza/think", "waza/ui"), + ), + SkillChoice("waza/think", "external:waza", indent=1), + SkillChoice("waza/ui", "external:waza", indent=1), + ] + screen = Mock() + screen.getmaxyx.return_value = (24, 100) + screen.get_wch.side_effect = [" ", "\n"] + + with patch.object(curses, "curs_set"): + selected = select_skills(choices, wrapper=lambda draw: draw(screen)) + + self.assertEqual(selected, {"waza/think", "waza/ui"}) + + def test_repository_choice_renders_partial_selection(self) -> None: + choices = [ + SkillChoice( + "waza", + "repository", + children=("waza/think", "waza/ui"), + ), + SkillChoice("waza/think", "external:waza", installed=True, indent=1), + SkillChoice("waza/ui", "external:waza", indent=1), + ] + screen = Mock() + screen.getmaxyx.return_value = (24, 100) + screen.get_wch.return_value = "\n" + + with patch.object(curses, "curs_set"): + select_skills(choices, wrapper=lambda draw: draw(screen)) + + first_row = "".join( + call.args[2] + for call in screen.addnstr.call_args_list + if call.args[0] == 3 + ) + self.assertIn("[-] waza", first_row) + 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"} @@ -172,6 +215,43 @@ class SelectorTests(unittest.TestCase): self.assertEqual(discover_external_skills("unsafe", entry), {}) + def test_registry_clone_is_shallow(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + checkout = root / "checkout" + skill_root = checkout / "skills" + entry = { + "repo": "https://example.test/skills.git", + "ref": "main", + "path": "skills", + } + with ( + patch.object(cli, "external_skill_path", return_value=skill_root), + patch.object(cli, "external_checkout_path", return_value=checkout), + patch.object( + cli, + "discover_external_skills", + side_effect=[{}, {"demo": skill_root / "demo"}], + ), + patch.object(cli.subprocess, "run") as run, + ): + cli._ensure_registry_fetched("demo-pack", entry) + + run.assert_called_once_with( + [ + "git", + "clone", + "--depth", + "1", + "--branch", + "main", + "--", + "https://example.test/skills.git", + str(checkout), + ], + check=True, + ) + class SelectCommandTests(unittest.TestCase): def test_non_tty_exits_with_add_guidance(self) -> None: @@ -390,7 +470,12 @@ class SelectCommandTests(unittest.TestCase): ): cli.cmd_select(args) - self.assertEqual([choice.name for choice in selected_choices], ["waza/think", "waza/ui"]) + self.assertEqual( + [choice.name for choice in selected_choices], + ["waza", "waza/think", "waza/ui"], + ) + self.assertEqual(selected_choices[0].children, ("waza/think", "waza/ui")) + self.assertEqual([choice.indent for choice in selected_choices[1:]], [1, 1]) self.assertEqual(installed, [("think", "registry:waza")]) def test_select_deduplicates_local_registry_collection_from_owned(self) -> None: