"""skiff CLI 入口。""" from __future__ import annotations import argparse import re import subprocess import sys from pathlib import Path from typing import Any from skiff import __version__ from skiff.paths import ( ALL_TARGETS, EXTERNALS_DIR, REGISTRY_FILE, SKILLS_DIR, SKILLS_HOME, TEMPLATE_DIR, agent_skill_dir, ensure_skills_home, resolve_targets, ) from skiff.project import ( add_skill_to_manifest, iter_manifest_skills, load_manifest, remove_skill_from_manifest, resolve_manifest_skill, save_manifest, ) from skiff.registry import external_skill_path, load_registry, save_registry from skiff.skills import list_owned_skills, owned_skill_path, resolve_skill_source, validate_skill_name from skiff.symlinks import check_link, copy_template, create_link, find_repo_root, remove_link def _print(msg: str = "") -> None: print(msg, file=sys.stdout) def _err(msg: str) -> None: print(msg, file=sys.stderr) def cmd_setup(args: argparse.Namespace) -> None: repo = Path(args.path).resolve() if not (repo / "skills").is_dir(): raise SystemExit(f"不是有效的 skills 仓库(缺少 skills/): {repo}") # 开发场景:仓库本身就在 ~/.skills if repo == SKILLS_HOME.resolve() and SKILLS_HOME.is_dir() and not SKILLS_HOME.is_symlink(): _print(f"skills 仓库已在 ~/.skills: {repo}") return if SKILLS_HOME.is_symlink(): current = SKILLS_HOME.resolve() if current == repo: _print(f"已关联: ~/.skills -> {repo}") return SKILLS_HOME.unlink() elif SKILLS_HOME.exists(): raise SystemExit(f"~/.skills 已存在且不是软链: {SKILLS_HOME}") SKILLS_HOME.parent.mkdir(parents=True, exist_ok=True) SKILLS_HOME.symlink_to(repo) _print(f"已关联: ~/.skills -> {repo}") def cmd_list(args: argparse.Namespace) -> None: ensure_skills_home() owned = list_owned_skills() registry = load_registry() _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})") def _installed_links(name: str, targets: list[str], project_root: Path | None = None) -> list[tuple[str, Path, Path]]: skill_path, _ = resolve_skill_source(name) rows: list[tuple[str, Path, Path]] = [] for target in targets: link = agent_skill_dir(target, project_root=project_root) / name rows.append((target, link, skill_path)) return rows def cmd_status(args: argparse.Namespace) -> None: ensure_skills_home() targets = resolve_targets(args.target) owned = list_owned_skills() registry = load_registry() all_names = owned + [n for n in registry if n not in owned] _print(f"skills 仓库: {SKILLS_HOME.resolve()}") _print(f"targets: {', '.join(targets)}\n") for name in all_names: kind = "owned" if name in owned else "external" _print(f"[{kind}] {name}") try: rows = _installed_links(name, targets) except SystemExit: _print(" (未 fetch)") continue for target, link, expected in rows: status = check_link(link, expected) mark = "✓" if status.ok else "✗" detail = "" if status.ok else f" — {status.issue}" _print(f" {mark} {target}: {link}{detail}") _print("") def _install_skill(name: str, targets: list[str], project_root: Path | None = None) -> None: skill_path, kind = resolve_skill_source(name) for target in targets: link = agent_skill_dir(target, project_root=project_root) / name create_link(link, skill_path) scope = "项目" if project_root else "全局" _print(f"已安装 ({scope}/{target}): {name} -> {skill_path}") def cmd_install(args: argparse.Namespace) -> None: ensure_skills_home() validate_skill_name(args.name) targets = resolve_targets(args.target) _install_skill(args.name, targets) def cmd_uninstall(args: argparse.Namespace) -> None: ensure_skills_home() targets = resolve_targets(args.target) for target in targets: link = agent_skill_dir(target) / args.name if remove_link(link): _print(f"已移除 ({target}): {link}") def cmd_add(args: argparse.Namespace) -> None: ensure_skills_home() validate_skill_name(args.name) registry = load_registry() if args.name in registry: raise SystemExit(f"registry 中已存在: {args.name}") registry[args.name] = { "repo": args.repo, "ref": args.ref, "path": args.path, } save_registry(registry) _print(f"已添加 registry 条目: {args.name}") def cmd_fetch(args: argparse.Namespace) -> None: ensure_skills_home() registry = load_registry() if args.name not in registry: raise SystemExit(f"registry 中不存在: {args.name}") entry = registry[args.name] repo = entry["repo"] ref = entry.get("ref", "main") dest = EXTERNALS_DIR / args.name EXTERNALS_DIR.mkdir(parents=True, exist_ok=True) if dest.exists(): _print(f"更新: {dest}") subprocess.run(["git", "-C", str(dest), "fetch", "--all", "--tags"], check=True) subprocess.run(["git", "-C", str(dest), "checkout", ref], check=True) subprocess.run(["git", "-C", str(dest), "pull", "--ff-only"], check=True) else: _print(f"克隆: {repo} -> {dest}") subprocess.run( ["git", "clone", "--branch", ref, "--", repo, str(dest)], check=True, ) def cmd_install_external(args: argparse.Namespace) -> None: ensure_skills_home() registry = load_registry() if args.name not in registry: raise SystemExit(f"registry 中不存在: {args.name}") path = external_skill_path(args.name, registry[args.name]) if not path.exists(): raise SystemExit(f"请先 fetch: skiff fetch {args.name}") targets = resolve_targets(args.target) for target in targets: link = agent_skill_dir(target) / args.name create_link(link, path) _print(f"已安装外部 ({target}): {args.name} -> {path}") def _project_root(args: argparse.Namespace) -> Path: if args.project: root = Path(args.project).resolve() else: root = find_repo_root() or Path.cwd() return root def cmd_enable(args: argparse.Namespace) -> None: ensure_skills_home() validate_skill_name(args.name) root = _project_root(args) manifest_path = root / ".skills.yaml" # 判断来源 registry = load_registry() if args.name in registry: source = "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") targets = resolve_targets(args.target) _, data = load_manifest(manifest_path) manifest_targets = data.get("targets") 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}") def cmd_disable(args: argparse.Namespace) -> None: root = _project_root(args) manifest_path = root / ".skills.yaml" if not remove_skill_from_manifest(manifest_path, args.name): _print(f"manifest 中不存在: {args.name}") return targets = resolve_targets(args.target) for target in targets: link = agent_skill_dir(target, project_root=root) / args.name if remove_link(link): _print(f"已禁用 ({target}): {link}") def cmd_sync(args: argparse.Namespace) -> None: ensure_skills_home() root = _project_root(args) manifest_path = root / ".skills.yaml" if not manifest_path.is_file(): raise SystemExit(f"未找到 {manifest_path}") _, data = load_manifest(manifest_path) targets = resolve_targets(args.target) manifest_targets = data.get("targets") if manifest_targets: targets = [t for t in targets if t in manifest_targets] for entry in iter_manifest_skills(data): name = entry["name"] skill_path, _ = resolve_manifest_skill(entry) for target in targets: link = agent_skill_dir(target, project_root=root) / name create_link(link, skill_path) _print(f"已同步: {link} -> {skill_path}") def cmd_create(args: argparse.Namespace) -> None: ensure_skills_home() validate_skill_name(args.name) if not TEMPLATE_DIR.is_dir(): raise SystemExit(f"模板目录不存在: {TEMPLATE_DIR}") dst = SKILLS_DIR / args.name copy_template(TEMPLATE_DIR, dst) skill_md = dst / "SKILL.md" content = skill_md.read_text(encoding="utf-8") content = re.sub( r"(^name:\s*)skill-name\s*$", rf"\g<1>{args.name}", content, count=1, flags=re.MULTILINE, ) skill_md.write_text(content, encoding="utf-8") _print(f"已创建 skill: {dst}") _print(f"下一步: 编辑 {skill_md},然后 skiff install {args.name}") def cmd_doctor(args: argparse.Namespace) -> None: ensure_skills_home() targets = resolve_targets(args.target) issues = 0 _print(f"skills 仓库: {SKILLS_HOME.resolve()}") if SKILLS_HOME.is_symlink(): if not SKILLS_HOME.resolve().is_dir(): _err(f"✗ ~/.skills 指向无效路径: {SKILLS_HOME.resolve()}") issues += 1 else: _print("✓ ~/.skills 软链正常") elif SKILLS_HOME.is_dir() and (SKILLS_HOME / "skills").is_dir(): _print("✓ ~/.skills 为本地仓库目录") else: _err("✗ ~/.skills 未正确配置") issues += 1 for name in list_owned_skills(): for target, link, expected in _installed_links(name, targets): status = check_link(link, expected) if status.ok: continue _err(f"✗ [{name}/{target}] {status.issue}: {link}") issues += 1 if args.fix: try: create_link(link, expected) _print(f" 已修复: {link}") except Exception as exc: # noqa: BLE001 _err(f" 修复失败: {exc}") registry = load_registry() for name in registry: ext = external_skill_path(name, registry[name]) if not ext.exists(): _err(f"✗ 外部 skill 未 fetch: {name}") issues += 1 if issues == 0: _print("\n全部正常") else: _print(f"\n发现 {issues} 个问题") if not args.fix: _print("提示: 使用 skiff doctor --fix 尝试自动修复软链") sys.exit(1) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="skiff", description="Agent Skills 安装与管理 CLI", ) parser.add_argument("--version", action="version", version=f"skiff {__version__}") sub = parser.add_subparsers(dest="command", required=True) p_setup = sub.add_parser("setup", help="关联 ~/.skills 到本仓库") p_setup.add_argument("path", help="skills 仓库路径") p_setup.set_defaults(func=cmd_setup) p_list = sub.add_parser("list", help="列出所有 skill") p_list.set_defaults(func=cmd_list) p_status = sub.add_parser("status", help="安装状态总览") p_status.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all") p_status.set_defaults(func=cmd_status) p_install = sub.add_parser("install", help="全局安装 skill(软链)") p_install.add_argument("name", help="skill 名称") p_install.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all") p_install.set_defaults(func=cmd_install) p_uninstall = sub.add_parser("uninstall", help="移除全局软链") p_uninstall.add_argument("name", help="skill 名称") p_uninstall.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all") p_uninstall.set_defaults(func=cmd_uninstall) p_add = sub.add_parser("add", help="添加外部 skill 到 registry") p_add.add_argument("name", help="registry 名称") p_add.add_argument("repo", help="Git 仓库 URL") p_add.add_argument("--ref", default="main", help="分支或 tag(默认 main)") p_add.add_argument("--path", default=".", help="仓库内子路径(默认 .)") p_add.set_defaults(func=cmd_add) p_fetch = sub.add_parser("fetch", help="拉取外部 Git skill") p_fetch.add_argument("name", help="registry 名称") p_fetch.set_defaults(func=cmd_fetch) p_inst_ext = sub.add_parser("install-external", help="安装外部 skill 到 Agent 目录") p_inst_ext.add_argument("name", help="registry 名称") p_inst_ext.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all") p_inst_ext.set_defaults(func=cmd_install_external) p_enable = sub.add_parser("enable", help="项目级启用 skill") p_enable.add_argument("name", help="skill 名称") p_enable.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all") p_enable.add_argument("--project", help="项目根目录(默认自动检测)") p_enable.set_defaults(func=cmd_enable) p_disable = sub.add_parser("disable", help="项目级禁用 skill") p_disable.add_argument("name", help="skill 名称") p_disable.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all") p_disable.add_argument("--project", help="项目根目录(默认自动检测)") p_disable.set_defaults(func=cmd_disable) p_sync = sub.add_parser("sync", help="按 .skills.yaml 重建项目软链") p_sync.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all") p_sync.add_argument("--project", help="项目根目录(默认自动检测)") p_sync.set_defaults(func=cmd_sync) p_create = sub.add_parser("create", help="从 _template 创建自研 skill") p_create.add_argument("name", help="skill 名称") p_create.set_defaults(func=cmd_create) p_doctor = sub.add_parser("doctor", help="软链健康检查") p_doctor.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all") p_doctor.add_argument("--fix", action="store_true", help="自动修复可修复的软链") p_doctor.set_defaults(func=cmd_doctor) return parser def main(argv: list[str] | None = None) -> None: parser = build_parser() args = parser.parse_args(argv) args.func(args) if __name__ == "__main__": main()