feat: add shallow fetch and grouped skill selection

This commit is contained in:
2026-07-29 10:57:53 +08:00
parent 18ebb70911
commit 262c138bee
6 changed files with 174 additions and 10 deletions
+21 -2
View File
@@ -150,7 +150,7 @@ def _ensure_registry_fetched(name: str, entry: dict[str, object]) -> None:
) )
_print(f"拉取外部 skill: {name}") _print(f"拉取外部 skill: {name}")
subprocess.run( subprocess.run(
["git", "clone", "--branch", ref, "--", repo, str(dest)], ["git", "clone", "--depth", "1", "--branch", ref, "--", repo, str(dest)],
check=True, check=True,
) )
if not discover_external_skills(name, entry): if not discover_external_skills(name, entry):
@@ -563,6 +563,7 @@ def cmd_select(args: argparse.Namespace) -> None:
expected: Path, expected: Path,
kind: str, kind: str,
description: str, description: str,
indent: int = 0,
) -> SkillChoice: ) -> SkillChoice:
return SkillChoice( return SkillChoice(
name=name, name=name,
@@ -579,6 +580,7 @@ def cmd_select(args: argparse.Namespace) -> None:
if project_root is not None if project_root is not None
else "" else ""
), ),
indent=indent,
) )
choices = [ choices = [
@@ -628,9 +630,26 @@ def cmd_select(args: argparse.Namespace) -> None:
expected=expected, expected=expected,
kind=f"external:{package}", kind=f"external:{package}",
description=description, description=description,
indent=1,
) )
) )
choice_requests[choice_name] = (skill_name, f"registry:{package}") 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: try:
scope_label = ( scope_label = (
@@ -807,7 +826,7 @@ def cmd_fetch(args: argparse.Namespace) -> None:
else: else:
_print(f"克隆: {repo} -> {dest}") _print(f"克隆: {repo} -> {dest}")
subprocess.run( subprocess.run(
["git", "clone", "--branch", ref, "--", repo, str(dest)], ["git", "clone", "--depth", "1", "--branch", ref, "--", repo, str(dest)],
check=True, check=True,
) )
+26 -5
View File
@@ -14,6 +14,8 @@ class SkillChoice:
description: str = "" description: str = ""
installed: bool = False installed: bool = False
readonly_status: str = "" readonly_status: str = ""
children: tuple[str, ...] = ()
indent: int = 0
def fit_to_width(text: str, width: int) -> str: def fit_to_width(text: str, width: int) -> str:
@@ -74,7 +76,19 @@ def select_skills(
except ImportError as exc: except ImportError as exc:
raise RuntimeError("当前 Python 环境不支持 curses,无法打开交互界面") from 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: def draw(stdscr: object) -> set[str] | None:
curses.curs_set(0) curses.curs_set(0)
@@ -122,11 +136,11 @@ def select_skills(
for offset, choice in enumerate(visible[start : start + item_capacity]): for offset, choice in enumerate(visible[start : start + item_capacity]):
index = start + offset index = start + offset
row = 3 + offset * 3 row = 3 + offset * 3
mark = "x" if choice.name in selected else " " mark = mark_for(choice)
description = f" {choice.description}" if choice.description else "" description = f" {choice.description}" if choice.description else ""
focus_attr = curses.A_REVERSE if index == current else curses.A_NORMAL focus_attr = curses.A_REVERSE if index == current else curses.A_NORMAL
parts = [ parts = [
(f"[{mark}] ", focus_attr), (f"{' ' * choice.indent}[{mark}] ", focus_attr),
(choice.name, focus_attr | curses.A_BOLD), (choice.name, focus_attr | curses.A_BOLD),
(f" {choice.kind}", focus_attr | source_attr), (f" {choice.kind}", focus_attr | source_attr),
] ]
@@ -171,8 +185,15 @@ def select_skills(
elif key == curses.KEY_DOWN and visible: elif key == curses.KEY_DOWN and visible:
current = (current + 1) % len(visible) current = (current + 1) % len(visible)
elif key == " " and visible: elif key == " " and visible:
name = visible[current].name choice = visible[current]
selected.symmetric_difference_update({name}) 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 == "/": elif key == "/":
curses.curs_set(1) curses.curs_set(1)
query = _read_query(stdscr, curses, width, row=2) query = _read_query(stdscr, curses, width, row=2)
+11 -1
View File
@@ -100,7 +100,17 @@ def fetch_source(name: str, entry: dict[str, Any]) -> Path:
else: else:
checkout.parent.mkdir(parents=True, exist_ok=True) checkout.parent.mkdir(parents=True, exist_ok=True)
subprocess.run( subprocess.run(
["git", "clone", "--branch", ref, "--", str(repo), str(checkout)], [
"git",
"clone",
"--depth",
"1",
"--branch",
ref,
"--",
str(repo),
str(checkout),
],
check=True, check=True,
) )
return checkout return checkout
+2 -1
View File
@@ -115,7 +115,8 @@ skiff add waza -a codex -g -y
skiff add waza/think -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` 只向项目安装,并只读显示每个 Agent 的全局安装状态;
`skiff select -g` 只向全局安装。取消已勾选项不会卸载,卸载继续使用 `skiff select -g` 只向全局安装。取消已勾选项不会卸载,卸载继续使用
`skiff remove` `skiff remove`
+28
View File
@@ -6,7 +6,9 @@ import sys
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from skiff.sources import fetch_source
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -228,6 +230,32 @@ class CustomSourceTests(unittest.TestCase):
self.assertTrue((checkout / ".git").is_dir()) self.assertTrue((checkout / ".git").is_dir())
self.assertTrue((checkout / "skills" / "release-check" / "SKILL.md").is_file()) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+86 -1
View File
@@ -135,6 +135,49 @@ class SelectorTests(unittest.TestCase):
self.assertTrue(name_call.args[4] & curses.A_BOLD) self.assertTrue(name_call.args[4] & curses.A_BOLD)
self.assertTrue(description_call.args[4] & curses.A_DIM) 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: def test_shared_repo_path_is_same_for_different_skill_entries(self) -> None:
first = {"repo": "https://example.test/skills.git", "ref": "main", "path": "a"} first = {"repo": "https://example.test/skills.git", "ref": "main", "path": "a"}
second = {"repo": "https://example.test/skills.git", "ref": "main", "path": "b"} 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), {}) 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): class SelectCommandTests(unittest.TestCase):
def test_non_tty_exits_with_add_guidance(self) -> None: def test_non_tty_exits_with_add_guidance(self) -> None:
@@ -390,7 +470,12 @@ class SelectCommandTests(unittest.TestCase):
): ):
cli.cmd_select(args) 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")]) self.assertEqual(installed, [("think", "registry:waza")])
def test_select_deduplicates_local_registry_collection_from_owned(self) -> None: def test_select_deduplicates_local_registry_collection_from_owned(self) -> None: