diff --git a/bin/skiff b/bin/skiff index bacc148..cb56556 100755 --- a/bin/skiff +++ b/bin/skiff @@ -1,5 +1,14 @@ #!/usr/bin/env bash set -euo pipefail -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# 跟随软链,确保从 ~/.local/bin/skiff 调用时仍能找到仓库根目录 +SCRIPT="${BASH_SOURCE[0]}" +while [ -L "$SCRIPT" ]; do + link_dir="$(cd "$(dirname "$SCRIPT")" && pwd)" + SCRIPT="$(readlink "$SCRIPT")" + [[ "$SCRIPT" != /* ]] && SCRIPT="$link_dir/$SCRIPT" +done +REPO_ROOT="$(cd "$(dirname "$SCRIPT")/.." && pwd)" + export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:$PYTHONPATH}" exec python3 -m skiff "$@" diff --git a/skiff/README.md b/skiff/README.md index ae112b9..bf823f2 100644 --- a/skiff/README.md +++ b/skiff/README.md @@ -9,7 +9,29 @@ cd /path/to/skills # 本仓库根目录 ./install.sh # 软链到 ~/.local/bin/skiff ``` -确保 `~/.local/bin` 在 `PATH` 中。开发时也可直接运行: +确保 `~/.local/bin` 在 `PATH` 中。 + +## 命令风格 + +接口对齐 [Vercel skills CLI](https://github.com/vercel-labs/skills) 的 `add` / `remove`,专用于 **~/.skills 自研 skill**。 + +```bash +# 浏览可用自研 skill +skiff add --list + +# 装到当前项目 / 全局 +skiff add discussion-notes -a cursor -y +skiff add discussion-notes -a cursor -g -y + +# 卸载 +skiff remove discussion-notes -a cursor -y +skiff rm discussion-notes -g -y + +# 改完 skill 后提交推送(在任意目录执行,操作 ~/.skills) +skiff publish skills/discussion-notes -m "update discussion-notes" --push +``` + +开发时也可直接运行: ```bash PYTHONPATH=/path/to/skills python3 -m skiff @@ -42,8 +64,12 @@ skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库 | 命令 | 说明 | |------|------| -| `skiff install [--target all]` | 软链到 Agent 全局目录 | -| `skiff uninstall [--target all]` | 移除软链 | +| `skiff add [--global] [-a AGENT...] [-y]` | 安装到 Agent 目录(软链) | +| `skiff remove [--global] [-a AGENT...] [-y]` | 移除软链(`rm` / `r` 别名) | +| `skiff add --list` | 列出可用自研 skill | +| `skiff publish [paths] -m MSG [--push]` | 在 ~/.skills 内 git add/commit/push | + +旧命令 `install` / `uninstall` 已移除,请改用 `add` / `remove`。 全局目标路径: @@ -57,9 +83,9 @@ skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库 | 命令 | 说明 | |------|------| -| `skiff add [--ref main] [--path .]` | 写入 `registry.yaml` | +| `skiff registry add [--ref main] [--path .]` | 写入 `registry.yaml` | | `skiff fetch ` | 克隆/更新到 `~/.local/share/skills/externals//` | -| `skiff install-external [--target all]` | 安装外部 skill 到 Agent 目录 | +| `skiff add [-g] [-a AGENT...]` | 安装 registry 中的外部 skill(缺失时自动 fetch) | ### 项目级 @@ -91,16 +117,16 @@ skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库 ```bash skiff create my-skill # 编辑 skills/my-skill/SKILL.md -skiff install my-skill --target cursor -skiff doctor --target cursor +skiff publish skills/my-skill -m "add my-skill" --push +skiff add my-skill -a cursor -g -y +skiff doctor -a cursor ``` ### 在项目中启用 skill ```bash cd ~/code/my-app -skiff enable declarative-openspec-loop --target cursor -skiff sync +skiff add declarative-openspec-loop -a cursor -y ``` ### 安装外部 Git skill diff --git a/skiff/__init__.py b/skiff/__init__.py index 41b9419..7c8ac17 100644 --- a/skiff/__init__.py +++ b/skiff/__init__.py @@ -1,3 +1,3 @@ """skiff — Agent Skills 安装与管理 CLI。""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/skiff/agents.py b/skiff/agents.py new file mode 100644 index 0000000..121bb15 --- /dev/null +++ b/skiff/agents.py @@ -0,0 +1,40 @@ +"""Agent 名称解析(兼容 Vercel skills CLI 别名)。""" + +from __future__ import annotations + +from skiff.paths import ALL_TARGETS + +AGENT_ALIASES: dict[str, str] = { + "cursor": "cursor", + "claude": "claude", + "claude-code": "claude", + "codex": "codex", + "*": "*", +} + +AGENT_CHOICES = sorted({*ALL_TARGETS, *AGENT_ALIASES.keys()}) + + +def flatten_agent_args(groups: list[list[str]] | None) -> list[str] | None: + if not groups: + return None + flat = [item for group in groups for item in group] + return flat or None + + +def resolve_agent_args(agents: list[str] | None) -> list[str]: + if not agents or agents == ["*"] or "*" in agents: + return list(ALL_TARGETS) + + resolved: list[str] = [] + for raw in agents: + key = raw.lower() + if key == "*": + return list(ALL_TARGETS) + target = AGENT_ALIASES.get(key) + if target is None: + choices = ", ".join(AGENT_CHOICES) + raise SystemExit(f"未知 agent: {raw!r},可选: {choices}") + if target != "*" and target not in resolved: + resolved.append(target) + return resolved diff --git a/skiff/cli.py b/skiff/cli.py index d18e919..5015bd6 100644 --- a/skiff/cli.py +++ b/skiff/cli.py @@ -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 安装,或 skiff add -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 ") 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 ") + 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="目标 agent(cursor、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) diff --git a/skiff/gitops.py b/skiff/gitops.py new file mode 100644 index 0000000..beb587f --- /dev/null +++ b/skiff/gitops.py @@ -0,0 +1,59 @@ +"""~/.skills 仓库内的 git 操作。""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +from skiff.paths import SKILLS_HOME + + +def _run_git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(repo), *args], + check=check, + text=True, + capture_output=not sys.stdout.isatty(), + ) + + +def ensure_git_repo(repo: Path) -> None: + if not (repo / ".git").exists(): + raise SystemExit(f"不是 git 仓库: {repo}") + + +def has_staged_changes(repo: Path) -> bool: + result = _run_git(repo, "diff", "--cached", "--quiet", check=False) + return result.returncode == 1 + + +def publish( + *, + paths: list[str], + message: str | None, + push: bool, + no_commit: bool, +) -> None: + repo = SKILLS_HOME.resolve() + ensure_git_repo(repo) + + _run_git(repo, "add", "--", *paths) + + if not has_staged_changes(repo): + print("没有可提交的变更", file=sys.stdout) + return + + if no_commit: + print(f"已暂存变更: {', '.join(paths)}", file=sys.stdout) + return + + if not message: + raise SystemExit("提交需要 -m/--message") + + _run_git(repo, "commit", "-m", message) + print(f"已提交: {message}", file=sys.stdout) + + if push: + _run_git(repo, "push") + print("已推送到远程", file=sys.stdout) diff --git a/skiff/skills.py b/skiff/skills.py index f4310a6..a7475e3 100644 --- a/skiff/skills.py +++ b/skiff/skills.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from pathlib import Path from skiff.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_skills_home @@ -53,6 +54,45 @@ def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, raise SystemExit(f"找不到 skill: {name}") +def read_skill_meta(skill_dir: Path) -> dict[str, str]: + skill_md = skill_dir / "SKILL.md" + if not skill_md.is_file(): + return {} + text = skill_md.read_text(encoding="utf-8") + if not text.startswith("---"): + return {} + end = text.find("\n---", 3) + if end == -1: + return {} + frontmatter = text[3:end] + meta: dict[str, str] = {} + + name_match = re.search(r"^name:\s*(.+)$", frontmatter, re.MULTILINE) + if name_match: + meta["name"] = name_match.group(1).strip().strip("\"'") + + desc_match = re.search( + r"^description:\s*(?:>-|>\||>|-)?\s*\n((?:[ \t].+\n?)+)", + frontmatter, + re.MULTILINE, + ) + if desc_match: + lines = [line.strip() for line in desc_match.group(1).splitlines() if line.strip()] + meta["description"] = " ".join(lines) + else: + inline = re.search(r"^description:\s*(.+)$", frontmatter, re.MULTILINE) + if inline: + meta["description"] = inline.group(1).strip().strip("\"'") + + return meta + + +def skill_description(name: str) -> str | None: + meta = read_skill_meta(SKILLS_DIR / name) + desc = meta.get("description") + return desc.strip() if desc else None + + def validate_skill_name(name: str) -> None: import re diff --git a/skills/skiff/SKILL.md b/skills/skiff/SKILL.md new file mode 100644 index 0000000..c1a5584 --- /dev/null +++ b/skills/skiff/SKILL.md @@ -0,0 +1,93 @@ +--- +name: skiff +description: >- + 维护 ~/.skills 自研 skill:编辑后用 skiff publish 提交推送,用 skiff add/remove 在各项目或全局挂卸 skill。 + 触发词:skiff、自研 skill、publish skill、安装自研 skill、更新 skill 到项目。 +--- + +# skiff 自研 Skill 工作流 + +SSOT 固定在 `~/.skills/skills//`。内容通过 **symlink** 分发到各 agent,改 SSOT 即全项目生效。 + +--- + +## 日常流程 + +### 1. 改 skill + +直接编辑: + +``` +~/.skills/skills//SKILL.md +``` + +### 2. 提交并推送(不用离开当前目录) + +```bash +skiff publish skills/ -m "update " --push +``` + +只暂存不提交: + +```bash +skiff publish skills/ --no-commit +``` + +### 3. 装到项目或全局 + +```bash +# 当前项目 +cd ~/code/my-app +skiff add discussion-notes -a cursor -y + +# 全局(所有项目) +skiff add discussion-notes -a cursor -g -y + +# 多个 agent +skiff add discussion-notes -a cursor -a codex -g -y +``` + +### 4. 卸载 + +```bash +skiff remove discussion-notes -a cursor -y # 当前项目 +skiff remove discussion-notes -g -a cursor -y # 全局 +skiff rm discussion-notes -g -y # rm 别名 +``` + +### 5. 浏览可用自研 skill + +```bash +skiff add --list +``` + +--- + +## 与 Vercel `npx skills` 的分工 + +| 场景 | 工具 | +|------|------| +| 自研 skill(~/.skills) | **skiff** | +| 社区 skill(GitHub 任意仓库) | `npx skills add` | + +--- + +## 命令对照 + +| skiff | 说明 | +|-------|------| +| `add [-g] [-a AGENT...] [-y]` | 安装 | +| `remove [-g] [-a AGENT...] [-y]` | 卸载(`rm` / `r` 别名) | +| `add --list` | 列出可用自研 skill | +| `publish [paths] -m MSG [--push]` | git add / commit / push | +| `list` | 列出 ~/.skills 目录结构 | +| `status` | 查看软链安装状态 | +| `create ` | 新建自研 skill | + +--- + +## 注意 + +- 不要在 `project/.agents/skills/` 里直接改文件;应改 `~/.skills/skills/` 再 `publish` +- symlink 正确时,**不需要 reinstall**;保存 SSOT 后各项目自动读到新内容 +- 社区 skill 用 `npx skills add`,不要用 skiff `registry add` 除非团队要 pin 版本