merge: custom skill sources
# Conflicts: # skiff/README.md # skiff/cli.py # skiff/project.py
This commit is contained in:
+259
-38
@@ -16,6 +16,7 @@ from skiff.paths import (
|
||||
ALL_TARGETS,
|
||||
DRAFTS_DIR,
|
||||
EXTERNALS_DIR,
|
||||
CONFIG_FILE,
|
||||
SKILLS_DIR,
|
||||
SKILLS_HOME,
|
||||
TEMPLATE_DIR,
|
||||
@@ -38,13 +39,23 @@ from skiff.registry import (
|
||||
)
|
||||
from skiff.selector import SkillChoice, select_skills
|
||||
from skiff.skills import (
|
||||
list_custom_skills,
|
||||
list_owned_skills,
|
||||
owned_skill_path,
|
||||
resolve_skill_source,
|
||||
split_skill_spec,
|
||||
skill_description,
|
||||
validate_skill_dir,
|
||||
validate_skill_name,
|
||||
)
|
||||
from skiff.sources import (
|
||||
fetch_source,
|
||||
load_sources,
|
||||
save_sources,
|
||||
source_checkout,
|
||||
source_skills_root,
|
||||
validate_source_name,
|
||||
)
|
||||
from skiff.yaml_io import safe_dump
|
||||
from skiff.symlinks import check_link, copy_template, create_link, find_repo_root, remove_link
|
||||
|
||||
@@ -76,7 +87,18 @@ def _collect_skill_names(positional: list[str] | None, flagged: list[str] | None
|
||||
return names
|
||||
|
||||
|
||||
def _ensure_external_fetched(name: str) -> None:
|
||||
def _ensure_source_fetched(name: str, source: str | None = None) -> None:
|
||||
name, source = split_skill_spec(name, source)
|
||||
sources = load_sources()
|
||||
if source in sources:
|
||||
root = source_skills_root(source, sources[source])
|
||||
if not root.is_dir():
|
||||
_print(f"拉取 source: {source}")
|
||||
fetch_source(source, sources[source])
|
||||
return
|
||||
if source not in (None, "registry"):
|
||||
return
|
||||
|
||||
registry = load_registry()
|
||||
if name not in registry:
|
||||
return
|
||||
@@ -101,9 +123,16 @@ 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)
|
||||
def _install_skill(
|
||||
name: str,
|
||||
targets: list[str],
|
||||
project_root: Path | None = None,
|
||||
*,
|
||||
source: str | None = None,
|
||||
) -> str:
|
||||
name, source = split_skill_spec(name, source)
|
||||
_ensure_source_fetched(name, source)
|
||||
skill_path, resolved_source = resolve_skill_source(name, source=source)
|
||||
links = [
|
||||
(target, agent_skill_dir(target, project_root=project_root) / name)
|
||||
for target in targets
|
||||
@@ -124,7 +153,8 @@ def _install_skill(name: str, targets: list[str], project_root: Path | None = No
|
||||
|
||||
for target, _ in links:
|
||||
scope = "全局" if project_root is None else "项目"
|
||||
_print(f"已安装 ({scope}/{target}): {name} -> {skill_path}")
|
||||
_print(f"已安装 ({scope}/{target}): {resolved_source}/{name} -> {skill_path}")
|
||||
return resolved_source
|
||||
|
||||
|
||||
def _list_installed_names(project_root: Path | None, targets: list[str]) -> list[str]:
|
||||
@@ -177,18 +207,32 @@ def cmd_list(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
owned = list_owned_skills()
|
||||
registry = load_registry()
|
||||
custom = (
|
||||
{}
|
||||
if args.source in ("owned", "registry")
|
||||
else list_custom_skills(args.source)
|
||||
)
|
||||
|
||||
_print("自研 (owned):")
|
||||
for name in owned:
|
||||
_print(f" {name}")
|
||||
if args.source in (None, "owned"):
|
||||
_print("自研 (owned):")
|
||||
for name in owned:
|
||||
_print(f" {name}")
|
||||
|
||||
_print("\n外部 (registry):")
|
||||
if not registry:
|
||||
_print(" (无)")
|
||||
else:
|
||||
for name, entry in registry.items():
|
||||
repo = entry.get("repo", "?")
|
||||
_print(f" {name} ({repo})")
|
||||
if args.source in (None, "registry"):
|
||||
_print("\n外部 (registry):")
|
||||
if not registry:
|
||||
_print(" (无)")
|
||||
else:
|
||||
for name, entry in registry.items():
|
||||
repo = entry.get("repo", "?")
|
||||
_print(f" {name} ({repo})")
|
||||
|
||||
for source, names in custom.items():
|
||||
_print(f"\n自定义 ({source}):")
|
||||
if not names:
|
||||
_print(" (无,或 source 尚未 fetch)")
|
||||
for name in names:
|
||||
_print(f" {name}")
|
||||
|
||||
|
||||
def cmd_bootstrap(args: argparse.Namespace) -> None:
|
||||
@@ -200,8 +244,14 @@ def cmd_bootstrap(args: argparse.Namespace) -> None:
|
||||
_print("已安装项目 skill 到所有 agent")
|
||||
|
||||
|
||||
def _installed_links(name: str, targets: list[str], project_root: Path | None = None) -> list[tuple[str, Path, Path]]:
|
||||
skill_path, _ = resolve_skill_source(name)
|
||||
def _installed_links(
|
||||
name: str,
|
||||
targets: list[str],
|
||||
project_root: Path | None = None,
|
||||
*,
|
||||
source: str | None = None,
|
||||
) -> list[tuple[str, Path, Path]]:
|
||||
skill_path, _ = resolve_skill_source(name, source=source)
|
||||
rows: list[tuple[str, Path, Path]] = []
|
||||
for target in targets:
|
||||
link = agent_skill_dir(target, project_root=project_root) / name
|
||||
@@ -214,16 +264,22 @@ def cmd_status(args: argparse.Namespace) -> None:
|
||||
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
||||
owned = list_owned_skills()
|
||||
registry = load_registry()
|
||||
all_names = owned + [n for n in registry if n not in owned]
|
||||
custom = list_custom_skills()
|
||||
entries = [("owned", name) for name in owned]
|
||||
entries.extend(("registry", name) for name in registry)
|
||||
entries.extend((source, name) for source, names in custom.items() for name in names)
|
||||
|
||||
_print(f"skills 仓库: {SKILLS_HOME.resolve()}")
|
||||
_print(f"agents: {', '.join(targets)}\n")
|
||||
|
||||
for name in all_names:
|
||||
kind = "owned" if name in owned else "external"
|
||||
_print(f"[{kind}] {name}")
|
||||
for source, name in entries:
|
||||
_print(f"[{source}] {name}")
|
||||
try:
|
||||
rows = _installed_links(name, targets)
|
||||
skill_path, _ = resolve_skill_source(name, source=source)
|
||||
rows = [
|
||||
(target, agent_skill_dir(target) / name, skill_path)
|
||||
for target in targets
|
||||
]
|
||||
except SystemExit:
|
||||
_print(" (未 fetch)")
|
||||
continue
|
||||
@@ -256,12 +312,24 @@ def cmd_add(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
|
||||
if args.list_available:
|
||||
_print_available_skills()
|
||||
if args.source:
|
||||
cmd_list(argparse.Namespace(source=args.source))
|
||||
else:
|
||||
_print_available_skills()
|
||||
_print("使用 skiff add <name> 安装,或 skiff add <name> -g 全局安装")
|
||||
return
|
||||
|
||||
if args.all:
|
||||
names = list_owned_skills()
|
||||
if args.source:
|
||||
if args.source == "owned":
|
||||
names = list_owned_skills()
|
||||
elif args.source == "registry":
|
||||
names = list(load_registry())
|
||||
else:
|
||||
_ensure_source_fetched("", args.source)
|
||||
names = list_custom_skills(args.source)[args.source]
|
||||
else:
|
||||
names = list_owned_skills()
|
||||
targets = resolve_agent_args(["*"])
|
||||
else:
|
||||
names = _collect_skill_names(args.skills, args.skills_flag)
|
||||
@@ -272,8 +340,20 @@ def cmd_add(args: argparse.Namespace) -> None:
|
||||
project_root = None if args.global_scope else _project_root(args.project)
|
||||
|
||||
for name in names:
|
||||
validate_skill_name(name)
|
||||
_install_skill(name, targets, project_root=project_root)
|
||||
skill_name, source = split_skill_spec(name, args.source)
|
||||
validate_skill_name(skill_name)
|
||||
resolved_source = _install_skill(
|
||||
skill_name,
|
||||
targets,
|
||||
project_root=project_root,
|
||||
source=source,
|
||||
)
|
||||
if project_root is not None:
|
||||
add_skill_to_manifest(
|
||||
project_root / ".skills.yaml",
|
||||
skill_name,
|
||||
source=resolved_source,
|
||||
)
|
||||
|
||||
|
||||
def cmd_select(args: argparse.Namespace) -> None:
|
||||
@@ -313,6 +393,10 @@ def cmd_select(args: argparse.Namespace) -> None:
|
||||
for name, entry in registry.items()
|
||||
if name not in collisions
|
||||
)
|
||||
choice_sources = {
|
||||
**{name: "owned" for name in owned_names},
|
||||
**{name: "registry" for name in registry if name not in collisions},
|
||||
}
|
||||
|
||||
try:
|
||||
selected = select_skills(choices)
|
||||
@@ -329,7 +413,12 @@ def cmd_select(args: argparse.Namespace) -> None:
|
||||
successful = set(selected & installed)
|
||||
for name in names:
|
||||
try:
|
||||
_install_skill(name, targets, project_root=project_root)
|
||||
_install_skill(
|
||||
name,
|
||||
targets,
|
||||
project_root=project_root,
|
||||
source=choice_sources[name],
|
||||
)
|
||||
successful.add(name)
|
||||
except (OSError, subprocess.CalledProcessError, SystemExit) as exc:
|
||||
failures.append((name, str(exc)))
|
||||
@@ -440,6 +529,94 @@ def cmd_fetch(args: argparse.Namespace) -> None:
|
||||
)
|
||||
|
||||
|
||||
def cmd_source_add(args: argparse.Namespace) -> None:
|
||||
validate_source_name(args.name)
|
||||
sources = load_sources()
|
||||
if args.name in sources:
|
||||
raise SystemExit(f"source 已存在: {args.name}")
|
||||
if bool(args.repo) == bool(args.local):
|
||||
raise SystemExit("必须且只能指定 Git repo 或 --local")
|
||||
entry: dict[str, str] = {"skills_path": args.skills_path}
|
||||
if args.local:
|
||||
local = Path(args.local).expanduser().resolve()
|
||||
if not local.is_dir():
|
||||
raise SystemExit(f"本地仓库不存在: {local}")
|
||||
entry["local_path"] = str(local)
|
||||
else:
|
||||
entry.update({"repo": args.repo, "ref": args.ref})
|
||||
if args.checkout:
|
||||
entry["checkout"] = str(Path(args.checkout).expanduser().resolve())
|
||||
# 注册前先校验路径不能逃出仓库。
|
||||
source_skills_root(args.name, entry)
|
||||
if not args.local and not args.no_fetch:
|
||||
_print(f"克隆 source: {args.name}")
|
||||
fetch_source(args.name, entry)
|
||||
sources[args.name] = entry
|
||||
save_sources(sources)
|
||||
_print(f"已添加 source: {args.name} ({CONFIG_FILE})")
|
||||
|
||||
|
||||
def cmd_source_list(args: argparse.Namespace) -> None:
|
||||
del args
|
||||
sources = load_sources()
|
||||
if not sources:
|
||||
_print("未配置 custom source")
|
||||
return
|
||||
for name, entry in sources.items():
|
||||
kind = "local" if entry.get("local_path") else "git"
|
||||
_print(
|
||||
f"{name} [{kind}] checkout={source_checkout(name, entry)} "
|
||||
f"skills={source_skills_root(name, entry)}"
|
||||
)
|
||||
|
||||
|
||||
def cmd_source_show(args: argparse.Namespace) -> None:
|
||||
sources = load_sources()
|
||||
if args.name not in sources:
|
||||
raise SystemExit(f"未配置 source: {args.name}")
|
||||
entry = sources[args.name]
|
||||
_print(f"name: {args.name}")
|
||||
for key, value in entry.items():
|
||||
_print(f"{key}: {value}")
|
||||
_print(f"checkout: {source_checkout(args.name, entry)}")
|
||||
_print(f"skills_root: {source_skills_root(args.name, entry)}")
|
||||
|
||||
|
||||
def cmd_source_fetch(args: argparse.Namespace) -> None:
|
||||
sources = load_sources()
|
||||
names = list(sources) if args.all else [args.name]
|
||||
if not names or names == [None]:
|
||||
raise SystemExit("请指定 source 名称,或使用 --all")
|
||||
for name in names:
|
||||
if name not in sources:
|
||||
raise SystemExit(f"未配置 source: {name}")
|
||||
_print(f"更新 source: {name}")
|
||||
fetch_source(name, sources[name])
|
||||
|
||||
|
||||
def cmd_source_remove(args: argparse.Namespace) -> None:
|
||||
sources = load_sources()
|
||||
if args.name not in sources:
|
||||
raise SystemExit(f"未配置 source: {args.name}")
|
||||
entry = sources[args.name]
|
||||
if args.delete_checkout and entry.get("local_path"):
|
||||
raise SystemExit("不会删除 --local 指定的仓库")
|
||||
checkout = source_checkout(args.name, entry)
|
||||
if args.delete_checkout and (
|
||||
checkout in (Path("/"), Path.home().resolve()) or not (checkout / ".git").is_dir()
|
||||
):
|
||||
raise SystemExit(f"拒绝删除不安全或非 Git checkout: {checkout}")
|
||||
sources.pop(args.name)
|
||||
save_sources(sources)
|
||||
_print(f"已移除 source 配置: {args.name}")
|
||||
if args.delete_checkout:
|
||||
import shutil
|
||||
|
||||
if checkout.is_dir():
|
||||
shutil.rmtree(checkout)
|
||||
_print(f"已删除 checkout(不可恢复): {checkout}")
|
||||
|
||||
|
||||
def cmd_enable(args: argparse.Namespace) -> None:
|
||||
_warn_deprecated("skiff enable", "skiff add <name>")
|
||||
ensure_skills_home()
|
||||
@@ -447,13 +624,10 @@ def cmd_enable(args: argparse.Namespace) -> None:
|
||||
root = _project_root(args.project)
|
||||
manifest_path = root / ".skills.yaml"
|
||||
|
||||
registry = load_registry()
|
||||
if args.name in registry:
|
||||
extra = {"source": "registry", "ref": registry[args.name].get("ref", "main")}
|
||||
add_skill_to_manifest(manifest_path, args.name, source="registry", extra=extra)
|
||||
else:
|
||||
owned_skill_path(args.name)
|
||||
add_skill_to_manifest(manifest_path, args.name, source="owned")
|
||||
name, source = split_skill_spec(args.name)
|
||||
_ensure_source_fetched(name, source)
|
||||
_, resolved_source = resolve_skill_source(name, source=source)
|
||||
add_skill_to_manifest(manifest_path, name, source=resolved_source)
|
||||
|
||||
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
||||
_, data = load_manifest(manifest_path)
|
||||
@@ -461,8 +635,8 @@ def cmd_enable(args: argparse.Namespace) -> None:
|
||||
if manifest_targets:
|
||||
targets = [t for t in targets if t in manifest_targets]
|
||||
|
||||
_install_skill(args.name, targets, project_root=root)
|
||||
_print(f"已启用项目 skill: {args.name} @ {root}")
|
||||
_install_skill(name, targets, project_root=root, source=resolved_source)
|
||||
_print(f"已启用项目 skill: {resolved_source}/{name} @ {root}")
|
||||
|
||||
|
||||
def cmd_disable(args: argparse.Namespace) -> None:
|
||||
@@ -492,6 +666,7 @@ def cmd_sync(args: argparse.Namespace) -> None:
|
||||
|
||||
for entry in iter_manifest_skills(data):
|
||||
name = entry["name"]
|
||||
_ensure_source_fetched(name, entry.get("source"))
|
||||
skill_path, _ = resolve_manifest_skill(entry)
|
||||
skill_targets = targets
|
||||
if entry.get("targets"):
|
||||
@@ -603,7 +778,7 @@ def cmd_doctor(args: argparse.Namespace) -> None:
|
||||
issues += 1
|
||||
|
||||
for name in list_owned_skills():
|
||||
for target, link, expected in _installed_links(name, targets):
|
||||
for target, link, expected in _installed_links(name, targets, source="owned"):
|
||||
status = check_link(link, expected)
|
||||
if status.ok:
|
||||
continue
|
||||
@@ -623,6 +798,16 @@ def cmd_doctor(args: argparse.Namespace) -> None:
|
||||
_err(f"✗ 外部 skill 未 fetch: {name}")
|
||||
issues += 1
|
||||
|
||||
for source, entry in load_sources().items():
|
||||
checkout = source_checkout(source, entry)
|
||||
root = source_skills_root(source, entry)
|
||||
if not checkout.is_dir():
|
||||
_err(f"✗ source checkout 不存在: {source} -> {checkout}")
|
||||
issues += 1
|
||||
elif not root.is_dir():
|
||||
_err(f"✗ source skills_path 不存在: {source} -> {root}")
|
||||
issues += 1
|
||||
|
||||
if issues == 0:
|
||||
_print("\n全部正常")
|
||||
else:
|
||||
@@ -668,7 +853,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
p_bootstrap.set_defaults(func=cmd_bootstrap)
|
||||
|
||||
p_list = sub.add_parser("list", help="列出 ~/.skills 中的 skill 目录")
|
||||
p_list = sub.add_parser("list", help="列出所有 source 中的 skill")
|
||||
p_list.add_argument("--source", help="只列出指定来源(owned、registry 或 custom source)")
|
||||
p_list.set_defaults(func=cmd_list)
|
||||
|
||||
p_status = sub.add_parser("status", help="安装状态总览")
|
||||
@@ -684,6 +870,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
p_add.add_argument("-s", "--skill", dest="skills_flag", action="append", metavar="SKILL")
|
||||
p_add.add_argument("--list", dest="list_available", action="store_true", help="列出可用自研 skill,不安装")
|
||||
p_add.add_argument("--all", action="store_true", help="安装全部自研 skill 到全部 agent")
|
||||
p_add.add_argument("--source", help="指定 skill 来源(也可使用 source/name)")
|
||||
_add_common_flags(p_add)
|
||||
p_add.set_defaults(func=cmd_add)
|
||||
|
||||
@@ -728,6 +915,40 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
p_fetch.add_argument("name", help="registry 名称")
|
||||
p_fetch.set_defaults(func=cmd_fetch)
|
||||
|
||||
p_source = sub.add_parser("source", help="管理包含多个 skills 的自定义仓库")
|
||||
source_sub = p_source.add_subparsers(dest="source_command", required=True)
|
||||
|
||||
p_source_add = source_sub.add_parser("add", help="注册 Git 或本地 skill source")
|
||||
p_source_add.add_argument("name", help="source 名称")
|
||||
p_source_add.add_argument("repo", nargs="?", help="Git 仓库 URL")
|
||||
p_source_add.add_argument("--local", help="已有本地仓库路径")
|
||||
p_source_add.add_argument("--ref", default="main", help="Git 分支或 tag(默认 main)")
|
||||
p_source_add.add_argument("--checkout", help="Git checkout 路径")
|
||||
p_source_add.add_argument("--skills-path", default="skills", help="仓库内 skills 父目录")
|
||||
p_source_add.add_argument("--no-fetch", action="store_true", help="仅写配置,不立即 clone")
|
||||
p_source_add.set_defaults(func=cmd_source_add)
|
||||
|
||||
p_source_list = source_sub.add_parser("list", help="列出已配置 source")
|
||||
p_source_list.set_defaults(func=cmd_source_list)
|
||||
|
||||
p_source_show = source_sub.add_parser("show", help="显示 source 详情")
|
||||
p_source_show.add_argument("name")
|
||||
p_source_show.set_defaults(func=cmd_source_show)
|
||||
|
||||
p_source_fetch = source_sub.add_parser("fetch", help="克隆或更新 source")
|
||||
p_source_fetch.add_argument("name", nargs="?")
|
||||
p_source_fetch.add_argument("--all", action="store_true")
|
||||
p_source_fetch.set_defaults(func=cmd_source_fetch)
|
||||
|
||||
p_source_remove = source_sub.add_parser("remove", help="移除 source 配置")
|
||||
p_source_remove.add_argument("name")
|
||||
p_source_remove.add_argument(
|
||||
"--delete-checkout",
|
||||
action="store_true",
|
||||
help="同时永久删除 skiff 管理的 checkout",
|
||||
)
|
||||
p_source_remove.set_defaults(func=cmd_source_remove)
|
||||
|
||||
p_enable = sub.add_parser("enable", help=argparse.SUPPRESS)
|
||||
p_enable.add_argument("name")
|
||||
p_enable.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
|
||||
|
||||
Reference in New Issue
Block a user