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
+148 -9
View File
@@ -30,7 +30,13 @@ from skiff.project import (
resolve_manifest_skill,
save_manifest,
)
from skiff.registry import external_skill_path, load_registry, save_registry
from skiff.registry import (
external_checkout_path,
external_skill_path,
load_registry,
save_registry,
)
from skiff.selector import SkillChoice, select_skills
from skiff.skills import (
list_owned_skills,
owned_skill_path,
@@ -77,13 +83,17 @@ def _ensure_external_fetched(name: str) -> None:
entry = registry[name]
path = external_skill_path(name, entry)
if path.exists():
if (path / "SKILL.md").is_file():
return
repo = entry["repo"]
ref = entry.get("ref", "main")
dest = EXTERNALS_DIR / name
EXTERNALS_DIR.mkdir(parents=True, exist_ok=True)
dest = external_checkout_path(name, entry)
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
raise SystemExit(
f"外部仓库已存在但 skill 路径无效: {external_skill_path(name, entry)}"
)
_print(f"拉取外部 skill: {name}")
subprocess.run(
["git", "clone", "--branch", ref, "--", repo, str(dest)],
@@ -94,9 +104,25 @@ def _ensure_external_fetched(name: str) -> None:
def _install_skill(name: str, targets: list[str], project_root: Path | None = None) -> None:
_ensure_external_fetched(name)
skill_path, _ = resolve_skill_source(name)
for target in targets:
link = agent_skill_dir(target, project_root=project_root) / name
create_link(link, skill_path)
links = [
(target, agent_skill_dir(target, project_root=project_root) / name)
for target in targets
]
original: list[tuple[Path, str | None]] = []
try:
for _, link in links:
previous = str(link.readlink()) if link.is_symlink() else None
original.append((link, previous))
create_link(link, skill_path)
except Exception:
for link, previous in reversed(original):
if link.is_symlink():
link.unlink()
if previous is not None:
link.symlink_to(previous)
raise
for target, _ in links:
scope = "全局" if project_root is None else "项目"
_print(f"已安装 ({scope}/{target}): {name} -> {skill_path}")
@@ -115,6 +141,19 @@ def _list_installed_names(project_root: Path | None, targets: list[str]) -> list
return sorted(names)
def _list_fully_installed_names(
project_root: Path | None,
targets: list[str],
) -> list[str]:
installed_by_target = [
set(_list_installed_names(project_root, [target]))
for target in targets
]
if not installed_by_target:
return []
return sorted(set.intersection(*installed_by_target))
def _remove_skill(
name: str,
targets: list[str],
@@ -237,6 +276,96 @@ def cmd_add(args: argparse.Namespace) -> None:
_install_skill(name, targets, project_root=project_root)
def cmd_select(args: argparse.Namespace) -> None:
ensure_skills_home()
if not sys.stdin.isatty() or not sys.stdout.isatty():
raise SystemExit(
"`skiff select` 需要交互式终端;非交互环境请使用 `skiff add <name>...`"
)
targets = resolve_agent_args(flatten_agent_args(args.agents))
project_root = None if args.global_scope else _project_root(args.project)
installed = set(_list_fully_installed_names(project_root, targets))
registry = load_registry()
owned_names = list_owned_skills()
for name in [*owned_names, *registry]:
validate_skill_name(name)
collisions = set(owned_names) & set(registry)
for name in sorted(collisions):
_err(f"警告: registry 条目与 owned skill 同名,已忽略 external: {name}")
choices = [
SkillChoice(
name=name,
kind="owned",
description=skill_description(name) or "",
installed=name in installed,
)
for name in owned_names
]
choices.extend(
SkillChoice(
name=name,
kind="external",
description=str(entry.get("description", "")),
installed=name in installed,
)
for name, entry in registry.items()
if name not in collisions
)
try:
selected = select_skills(choices)
except (RuntimeError, OSError) as exc:
raise SystemExit(str(exc)) from exc
if selected is None:
_print("已取消,未修改环境")
return
names = sorted(selected - installed)
failures: list[tuple[str, str]] = []
manifest_path = project_root / ".skills.yaml" if project_root else None
successful = set(selected & installed)
for name in names:
try:
_install_skill(name, targets, project_root=project_root)
successful.add(name)
except (OSError, subprocess.CalledProcessError, SystemExit) as exc:
failures.append((name, str(exc)))
_err(f"{name}: {exc}")
if manifest_path is not None:
entry_targets = targets if args.agents else None
for name in sorted(successful):
entry = registry.get(name)
if name in owned_names:
add_skill_to_manifest(
manifest_path,
name,
source="owned",
extra={"targets": entry_targets} if entry_targets else None,
)
else:
extra = {"ref": entry.get("ref", "main")}
if entry_targets:
extra["targets"] = entry_targets
add_skill_to_manifest(
manifest_path,
name,
source="registry",
extra=extra,
)
installed_count = len(names) - len(failures)
if not names:
_print("没有需要安装的新 skill")
else:
_print(f"安装完成: {installed_count} 成功,{len(failures)} 失败")
if failures:
raise SystemExit(1)
def cmd_remove(args: argparse.Namespace) -> None:
ensure_skills_home()
project_root = None if args.global_scope else _project_root(args.project)
@@ -294,7 +423,7 @@ def cmd_fetch(args: argparse.Namespace) -> None:
entry = registry[args.name]
repo = entry["repo"]
ref = entry.get("ref", "main")
dest = EXTERNALS_DIR / args.name
dest = external_checkout_path(args.name, entry)
EXTERNALS_DIR.mkdir(parents=True, exist_ok=True)
@@ -364,7 +493,10 @@ def cmd_sync(args: argparse.Namespace) -> None:
for entry in iter_manifest_skills(data):
name = entry["name"]
skill_path, _ = resolve_manifest_skill(entry)
for target in targets:
skill_targets = targets
if entry.get("targets"):
skill_targets = [target for target in targets if target in entry["targets"]]
for target in skill_targets:
link = agent_skill_dir(target, project_root=root) / name
create_link(link, skill_path)
_print(f"已同步: {link} -> {skill_path}")
@@ -555,6 +687,13 @@ def build_parser() -> argparse.ArgumentParser:
_add_common_flags(p_add)
p_add.set_defaults(func=cmd_add)
p_select = sub.add_parser(
"select",
help="交互式选择并批量安装 skill",
)
_add_common_flags(p_select)
p_select.set_defaults(func=cmd_select)
p_remove = sub.add_parser(
"remove",
aliases=["rm", "r"],