feat: add shallow fetch and grouped skill selection
This commit is contained in:
+21
-2
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+26
-5
@@ -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)
|
||||
|
||||
+11
-1
@@ -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
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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()
|
||||
|
||||
+86
-1
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user