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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions