Files
MetaSkill/skills/le-meta-skill/scripts/validate_skill.py
T
Leroy d3e9bd45bd LeMetaSkill v1.1.0:跨 runtime(ZCode/Claude Code/Codex)的 Agent Skill 创建与迭代工坊
- SKILL.md 双工作流(创建/迭代)+ 两道门(验料/通用化)+ 三档位
- 7 篇方法论 references + 3 个纯标准库脚本(scaffold/validate/trigger_eval)
- 自身触发用例 16 条 + 先例研究/创建/迭代证据报告
- 方法论吸收自 qiaomu-meta-skill 与 luban-skill,取舍见 reports/prior-art.md
2026-10-05 12:09:22 +08:00

195 lines
6.9 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.
#!/usr/bin/env python3
"""LeMetaSkill 结构体检:检查一个 skill 目录是否满足基本契约。
只依赖标准库。检查项(FAIL 阻断 / WARN 提醒):
- SKILL.md 存在、frontmatter 可解析 [FAIL]
- name 存在、kebab-case、1-64 字符、与目录名一致 [FAIL]
- description 存在且够长(>=30 字符) [WARN]
- description 含负触发信号("不要用于"类表述) [WARN]
- 正文非空、<=500 行 [WARN]
- 正文中的本地 .md 链接可达(无悬空引用) [FAIL]
- evals/*.json 可解析 [FAIL]
- 无密钥/私有绝对路径 [WARN]
- 顶层目录都是已知用途 [WARN]
用法: python3 validate_skill.py <skill目录>
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
KNOWN_DIRS = {"references", "scripts", "evals", "assets", "reports", "examples", "tests"}
MAX_BODY_LINES = 500
SECRET_PATTERNS = [
r"AKIA[0-9A-Z]{16}",
r"sk-[A-Za-z0-9]{20,}",
r"(?:api[_-]?key|token|secret|password)\s*[:=]\s*['\"]?[A-Za-z0-9_\-]{16,}",
r"ghp_[A-Za-z0-9]{36}",
]
PRIVATE_PATH = "/Users" + "/[A-Za-z0-9_.\\-]+/" # 拼接写法:避免扫描器匹配到自身源码
NEGATIVE_HINT = re.compile(r"不要|不该|不用于|排除|exclude|not for|don'?t use|do not use", re.I)
MD_LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
def parse_frontmatter(text: str) -> tuple[dict[str, str], int]:
"""解析简易 YAML frontmatter(key: value 与 key: | 块),返回 (字段, 正文起始行号)。"""
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return {}, 0
fields: dict[str, str] = {}
key = ""
for i in range(1, min(len(lines), 60)):
line = lines[i]
if line.strip() == "---":
return fields, i + 1
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
if m:
key = m.group(1)
fields[key] = m.group(2).strip().strip("'\"")
elif line.startswith((" ", "\t")) and key:
fields[key] = (fields[key] + " " + line.strip().strip("'\"")).strip()
return {}, 0 # 没有闭合的 ---
def main() -> int:
ap = argparse.ArgumentParser(description="skill 结构体检")
ap.add_argument("skill_dir", type=Path)
args = ap.parse_args()
root: Path = args.skill_dir.resolve()
if not root.is_dir():
print(f"FAIL 目录不存在: {root}")
return 1
n_pass = n_warn = n_fail = 0
def ok(msg: str) -> None:
nonlocal n_pass
n_pass += 1
print(f"PASS {msg}")
def warn(msg: str) -> None:
nonlocal n_warn
n_warn += 1
print(f"WARN {msg}")
def fail(msg: str) -> None:
nonlocal n_fail
n_fail += 1
print(f"FAIL {msg}")
skill_md = root / "SKILL.md"
if not skill_md.is_file():
fail("SKILL.md 不存在")
print(f"\n--- Summary --- PASS:{n_pass} WARN:{n_warn} FAIL:{n_fail}")
return 1
ok("SKILL.md 存在")
text = skill_md.read_text(encoding="utf-8")
fields, body_start = parse_frontmatter(text)
if not fields:
fail("frontmatter 缺失或未闭合(需要 --- 包裹的头)")
else:
ok("frontmatter 可解析")
name = fields.get("name", "")
if not name:
fail("缺少 name 字段")
else:
if not re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", name):
fail(f"name 不是 kebab-case: {name!r}")
elif not 1 <= len(name) <= 64:
fail(f"name 长度超出 1-64: {len(name)}")
else:
ok(f"name 合法: {name}")
if root.name != name:
fail(f"name 与目录名不一致: {name!r} vs {root.name!r}")
else:
ok("name 与目录名一致")
desc = fields.get("description", "")
if not desc:
fail("缺少 description 字段(触发信号,必填)")
else:
if len(desc) < 30:
warn(f"description 太短({len(desc)} 字符),盖不住触发场景")
else:
ok(f"description 长度充分({len(desc)} 字符)")
if NEGATIVE_HINT.search(desc):
ok("description 含负触发信号")
else:
warn("description 缺负触发(\"不要用于…\"),边界请求会犹豫")
body = "\n".join(text.splitlines()[body_start:]).strip()
if not body:
fail("正文为空")
else:
n_lines = len(body.splitlines())
if n_lines > MAX_BODY_LINES:
warn(f"正文 {n_lines} 行,超过 {MAX_BODY_LINES} 行预算——考虑拆进 references/")
else:
ok(f"正文 {n_lines} 行,在预算内")
# 悬空引用
dangling = []
for target in MD_LINK.findall(body):
if target.startswith(("http://", "https://", "mailto:", "#")):
continue
if not (root / target).exists():
dangling.append(target)
if dangling:
fail(f"正文引用的本地文件不存在: {', '.join(sorted(set(dangling)))}")
elif MD_LINK.search(body):
ok("正文本地链接全部可达")
# evals JSON
evals_dir = root / "evals"
if evals_dir.is_dir():
for j in sorted(evals_dir.glob("*.json")):
try:
json.loads(j.read_text(encoding="utf-8"))
ok(f"{j.relative_to(root)} 可解析")
except json.JSONDecodeError as e:
fail(f"{j.relative_to(root)} JSON 解析失败: {e}")
# 密钥与私有路径(只读文本类文件)
for p in sorted(root.rglob("*")):
if not p.is_file() or p.suffix not in {".md", ".json", ".py", ".sh", ".txt", ".yaml", ".yml"}:
continue
try:
content = p.read_text(encoding="utf-8")
except (UnicodeDecodeError, PermissionError):
continue
rel = p.relative_to(root)
for pat in SECRET_PATTERNS:
if re.search(pat, content):
warn(f"{rel} 疑似包含密钥/令牌(模式 {pat})")
if rel.name != "SKILL.md" and re.search(PRIVATE_PATH, content):
warn(f"{rel} 含私有绝对路径(/Users/…)")
# 顶层结构
unknown = [d.name for d in root.iterdir() if d.is_dir() and d.name not in KNOWN_DIRS]
if unknown:
warn(f"未知顶层目录(确认其用途,或移除): {', '.join(unknown)}")
present = [d.name for d in root.iterdir() if d.is_dir() and d.name in KNOWN_DIRS]
if present:
ok(f"标准目录: {', '.join(sorted(present))}")
print(f"\n--- Summary --- PASS:{n_pass} WARN:{n_warn} FAIL:{n_fail}")
if n_fail:
print("结论:未过体检,先修 FAIL 再交付。")
return 1
if n_warn:
print("结论:可交付,WARN 项酌情处理。")
else:
print("结论:全绿。")
return 0
if __name__ == "__main__":
sys.exit(main())