Files
.pouch/skiff/skills.py
T

171 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""自研 skill 发现与解析。"""
from __future__ import annotations
import re
from pathlib import Path
from skiff.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_skills_home
from skiff.registry import external_skill_path, load_registry
def list_owned_skills() -> list[str]:
ensure_skills_home()
if not SKILLS_DIR.is_dir():
return []
names: list[str] = []
for entry in sorted(SKILLS_DIR.iterdir()):
if not entry.is_dir():
continue
if entry.name.startswith("_"):
continue
if (entry / "SKILL.md").is_file():
names.append(entry.name)
return names
def owned_skill_path(name: str) -> Path:
path = SKILLS_DIR / name
if not (path / "SKILL.md").is_file():
raise SystemExit(f"自研 skill 不存在: {name}")
return path
def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, str]:
"""返回 (skill_path, kind)kind 为 owned 或 external。"""
ensure_skills_home()
owned = SKILLS_DIR / name
if source in (None, "owned") and (owned / "SKILL.md").is_file():
return owned, "owned"
registry = load_registry()
if source in (None, "registry") and name in registry:
path = external_skill_path(name, registry[name])
if not (path / "SKILL.md").is_file():
raise SystemExit(
f"外部 skill {name!r} 尚未 fetch 或 path 中缺少 SKILL.md。"
f"请运行: skiff fetch {name}"
)
return path, "external"
if (owned / "SKILL.md").is_file():
return owned, "owned"
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
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
raise SystemExit(
f"skill 名称无效: {name!r}(小写 + 连字符,如 security-review"
)
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