feat: add interactive skill selector
This commit is contained in:
+148
-9
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user