feat: add skill draft workflow
This commit is contained in:
+21
-12
@@ -37,22 +37,21 @@ skiff publish skills/discussion-notes -m "update discussion-notes" --push
|
||||
PYTHONPATH=/path/to/skills python3 -m skiff <command>
|
||||
```
|
||||
|
||||
## 首次配置
|
||||
## 首次安装
|
||||
|
||||
```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 <path>` | 关联 `~/.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 <name>` | 从 `skills/_template/` 创建自研 skill |
|
||||
| `skiff create <name> [--idea TEXT] [--from-project PATH]` | 从模板创建草稿到 `~/.skills/.drafts/` |
|
||||
| `skiff check <name>` | 校验草稿或正式 skill |
|
||||
| `skiff finalize <name>` | 校验草稿并移动到正式 `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
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""skiff — Agent Skills 安装与管理 CLI。"""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
+91
-32
@@ -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="自动修复可修复的软链")
|
||||
|
||||
+2
-1
@@ -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-repo-path>"
|
||||
"~/.skills 不存在。请将 skills 仓库克隆到 ~/.skills"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user