Skip to content

07中load_skill问题建议 #430

Description

@zzzauly0v0

修复 _parse_frontmatter / _scan_skills 的若干边界判断问题
背景
learn-cc/skills/skills.py 中的 _parse_frontmatter 与 _scan_skills 负责扫描 skills/ 目录、解析每个 SKILL.md 的 YAML frontmatter,并把结果注册进 SKILL_REGISTRY。原实现在常规模板下能正常工作,但对几类边界输入不够鲁棒,可能导致误解析、抛异常或注册表被污染。
改动一览

  1. text.startswith("---") → text.startswith("---\n")
    问题:原判断只检查前 3 个字符,会把以下输入误识别为带 frontmatter:

--- 一些文字(破折号开头的普通段落)
----分隔线 / ---some-flag
正文内出现 Markdown 水平线 --- 的情况
一旦进入解析分支,split("---", 2) 可能把正文里的 --- 当成 frontmatter 边界拆开,把一段正文误送进 yaml.safe_load。

修复:要求开头必须是 --- + 换行符,符合 YAML frontmatter 规范

  1. yaml.safe_load(...) 后增加 isinstance(meta, dict) 检查
    问题:yaml.safe_load 不只返回 dict——如果 frontmatter 写成纯字符串、列表或数字(虽然不规范,但用户可能写错),会返回非 dict 对象。后续 meta.get("name") 会直接抛 AttributeError,导致整个 skill 扫描中断。

修复:解析后兜底为 {},让后续逻辑走 fallback 分支即可。

  1. meta.get("name", d.name) → meta.get("name") or d.name
    问题:dict.get(k, default) 只在 key 不存在时返回 default。如果 YAML 里写了 name:(值为空 → None),get 会返回 None,导致 SKILL_REGISTRY[None] = {...},后续 load_skill 全部失效。

修复:用 or 把 None / 空串也视作"未提供",统一退到目录名兜底。description 字段同理。

  1. description 兜底源由 raw 改为 body
    问题:原写法 raw.split("\n")[0] 取的是整篇 SKILL.md 的第一行。当 frontmatter 存在时,第一行就是 ---,lstrip("#").strip() 之后变成空串——description 永远是空的。

修复:从 body(已去除 frontmatter 的正文)取首行,并改用 split("\n", 1)[0] 避免把整篇文档全切一遍(小幅性能优化)。

影响范围
对标准 SKILL.md(frontmatter 写全):行为完全一致,没有 regression。
对异常 / 不规范输入:从"抛异常 / 静默错误"改为"安全降级 + 目录名兜底"。
测试
可用以下几类输入回归:

标准模板(参考 roll-dice 的 SKILL.md)— 应正常解析 name / description / content。
frontmatter 缺失字段(如只有 name: 没有值)— 应 fallback 到目录名 / body 首行。
正文中包含 --- Markdown 水平线 — 不应被误识别为 frontmatter 边界。
frontmatter YAML 写成非字典(如 --- \nhello\n---)— 不应抛 AttributeError。

def _parse_frontmatter(text: str) -> tuple[dict, str]:
    """ 解析文本中的 YAML frontmatter """
    # new 在此处加入换行符, 标准SKILL.md都是---单独成行
    if not text.startswith("---\n"):
        return {}, text
    parts = text.split("---", 2)
    if len(parts) < 3:
        return {}, text
    try:
        # 得到skill的标题
        meta = yaml.safe_load(parts[1]) or {}
    except yaml.YAMLError:
        meta = {}
    # 加一道类型检查,防止yaml解析失败
    if not isinstance(meta, dict):
        meta = {}
    return meta, parts[2].strip()

SKILL_REGISTRY: dict[str, dict] = {}

def _scan_skills():
    if not SKILLS_DIR.exists():
        return
    for d in sorted(SKILLS_DIR.iterdir()):
        # 查找skill目录
        if not d.is_dir():
            continue
        manifest = d / "SKILL.md"
        # In an standard SIILL.md template, we can find the basic structure of this 
        # ---
        # name: roll-dice
        # description: Roll dice using a random number generator. Use when asked to roll a die (d6, d20, etc.), roll dice, or generate a random dice roll.
        # ---

        # To roll a die, use the following command that generates a random number from 1
        # to the given number of sides:

        if manifest.exists():

            raw = manifest.read_text()
            meta, body = _parse_frontmatter(raw)
            # 首先处理meta, 这里使用了or兜底, 因为有些skill可能name没写
            name = meta.get("name") or d.name
            # 直接获取description的value数据, 如果前者不存在, 用正文首行兜底
            desc = meta.get("description") or body.split("\n", 1)[0].lstrip("#").strip()
            SKILL_REGISTRY[name] = {"name": name, "description": desc, "content": body}

_scan_skills()

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions