feat(skiff): align CLI with Vercel skills and fix entry symlink

Replace install/uninstall with add/remove, add publish for git ops in
~/.skills, and resolve bin/skiff symlinks so commands work from any cwd.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-04 12:11:53 +08:00
parent 9a3285fba6
commit f1ed320991
8 changed files with 533 additions and 116 deletions
+255 -105
View File
@@ -7,19 +7,18 @@ import re
import subprocess
import sys
from pathlib import Path
from typing import Any
from skiff import __version__
from skiff.agents import flatten_agent_args, resolve_agent_args
from skiff.gitops import publish as git_publish
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,
@@ -30,7 +29,13 @@ from skiff.project import (
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.skills import (
list_owned_skills,
owned_skill_path,
resolve_skill_source,
skill_description,
validate_skill_name,
)
from skiff.symlinks import check_link, copy_template, create_link, find_repo_root, remove_link
@@ -42,12 +47,94 @@ def _err(msg: str) -> None:
print(msg, file=sys.stderr)
def _warn_deprecated(old: str, new: str) -> None:
_err(f"警告: `{old}` 已弃用,请改用 `{new}`")
def _project_root(explicit: str | None = None) -> Path:
if explicit:
return Path(explicit).resolve()
return find_repo_root() or Path.cwd()
def _collect_skill_names(positional: list[str] | None, flagged: list[str] | None) -> list[str]:
names = list(positional or [])
if flagged:
names.extend(flagged)
if "*" in names:
return list_owned_skills()
return names
def _ensure_external_fetched(name: str) -> None:
registry = load_registry()
if name not in registry:
return
entry = registry[name]
path = external_skill_path(name, entry)
if path.exists():
return
repo = entry["repo"]
ref = entry.get("ref", "main")
dest = EXTERNALS_DIR / name
EXTERNALS_DIR.mkdir(parents=True, exist_ok=True)
_print(f"拉取外部 skill: {name}")
subprocess.run(
["git", "clone", "--branch", ref, "--", repo, str(dest)],
check=True,
)
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)
scope = "全局" if project_root is None else "项目"
_print(f"已安装 ({scope}/{target}): {name} -> {skill_path}")
def _list_installed_names(project_root: Path | None, targets: list[str]) -> list[str]:
names: set[str] = set()
for target in targets:
skill_dir = agent_skill_dir(target, project_root=project_root)
if not skill_dir.is_dir():
continue
for entry in skill_dir.iterdir():
if entry.name.startswith("."):
continue
if entry.is_symlink() or entry.is_dir():
names.add(entry.name)
return sorted(names)
def _remove_skill(
name: str,
targets: list[str],
*,
project_root: Path | None,
) -> int:
removed = 0
for target in targets:
link = agent_skill_dir(target, project_root=project_root) / name
try:
if remove_link(link):
scope = "全局" if project_root is None else "项目"
_print(f"已移除 ({scope}/{target}): {name}")
removed += 1
except FileExistsError as exc:
_err(f"跳过 {link}: {exc}")
return removed
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
@@ -95,13 +182,13 @@ def _installed_links(name: str, targets: list[str], project_root: Path | None =
def cmd_status(args: argparse.Namespace) -> None:
ensure_skills_home()
targets = resolve_targets(args.target)
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]
_print(f"skills 仓库: {SKILLS_HOME.resolve()}")
_print(f"targets: {', '.join(targets)}\n")
_print(f"agents: {', '.join(targets)}\n")
for name in all_names:
kind = "owned" if name in owned else "external"
@@ -119,33 +206,81 @@ def cmd_status(args: argparse.Namespace) -> None:
_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:
def _print_available_skills() -> None:
ensure_skills_home()
validate_skill_name(args.name)
targets = resolve_targets(args.target)
_install_skill(args.name, targets)
owned = list_owned_skills()
if not owned:
_print("~/.skills/skills/ 中没有自研 skill")
return
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}")
_print(f"来源: {SKILLS_DIR}\n")
for name in owned:
desc = skill_description(name)
_print(f" {name}")
if desc:
for line in desc.splitlines():
_print(f" {line}")
_print("")
def cmd_add(args: argparse.Namespace) -> None:
ensure_skills_home()
if args.list_available:
_print_available_skills()
_print("使用 skiff add <name> 安装,或 skiff add <name> -g 全局安装")
return
if args.all:
names = list_owned_skills()
targets = resolve_agent_args(["*"])
else:
names = _collect_skill_names(args.skills, args.skills_flag)
if not names:
raise SystemExit("请指定 skill 名称,或使用 skiff add --list 查看可用 skill")
targets = resolve_agent_args(flatten_agent_args(args.agents))
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)
def cmd_remove(args: argparse.Namespace) -> None:
ensure_skills_home()
project_root = None if args.global_scope else _project_root(args.project)
targets = resolve_agent_args(flatten_agent_args(args.agents))
if args.all:
names = _list_installed_names(project_root, targets)
else:
names = _collect_skill_names(args.skills, args.skills_flag)
if not names:
raise SystemExit("请指定 skill 名称,或使用 skiff remove --all")
total = 0
for name in names:
total += _remove_skill(name, targets, project_root=project_root)
if total == 0:
_print("没有移除任何 skill")
def cmd_publish(args: argparse.Namespace) -> None:
ensure_skills_home()
paths = args.paths or ["."]
git_publish(
paths=paths,
message=args.message,
push=args.push,
no_commit=args.no_commit,
)
def cmd_registry_add(args: argparse.Namespace) -> None:
ensure_skills_home()
validate_skill_name(args.name)
registry = load_registry()
if args.name in registry:
@@ -186,48 +321,22 @@ def cmd_fetch(args: argparse.Namespace) -> None:
)
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:
_warn_deprecated("skiff enable", "skiff add <name>")
ensure_skills_home()
validate_skill_name(args.name)
root = _project_root(args)
root = _project_root(args.project)
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) # 验证存在
owned_skill_path(args.name)
add_skill_to_manifest(manifest_path, args.name, source="owned")
targets = resolve_targets(args.target)
targets = resolve_agent_args(flatten_agent_args(args.agents))
_, data = load_manifest(manifest_path)
manifest_targets = data.get("targets")
if manifest_targets:
@@ -238,28 +347,26 @@ def cmd_enable(args: argparse.Namespace) -> None:
def cmd_disable(args: argparse.Namespace) -> None:
root = _project_root(args)
_warn_deprecated("skiff disable", "skiff remove <name>")
root = _project_root(args.project)
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}")
targets = resolve_agent_args(flatten_agent_args(args.agents))
_remove_skill(args.name, targets, project_root=root)
def cmd_sync(args: argparse.Namespace) -> None:
ensure_skills_home()
root = _project_root(args)
root = _project_root(args.project)
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)
targets = resolve_agent_args(flatten_agent_args(args.agents))
manifest_targets = data.get("targets")
if manifest_targets:
targets = [t for t in targets if t in manifest_targets]
@@ -293,12 +400,14 @@ def cmd_create(args: argparse.Namespace) -> None:
)
skill_md.write_text(content, encoding="utf-8")
_print(f"已创建 skill: {dst}")
_print(f"下一步: 编辑 {skill_md},然后 skiff install {args.name}")
_print(f"下一步: 编辑 {skill_md}")
_print(f" skiff publish skills/{args.name} -m \"add {args.name}\" --push")
_print(f" skiff add {args.name} -a cursor -g -y")
def cmd_doctor(args: argparse.Namespace) -> None:
ensure_skills_home()
targets = resolve_targets(args.target)
targets = resolve_agent_args(flatten_agent_args(args.agents))
issues = 0
_print(f"skills 仓库: {SKILLS_HOME.resolve()}")
@@ -344,67 +453,108 @@ def cmd_doctor(args: argparse.Namespace) -> None:
sys.exit(1)
def _add_common_flags(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"-a",
"--agent",
dest="agents",
nargs="+",
action="append",
metavar="AGENT",
help="目标 agentcursor、claude、claude-code、codex、*",
)
parser.add_argument(
"-g",
"--global",
dest="global_scope",
action="store_true",
help="全局安装/卸载(默认当前项目)",
)
parser.add_argument("-y", "--yes", action="store_true", help="跳过确认(兼容 Vercel skills")
parser.add_argument("--project", help="项目根目录(默认自动检测或当前目录)")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="skiff",
description="Agent Skills 安装与管理 CLI",
description="自研 Agent Skills 安装与管理 CLI(接口对齐 Vercel skills",
)
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 = sub.add_parser("setup", help="关联 ~/.skills 到 skills 仓库")
p_setup.add_argument("path", nargs="?", default=str(SKILLS_HOME), help="仓库路径(默认 ~/.skills")
p_setup.set_defaults(func=cmd_setup)
p_list = sub.add_parser("list", help="列出所有 skill")
p_list = sub.add_parser("list", help="列出 ~/.skills 中的 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.add_argument("-a", "--agent", dest="agents", nargs="+", action="append", metavar="AGENT")
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 = sub.add_parser(
"add",
help="安装 skill 到 agent(自研或 registry",
description="安装 ~/.skills 中的自研 skill,或 registry 中的外部 skill",
)
p_add.add_argument("skills", nargs="*", metavar="skill", help="skill 名称(可多个)")
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")
_add_common_flags(p_add)
p_add.set_defaults(func=cmd_add)
p_fetch = sub.add_parser("fetch", help="拉取外部 Git skill")
p_remove = sub.add_parser(
"remove",
aliases=["rm", "r"],
help="从 agent 移除 skill",
)
p_remove.add_argument("skills", nargs="*", metavar="skill", help="skill 名称(可多个)")
p_remove.add_argument("-s", "--skill", dest="skills_flag", action="append", metavar="SKILL")
p_remove.add_argument("--all", action="store_true", help="移除当前范围内全部 skill")
_add_common_flags(p_remove)
p_remove.set_defaults(func=cmd_remove)
p_publish = sub.add_parser(
"publish",
help="在 ~/.skills 内 git add / commit / push",
)
p_publish.add_argument("paths", nargs="*", help="要提交的路径(默认 .")
p_publish.add_argument("-m", "--message", help="commit 说明")
p_publish.add_argument("--push", action="store_true", help="commit 后 push")
p_publish.add_argument("--no-commit", action="store_true", help="只 git add,不 commit")
p_publish.set_defaults(func=cmd_publish)
p_registry = sub.add_parser("registry", help="管理 registry.yaml 中的外部 skill")
registry_sub = p_registry.add_subparsers(dest="registry_command", required=True)
p_reg_add = registry_sub.add_parser("add", help="注册外部 Git skill")
p_reg_add.add_argument("name", help="registry 名称")
p_reg_add.add_argument("repo", help="Git 仓库 URL")
p_reg_add.add_argument("--ref", default="main", help="分支或 tag(默认 main")
p_reg_add.add_argument("--path", default=".", help="仓库内子路径(默认 .")
p_reg_add.set_defaults(func=cmd_registry_add)
p_fetch = sub.add_parser("fetch", help="拉取/更新 registry 中的外部 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 = sub.add_parser("enable", help=argparse.SUPPRESS)
p_enable.add_argument("name")
p_enable.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
p_enable.add_argument("--project")
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 = sub.add_parser("disable", help=argparse.SUPPRESS)
p_disable.add_argument("name")
p_disable.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
p_disable.add_argument("--project")
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.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
p_sync.add_argument("--project")
p_sync.set_defaults(func=cmd_sync)
p_create = sub.add_parser("create", help="从 _template 创建自研 skill")
@@ -412,7 +562,7 @@ def build_parser() -> argparse.ArgumentParser:
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("-a", "--agent", dest="agents", nargs="+", action="append")
p_doctor.add_argument("--fix", action="store_true", help="自动修复可修复的软链")
p_doctor.set_defaults(func=cmd_doctor)