diff --git a/AGENTS.md b/AGENTS.md index 099b811..61cda54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,13 +15,12 @@ ```bash # 1. 克隆并关联 -git clone https://git.yumee.top/laily/skills.git ~/code/gitea/skills -git clone https://git.yumee.top/laily/skiff.git ~/code/gitea/skiff -cd ~/code/gitea/skiff && ./install.sh -skiff setup ~/code/gitea/skills +git clone https://git.yumee.top/laily/skills.git ~/.skills +cd ~/.skills && ./install.sh -# 2. 全局安装 skill -skiff install declarative-openspec-loop +# install.sh 会自动把 skiff 项目 skill 安装到所有 Agent +# 2. 安装其他 skill +skiff add declarative-openspec-loop -g # 3. 查看状态 skiff list @@ -61,6 +60,7 @@ AGENTS.md # 本文档 | Skill | 说明 | | ---------------------------------------------------------------------- | ------------------------------------------------- | +| [skiff](skills/skiff/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 owned skill | | [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 | | [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md | @@ -192,7 +192,7 @@ targets: # 可选,默认 all | `skiff sync` | `npm ci` | -项目级命令(skiff 待实现): +项目级命令: ```bash cd ~/code/my-app @@ -212,7 +212,7 @@ skiff sync | 命令 | 说明 | | -------------------------------------- | ------------------- | -| `skiff setup ` | 关联 `~/.skills` 到本仓库 | +| `skiff bootstrap` | 将 skiff 项目 skill 全局安装到所有 Agent | | `skiff list` | 列出所有 skill | | `skiff status` | 安装状态总览 | | `skiff install ` | 全局安装(symlink) | @@ -220,15 +220,15 @@ skiff sync | `skiff add / fetch / install-external` | 外部 Git skill | -### 待实现 +### 草稿与健康检查 -| 命令 | 说明 | -| ------------------------ | --------------------------- | -| `skiff enable / disable` | 项目级启用/关闭 | -| `skiff sync` | 按 `.skills.yaml` 重建 symlink | -| `skiff create` | 从 `_template/` 脚手架创建 skill | -| `skiff doctor` | symlink 健康检查 | +| 命令 | 说明 | +| --- | --- | +| `skiff create --idea TEXT [--from-project PATH]` | 从 `_template/` 创建草稿 | +| `skiff check ` | 校验草稿或正式 skill | +| `skiff finalize ` | 校验并将草稿转为正式 skill | +| `skiff doctor [--fix]` | symlink 健康检查与修复 | --- @@ -263,11 +263,12 @@ description: >- ### 新建流程 -1. `cp -r skills/_template skills/my-skill` -2. 编辑 `skills/my-skill/SKILL.md` -3. `skiff install my-skill --target cursor` 验证 -4. 在本仓库 commit -5. 各项目 `skiff enable my-skill`(待实现) +1. `skiff create my-skill --idea "..." --from-project .` +2. Agent 编辑 `~/.skills/.drafts/my-skill/SKILL.md` +3. `skiff check my-skill` +4. 用户确认后执行 `skiff finalize my-skill` +5. `skiff add my-skill -a cursor -g -y` 验证 +6. 在本仓库 commit;需要的项目再用 `skiff add my-skill` 启用 **禁止**在 `~/.cursor/skills/` 或项目 Agent 目录直接创建非 symlink 的 skill。 @@ -294,7 +295,7 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入 | -------------- | ----------------------------------------------------- | | Cursor / Codex | symlink,正常 | | Claude Code | symlink 单个 skill 目录,不要 symlink 整个 `~/.claude/skills/` | -| symlink 被替换 | `skiff doctor`(待实现)→ 重建 symlink | +| symlink 被替换 | `skiff doctor --fix` → 重建 symlink | --- @@ -317,8 +318,8 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入 | 我要… | 命令 | 在哪 | | ---------- | --------------------------------- | ---- | -| 首次 setup | `skiff setup ~/code/gitea/skills` | 任意 | -| 新建 skill | 复制 `_template/` → 编辑 → commit | 本仓库 | +| 首次安装 | `git clone ~/.skills && ~/.skills/install.sh` | 任意 | +| 新建 skill | `skiff create` → Agent 完善 → `check/finalize` | 任意项目 | | 全局启用 | `skiff install ` | 任意 | | 项目启用 | `skiff enable ` | 项目目录 | | 看状态 | `skiff status` | 任意 | @@ -335,4 +336,3 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入 - [Vercel skills CLI](https://github.com/vercel-labs/skills) - [skills.sh](https://skills.sh) - [Cursor Skills 文档](https://cursor.com/docs/context/skills) - diff --git a/README.md b/README.md index 2b8409f..823b63d 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,9 @@ ## 快速开始 ```bash -git clone https://git.yumee.top/laily/skills.git ~/code/gitea/skills -cd ~/code/gitea/skills -./install.sh -skiff setup ~/code/gitea/skills +git clone https://git.yumee.top/laily/skills.git ~/.skills +cd ~/.skills +./install.sh # 安装 CLI,并将 skiff 项目 skill 安装到所有 Agent skiff install declarative-openspec-loop skiff list @@ -38,6 +37,7 @@ AGENTS.md # 详细规范与架构说明 | Skill | 说明 | |-------|------| +| [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 owned skill | | [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 | | [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 | @@ -52,11 +52,15 @@ AGENTS.md # 详细规范与架构说明 新建 skill: ```bash -skiff create my-skill # 从 _template/ 脚手架创建 -# 编辑 skills/my-skill/SKILL.md -skiff install my-skill # 全局安装验证 +skiff create my-skill --idea "描述要解决的重复问题" --from-project . +# 由 Agent 完善 ~/.skills/.drafts/my-skill/SKILL.md +skiff check my-skill +skiff finalize my-skill +skiff add my-skill -g # 全局安装验证 ``` +项目里使用 skill 发现通用问题或优化时,让 Agent 按 `skiff` skill 收集实际结果与期望结果,修改 `~/.skills/skills//` 的 SSOT,并执行 `skiff check `。项目专属规则保留在项目内,不回流到通用 skill。 + ## 安装方式 ### 全局(用户级) diff --git a/install.sh b/install.sh index e30be86..4b363d7 100755 --- a/install.sh +++ b/install.sh @@ -9,6 +9,8 @@ mkdir -p "$BIN_DIR" ln -sf "$REPO/bin/skiff" "$BIN_DIR/skiff" echo "已安装 skiff -> $BIN_DIR/skiff" +PYTHONPATH="$REPO${PYTHONPATH:+:$PYTHONPATH}" python3 -m skiff bootstrap + path_already_configured() { local file="$1" [[ -f "$file" ]] && grep -qF "$PATH_MARKER" "$file" diff --git a/registry.yaml b/registry.yaml index edd76bc..a4a9006 100644 --- a/registry.yaml +++ b/registry.yaml @@ -6,12 +6,7 @@ # path: (default: .) # # Example: -# superpowers: -# repo: https://github.com/obra/superpowers +# example-skills: +# repo: https://github.com/example/skills # ref: main # path: . - -superpowers: - repo: https://github.com/obra/superpowers - ref: main - path: . diff --git a/skiff/README.md b/skiff/README.md index bf823f2..4b12264 100644 --- a/skiff/README.md +++ b/skiff/README.md @@ -37,22 +37,21 @@ skiff publish skills/discussion-notes -m "update discussion-notes" --push PYTHONPATH=/path/to/skills python3 -m skiff ``` -## 首次配置 +## 首次安装 ```bash -skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库 +git clone https://git.yumee.top/laily/skills.git ~/.skills +~/.skills/install.sh ``` -若仓库本身就在 `~/.skills`(开发场景),`setup` 会自动识别,无需额外操作。 +`install.sh` 会安装 CLI,并自动执行 `skiff bootstrap`,将本仓库的 `skiff` skill 全局软链到 Cursor、Claude Code 和 Codex。也可以随时手动重跑: + +```bash +skiff bootstrap +``` ## 命令参考 -### 仓库关联 - -| 命令 | 说明 | -|------|------| -| `skiff setup ` | 关联 `~/.skills` 到 skills 仓库 | - ### 查看 | 命令 | 说明 | @@ -60,6 +59,12 @@ skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库 | `skiff list` | 列出自研 skill 与 registry 中的外部 skill | | `skiff status [--target all\|cursor\|claude\|codex]` | 安装状态总览 | +### 项目初始化 + +| 命令 | 说明 | +|------|------| +| `skiff bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent | + ### 全局安装(自研 skill) | 命令 | 说明 | @@ -107,7 +112,9 @@ skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库 | 命令 | 说明 | |------|------| -| `skiff create ` | 从 `skills/_template/` 创建自研 skill | +| `skiff create [--idea TEXT] [--from-project PATH]` | 从模板创建草稿到 `~/.skills/.drafts/` | +| `skiff check ` | 校验草稿或正式 skill | +| `skiff finalize ` | 校验草稿并移动到正式 `skills/` | | `skiff doctor [--target all] [--fix]` | 检查软链健康状态,`--fix` 自动修复 | ## 常用工作流 @@ -115,8 +122,10 @@ skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库 ### 新建并全局启用自研 skill ```bash -skiff create my-skill -# 编辑 skills/my-skill/SKILL.md +skiff create my-skill --idea "描述要解决的重复问题" --from-project . +# 由 Agent 完善 ~/.skills/.drafts/my-skill/SKILL.md +skiff check my-skill +skiff finalize my-skill skiff publish skills/my-skill -m "add my-skill" --push skiff add my-skill -a cursor -g -y skiff doctor -a cursor diff --git a/skiff/__init__.py b/skiff/__init__.py index 7c8ac17..3e711ae 100644 --- a/skiff/__init__.py +++ b/skiff/__init__.py @@ -1,3 +1,3 @@ """skiff — Agent Skills 安装与管理 CLI。""" -__version__ = "0.2.0" +__version__ = "0.4.0" diff --git a/skiff/cli.py b/skiff/cli.py index 5015bd6..98a744e 100644 --- a/skiff/cli.py +++ b/skiff/cli.py @@ -6,6 +6,7 @@ import argparse import re import subprocess import sys +from datetime import datetime from pathlib import Path from skiff import __version__ @@ -13,6 +14,7 @@ from skiff.agents import flatten_agent_args, resolve_agent_args from skiff.gitops import publish as git_publish from skiff.paths import ( ALL_TARGETS, + DRAFTS_DIR, EXTERNALS_DIR, SKILLS_DIR, SKILLS_HOME, @@ -34,8 +36,10 @@ from skiff.skills import ( owned_skill_path, resolve_skill_source, skill_description, + validate_skill_dir, validate_skill_name, ) +from skiff.yaml_io import safe_dump from skiff.symlinks import check_link, copy_template, create_link, find_repo_root, remove_link @@ -130,29 +134,6 @@ def _remove_skill( 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}") - - 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() @@ -171,6 +152,15 @@ def cmd_list(args: argparse.Namespace) -> None: _print(f" {name} ({repo})") +def cmd_bootstrap(args: argparse.Namespace) -> None: + del args + ensure_skills_home() + project_skill = "skiff" + owned_skill_path(project_skill) + _install_skill(project_skill, list(ALL_TARGETS), project_root=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) rows: list[tuple[str, Path, Path]] = [] @@ -386,7 +376,7 @@ def cmd_create(args: argparse.Namespace) -> None: if not TEMPLATE_DIR.is_dir(): raise SystemExit(f"模板目录不存在: {TEMPLATE_DIR}") - dst = SKILLS_DIR / args.name + dst = DRAFTS_DIR / args.name copy_template(TEMPLATE_DIR, dst) skill_md = dst / "SKILL.md" @@ -399,10 +389,67 @@ def cmd_create(args: argparse.Namespace) -> None: flags=re.MULTILINE, ) skill_md.write_text(content, encoding="utf-8") - _print(f"已创建 skill: {dst}") - _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") + brief = { + "name": args.name, + "idea": args.idea or "", + "source_project": str(Path(args.from_project).resolve()) if args.from_project else "", + "status": "draft", + "created_at": datetime.now().astimezone().isoformat(timespec="seconds"), + } + (dst / "brief.yaml").write_text(safe_dump(brief), encoding="utf-8") + _print(f"草稿已创建: {dst}") + _print(f"下一步: 请完善 skiff 草稿 {args.name}") + _print(f"完成后运行: skiff check {args.name} && skiff finalize {args.name}") + + +def _draft_or_owned_path(name: str) -> tuple[Path, str]: + draft = DRAFTS_DIR / name + if draft.is_dir(): + return draft, "草稿" + owned = SKILLS_DIR / name + if owned.is_dir(): + return owned, "正式 skill" + raise SystemExit(f"找不到草稿或正式 skill: {name}") + + +def cmd_check(args: argparse.Namespace) -> None: + ensure_skills_home() + validate_skill_name(args.name) + path, kind = _draft_or_owned_path(args.name) + issues = validate_skill_dir(path, args.name) + if kind == "草稿": + issues = [issue for issue in issues if "草稿文件: brief.yaml" not in issue] + if issues: + for issue in issues: + _err(f"✗ {issue}") + raise SystemExit(1) + _print(f"✓ 校验通过 ({kind}): {path}") + + +def cmd_finalize(args: argparse.Namespace) -> None: + ensure_skills_home() + validate_skill_name(args.name) + draft = DRAFTS_DIR / args.name + if not draft.is_dir(): + raise SystemExit(f"草稿不存在: {args.name}") + final = SKILLS_DIR / args.name + if final.exists(): + raise SystemExit(f"正式 skill 已存在: {final}") + + issues = validate_skill_dir(draft, args.name) + issues = [issue for issue in issues if "草稿文件: brief.yaml" not in issue] + if issues: + for issue in issues: + _err(f"✗ {issue}") + raise SystemExit(1) + + final.parent.mkdir(parents=True, exist_ok=True) + draft.replace(final) + brief = final / "brief.yaml" + if brief.exists(): + brief.unlink() + _print(f"已完成 skill: {final}") + _print(f"下一步: skiff publish skills/{args.name} -m \"add {args.name}\" --push") def cmd_doctor(args: argparse.Namespace) -> None: @@ -483,9 +530,11 @@ def build_parser() -> argparse.ArgumentParser: sub = parser.add_subparsers(dest="command", required=True) - 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_bootstrap = sub.add_parser( + "bootstrap", + help="将本项目 skiff skill 全局安装到所有 agent", + ) + p_bootstrap.set_defaults(func=cmd_bootstrap) p_list = sub.add_parser("list", help="列出 ~/.skills 中的 skill 目录") p_list.set_defaults(func=cmd_list) @@ -557,10 +606,20 @@ def build_parser() -> argparse.ArgumentParser: p_sync.add_argument("--project") p_sync.set_defaults(func=cmd_sync) - p_create = sub.add_parser("create", help="从 _template 创建自研 skill") + p_create = sub.add_parser("create", help="从 _template 创建自研 skill 草稿") p_create.add_argument("name", help="skill 名称") + p_create.add_argument("--idea", help="创建 skill 的原始想法") + p_create.add_argument("--from-project", help="想法来源项目(仅记录上下文)") p_create.set_defaults(func=cmd_create) + p_check = sub.add_parser("check", help="校验草稿或正式 skill") + p_check.add_argument("name", help="skill 名称") + p_check.set_defaults(func=cmd_check) + + p_finalize = sub.add_parser("finalize", help="校验草稿并转为正式 skill") + p_finalize.add_argument("name", help="skill 名称") + p_finalize.set_defaults(func=cmd_finalize) + p_doctor = sub.add_parser("doctor", help="软链健康检查") p_doctor.add_argument("-a", "--agent", dest="agents", nargs="+", action="append") p_doctor.add_argument("--fix", action="store_true", help="自动修复可修复的软链") diff --git a/skiff/paths.py b/skiff/paths.py index ddc2386..a40af1e 100644 --- a/skiff/paths.py +++ b/skiff/paths.py @@ -8,6 +8,7 @@ HOME = Path.home() SKILLS_HOME = HOME / ".skills" SKILLS_DIR = SKILLS_HOME / "skills" TEMPLATE_DIR = SKILLS_DIR / "_template" +DRAFTS_DIR = SKILLS_HOME / ".drafts" REGISTRY_FILE = SKILLS_HOME / "registry.yaml" EXTERNALS_DIR = HOME / ".local" / "share" / "skills" / "externals" PROJECT_MANIFEST = ".skills.yaml" @@ -44,5 +45,5 @@ def agent_skill_dir(target: str, *, project_root: Path | None = None) -> Path: def ensure_skills_home() -> None: if not SKILLS_HOME.is_dir(): raise SystemExit( - f"~/.skills 未配置。请先运行: skiff setup " + "~/.skills 不存在。请将 skills 仓库克隆到 ~/.skills" ) diff --git a/skiff/skills.py b/skiff/skills.py index a7475e3..df506fd 100644 --- a/skiff/skills.py +++ b/skiff/skills.py @@ -102,3 +102,68 @@ def validate_skill_name(name: str) -> None: ) if name == "_template": raise SystemExit("不能使用保留名 _template") + + +_PLACEHOLDER_PATTERNS = ( + (r"^name:\s*skill-name\s*$", "skill-name"), + (r"^#\s+Skill 名称\s*$", "Skill 名称"), + (r"简要描述 skill 做什么、何时触发", "简要描述 skill 做什么"), + (r"^-\s*触发场景 1\s*$", "触发场景 1"), + (r"^\d+\.\s*第一步\s*$", "第一步"), +) + + +def validate_skill_dir(skill_dir: Path, expected_name: str) -> list[str]: + """返回 skill 目录中的校验问题;空列表表示通过。""" + issues: list[str] = [] + skill_md = skill_dir / "SKILL.md" + if not skill_md.is_file(): + return ["缺少 SKILL.md"] + + text = skill_md.read_text(encoding="utf-8") + if not text.strip(): + return ["SKILL.md 为空"] + if not text.startswith("---\n"): + return ["SKILL.md 缺少 YAML frontmatter"] + + end = text.find("\n---", 4) + if end == -1: + return ["SKILL.md frontmatter 未闭合"] + frontmatter = text[4:end] + keys = re.findall(r"^([A-Za-z0-9_-]+):", frontmatter, re.MULTILINE) + unexpected = sorted(set(keys) - {"name", "description"}) + missing = sorted({"name", "description"} - set(keys)) + if missing: + issues.append(f"frontmatter 缺少字段: {', '.join(missing)}") + if unexpected: + issues.append(f"frontmatter 只允许 name、description,发现: {', '.join(unexpected)}") + + meta = read_skill_meta(skill_dir) + if meta.get("name") != expected_name: + issues.append( + f"目录名与 frontmatter name 不一致: {expected_name} != {meta.get('name', '(缺失)')}" + ) + description = meta.get("description", "").strip() + if not description: + issues.append("description 不能为空") + + found_placeholders = [ + label + for pattern, label in _PLACEHOLDER_PATTERNS + if re.search(pattern, text, re.MULTILINE | re.IGNORECASE) + ] + if found_placeholders: + issues.append(f"存在模板占位内容: {', '.join(found_placeholders)}") + + link_pattern = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)") + for target in link_pattern.findall(text): + target = target.strip().split("#", 1)[0] + if not target or "://" in target or target.startswith(("mailto:", "/")): + continue + if not (skill_dir / target).resolve().is_file(): + issues.append(f"引用文件不存在: {target}") + + for draft_file in ("brief.yaml",): + if (skill_dir / draft_file).exists(): + issues.append(f"正式 skill 不应包含草稿文件: {draft_file}") + return issues diff --git a/skills/_template/SKILL.md b/skills/_template/SKILL.md index c8b78a9..a7189ac 100644 --- a/skills/_template/SKILL.md +++ b/skills/_template/SKILL.md @@ -1,7 +1,7 @@ --- name: skill-name description: >- - 简要描述 skill 做什么、何时触发。Include trigger keywords so agents can match automatically. + 简要描述 skill 做什么、何时触发。写入用户可能使用的触发表达,让 Agent 能自动匹配。 --- # Skill 名称 @@ -15,6 +15,10 @@ description: >- - 触发场景 1 - 触发场景 2 +## 不适用 + +- 不应触发的相近场景 + --- ## 步骤 @@ -28,3 +32,7 @@ description: >- ## 注意事项 - 约束或边界条件 + +## 验证 + +- 说明如何确认 skill 的执行结果正确 diff --git a/skills/skiff/SKILL.md b/skills/skiff/SKILL.md index c1a5584..1684e75 100644 --- a/skills/skiff/SKILL.md +++ b/skills/skiff/SKILL.md @@ -1,8 +1,9 @@ --- name: skiff description: >- - 维护 ~/.skills 自研 skill:编辑后用 skiff publish 提交推送,用 skiff add/remove 在各项目或全局挂卸 skill。 - 触发词:skiff、自研 skill、publish skill、安装自研 skill、更新 skill 到项目。 + 创建和维护 ~/.skills 自研 skill:把项目开发中产生的想法提炼为草稿,完善并校验后发布, + 或用 skiff add/remove 在项目及全局挂卸 skill。触发词:skiff、自研 skill、创建 skill、 + 想做一个 skill、publish skill、安装自研 skill、更新 skill 到项目。 --- # skiff 自研 Skill 工作流 @@ -11,29 +12,87 @@ SSOT 固定在 `~/.skills/skills//`。内容通过 **symlink** 分发到 --- -## 日常流程 +## 在项目中使用 skill -### 1. 改 skill +先浏览可用的 owned skill,再安装到当前项目: -直接编辑: - -``` -~/.skills/skills//SKILL.md +```bash +skiff add --list +skiff add -a cursor -a claude -a codex -y ``` -### 2. 提交并推送(不用离开当前目录) +`skiff add ` 默认安装到当前项目;只有用户明确需要所有项目使用时才加 `-g`。安装结果是指向 `~/.skills/skills//` 的软链,不要在 Agent 目录创建副本。 + +## 创建新的 skill + +### 从项目想法创建 skill + +当用户在项目开发中提出“想创建一个 skill”时: + +1. 用一句话确认它要解决的重复问题,并建议符合小写连字符规范的名称;信息足够时不要为了形式追问。 +2. 读取当前项目中与想法直接相关的代码和规范,区分可复用工作流与项目私有事实。 +3. 创建草稿: + +```bash +skiff create --idea "<用户原始想法>" --from-project . +``` + +4. 编辑 `~/.skills/.drafts//SKILL.md`,完善触发条件、不适用场景、步骤、边界与验证方法。仅在确有必要时增加 `references/`、`scripts/` 或 `assets/`。 +5. 不要把项目专属路径、私有业务规则、一次性命令或密钥复制到通用 skill。 +6. 运行校验并修复所有问题: + +```bash +skiff check +``` + +7. 向用户展示名称、description、核心步骤和验证方式。获得确认后再转正: + +```bash +skiff finalize +``` + +转正不会自动 commit、push 或安装。用户明确要求后再执行 `skiff publish` 或 `skiff add`。 + +## 问题或优化回流 + +在项目里使用 skills 遇到问题,或者发现可复用的优化时: + +1. 先记录最小证据:触发用户表达、使用的 skill 名称、实际结果、期望结果,以及能复现问题的必要项目上下文。 +2. 判断归属: + - 通用工作流、触发条件或验证缺陷:回流 owned skill。 + - 仅当前项目成立的命令、路径、业务规则:留在项目文档或项目配置,不写回通用 skill。 + - CLI 安装、软链或校验行为异常:修改 `~/.skills/skiff/` 中的 CLI 和测试。 + - 第三方 skill:不要复制成 owned skill 或直接改安装目录;整理证据反馈上游,除非用户明确决定维护 fork。 +3. 确认真实来源。Agent 目录通常是软链,owned skill 的 SSOT 固定为: + +```text +~/.skills/skills// +``` + +4. 修改 SSOT。行为修复应先补能复现问题的测试或示例,再改 `SKILL.md`、引用文件或脚本。 +5. 校验并在原项目重跑最初失败的场景: + +```bash +skiff check +``` + +6. 汇报修改内容、验证结果和影响范围。只有用户明确要求提交或推送时才运行: ```bash skiff publish skills/ -m "update " --push ``` -只暂存不提交: +软链正确时无需重新安装;SSOT 保存后项目立即读取新内容。 + +## 安装与维护 + +安装本项目的 `skiff` skill 到所有 Agent: ```bash -skiff publish skills/ --no-commit +skiff bootstrap ``` -### 3. 装到项目或全局 +安装其他 skill: ```bash # 当前项目 @@ -47,7 +106,7 @@ skiff add discussion-notes -a cursor -g -y skiff add discussion-notes -a cursor -a codex -g -y ``` -### 4. 卸载 +卸载: ```bash skiff remove discussion-notes -a cursor -y # 当前项目 @@ -55,7 +114,7 @@ skiff remove discussion-notes -g -a cursor -y # 全局 skiff rm discussion-notes -g -y # rm 别名 ``` -### 5. 浏览可用自研 skill +浏览可用自研 skill: ```bash skiff add --list @@ -76,18 +135,22 @@ skiff add --list | skiff | 说明 | |-------|------| +| `bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent | | `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 | +| `create --idea TEXT [--from-project PATH]` | 创建自研 skill 草稿 | +| `check ` | 校验草稿或正式 skill | +| `finalize ` | 校验草稿并转为正式 skill | --- ## 注意 - 不要在 `project/.agents/skills/` 里直接改文件;应改 `~/.skills/skills/` 再 `publish` +- 未完成的内容保留在 `~/.skills/.drafts/`,不要直接放进正式 `skills/` - symlink 正确时,**不需要 reinstall**;保存 SSOT 后各项目自动读到新内容 - 社区 skill 用 `npx skills add`,不要用 skiff `registry add` 除非团队要 pin 版本 diff --git a/tests/test_create_workflow.py b/tests/test_create_workflow.py new file mode 100644 index 0000000..d2e0166 --- /dev/null +++ b/tests/test_create_workflow.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class CreateWorkflowTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.home = Path(self.temp_dir.name) + self.skills_home = self.home / ".skills" + template = self.skills_home / "skills" / "_template" + template.mkdir(parents=True) + template.joinpath("SKILL.md").write_text( + "---\nname: skill-name\ndescription: >-\n" + " 简要描述 skill 做什么、何时触发。Include trigger keywords so agents can match automatically.\n" + "---\n\n# Skill 名称\n\n## 步骤\n\n1. 第一步\n", + encoding="utf-8", + ) + project_skill = self.skills_home / "skills" / "skiff" + project_skill.mkdir() + project_skill.joinpath("SKILL.md").write_text( + "---\nname: skiff\ndescription: >-\n" + " 创建和维护自研 skill。用于创建、安装、反馈或更新 skill 时。\n" + "---\n\n# skiff\n\n## 步骤\n\n1. 维护 skill。\n", + encoding="utf-8", + ) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def run_skiff(self, *args: str) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["HOME"] = str(self.home) + env["PYTHONPATH"] = str(REPO_ROOT) + return subprocess.run( + [sys.executable, "-m", "skiff", *args], + cwd=REPO_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + def test_setup_command_is_not_exposed(self) -> None: + help_result = self.run_skiff("--help") + setup_result = self.run_skiff("setup", str(self.skills_home)) + + self.assertEqual(help_result.returncode, 0, help_result.stderr) + self.assertNotIn("setup", help_result.stdout) + self.assertNotEqual(setup_result.returncode, 0) + + def test_bootstrap_installs_project_skill_globally_for_all_agents(self) -> None: + first = self.run_skiff("bootstrap") + second = self.run_skiff("bootstrap") + + self.assertEqual(first.returncode, 0, first.stderr) + self.assertEqual(second.returncode, 0, second.stderr) + expected = (self.skills_home / "skills" / "skiff").resolve() + for relative in ( + ".cursor/skills/skiff", + ".claude/skills/skiff", + ".codex/skills/skiff", + ): + link = self.home / relative + self.assertTrue(link.is_symlink(), relative) + self.assertEqual(link.resolve(), expected) + self.assertIn("cursor", first.stdout) + self.assertIn("claude", first.stdout) + self.assertIn("codex", first.stdout) + + def test_create_writes_draft_and_brief_without_registering_owned_skill(self) -> None: + project = self.home / "project" + project.mkdir() + + result = self.run_skiff( + "create", + "migration-review", + "--idea", + "检查数据库迁移", + "--from-project", + str(project), + ) + + self.assertEqual(result.returncode, 0, result.stderr) + draft = self.skills_home / ".drafts" / "migration-review" + self.assertTrue((draft / "SKILL.md").is_file()) + brief = (draft / "brief.yaml").read_text(encoding="utf-8") + self.assertIn("idea: 检查数据库迁移", brief) + self.assertIn(f"source_project: {project.resolve()}", brief) + self.assertFalse((self.skills_home / "skills" / "migration-review").exists()) + self.assertIn("请完善 skiff 草稿 migration-review", result.stdout) + + def test_check_rejects_template_placeholders(self) -> None: + created = self.run_skiff("create", "migration-review", "--idea", "检查数据库迁移") + self.assertEqual(created.returncode, 0, created.stderr) + + result = self.run_skiff("check", "migration-review") + + self.assertNotEqual(result.returncode, 0) + self.assertIn("模板占位", result.stderr) + + def test_finalize_moves_valid_draft_and_removes_brief(self) -> None: + created = self.run_skiff("create", "migration-review", "--idea", "检查数据库迁移") + self.assertEqual(created.returncode, 0, created.stderr) + draft = self.skills_home / ".drafts" / "migration-review" + draft.joinpath("SKILL.md").write_text( + "---\nname: migration-review\ndescription: >-\n" + " 检查数据库迁移文件的安全性。用于用户修改数据库结构、迁移或回滚方案时。\n" + "---\n\n# 数据库迁移检查\n\n## 步骤\n\n1. 检查迁移与回滚路径。\n", + encoding="utf-8", + ) + + result = self.run_skiff("finalize", "migration-review") + + self.assertEqual(result.returncode, 0, result.stderr) + final = self.skills_home / "skills" / "migration-review" + self.assertTrue((final / "SKILL.md").is_file()) + self.assertFalse((final / "brief.yaml").exists()) + self.assertFalse(draft.exists()) + + def test_check_rejects_missing_local_reference(self) -> None: + draft = self.skills_home / ".drafts" / "reference-check" + draft.mkdir(parents=True) + draft.joinpath("SKILL.md").write_text( + "---\nname: reference-check\ndescription: >-\n" + " 检查本地参考资料。用于用户要求核对参考文档时。\n" + "---\n\n# 参考检查\n\n读取 [规范](references/rules.md)。\n", + encoding="utf-8", + ) + + result = self.run_skiff("check", "reference-check") + + self.assertNotEqual(result.returncode, 0) + self.assertIn("引用文件不存在", result.stderr) + + def test_finalize_does_not_overwrite_existing_owned_skill(self) -> None: + created = self.run_skiff("create", "collision-check", "--idea", "检查名称冲突") + self.assertEqual(created.returncode, 0, created.stderr) + final = self.skills_home / "skills" / "collision-check" + final.mkdir() + final.joinpath("marker").write_text("keep", encoding="utf-8") + + result = self.run_skiff("finalize", "collision-check") + + self.assertNotEqual(result.returncode, 0) + self.assertEqual(final.joinpath("marker").read_text(encoding="utf-8"), "keep") + self.assertTrue((self.skills_home / ".drafts" / "collision-check" / "brief.yaml").is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_install_script.py b/tests/test_install_script.py new file mode 100644 index 0000000..40126c2 --- /dev/null +++ b/tests/test_install_script.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class InstallScriptTests(unittest.TestCase): + def test_install_bootstraps_skiff_skill_for_all_agents(self) -> None: + with tempfile.TemporaryDirectory() as temp: + home = Path(temp) + (home / ".skills").symlink_to(REPO_ROOT, target_is_directory=True) + env = os.environ.copy() + env["HOME"] = str(home) + + result = subprocess.run( + ["bash", str(REPO_ROOT / "install.sh")], + cwd=REPO_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + for relative in ( + ".cursor/skills/skiff", + ".claude/skills/skiff", + ".codex/skills/skiff", + ): + link = home / relative + self.assertTrue(link.is_symlink(), relative) + self.assertEqual(link.resolve(), (REPO_ROOT / "skills" / "skiff").resolve()) + self.assertIn("已安装项目 skill", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_project_skill.py b/tests/test_project_skill.py new file mode 100644 index 0000000..1fb4419 --- /dev/null +++ b/tests/test_project_skill.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class ProjectSkillContentTests(unittest.TestCase): + def test_skiff_skill_documents_project_usage_and_feedback_loop(self) -> None: + content = (REPO_ROOT / "skills" / "skiff" / "SKILL.md").read_text(encoding="utf-8") + + for expected in ( + "skiff add ", + "问题或优化回流", + "实际结果", + "期望结果", + "skiff check ", + "~/.skills/skills//", + "第三方 skill", + ): + self.assertIn(expected, content) + + +if __name__ == "__main__": + unittest.main()