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}")
|
_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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user