mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-14 07:10:57 +00:00
* fix(skills): load SKILL.md saved as UTF-8 with a BOM The front-matter anchor began at ^---, so the U+FEFF that Windows Notepad and PowerShell's `Set-Content -Encoding UTF8` prepend made the match fail. parse_skill_file then returned None and the catalog builder dropped the skill without logging, leaving an author with a valid SKILL.md that simply never appears. split_skill_markdown reported "No YAML frontmatter found" for the same file at install time. Let the anchor consume one leading byte-order mark, and reuse the shared compiled pattern in parser.py instead of the duplicated inline copy so the two cannot drift apart again. * fix(skills): capture the description of a BOM-prefixed SKILL.md The reader in skill_context anchored on ^---, so the byte-order mark that this change already tolerates in the loader and validator made the match fail and build_skill_entry_metadata_from_read capture an empty description. render_skill_context then listed the skill without the one line the active-skill reminder shows for it. Consume one leading byte-order mark there and pin the capture with a regression test.
76 lines
2.1 KiB
Python
76 lines
2.1 KiB
Python
"""Shared SKILL.md frontmatter parsing helpers.
|
|
|
|
The runtime parser, install-time validator, and review core all use this module
|
|
as the schema source for DeerFlow SKILL.md metadata.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
ALLOWED_FRONTMATTER_PROPERTIES = {
|
|
"name",
|
|
"description",
|
|
"license",
|
|
"allowed-tools",
|
|
"argument-hint",
|
|
"required-secrets",
|
|
"secrets-autonomous",
|
|
"metadata",
|
|
"compatibility",
|
|
"version",
|
|
"author",
|
|
}
|
|
|
|
# `\ufeff` is the UTF-8 byte-order mark that Windows Notepad and PowerShell's
|
|
# `Set-Content -Encoding UTF8` prepend to a saved file. It is consumed by the match, so
|
|
# neither the front matter nor the body carries it downstream.
|
|
_FRONTMATTER_RE = re.compile(r"^\ufeff?---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SkillMarkdownParts:
|
|
"""Parsed pieces of a SKILL.md document."""
|
|
|
|
metadata: dict[str, Any]
|
|
frontmatter_text: str
|
|
body: str
|
|
|
|
|
|
def split_skill_markdown(content: str) -> tuple[SkillMarkdownParts | None, str | None]:
|
|
"""Split a SKILL.md document into frontmatter and body.
|
|
|
|
Returns ``(parts, None)`` on success and ``(None, message)`` on failure. The
|
|
message intentionally avoids host paths so callers can reuse it in
|
|
deterministic review output.
|
|
"""
|
|
match = _FRONTMATTER_RE.match(content)
|
|
if not match:
|
|
return None, "No YAML frontmatter found"
|
|
|
|
frontmatter_text = match.group(1)
|
|
try:
|
|
metadata = yaml.safe_load(frontmatter_text)
|
|
except yaml.YAMLError as exc:
|
|
return None, f"Invalid YAML in frontmatter: {exc}"
|
|
|
|
if not isinstance(metadata, dict):
|
|
return None, "Frontmatter must be a YAML dictionary"
|
|
|
|
# YAML permits non-string keys, but downstream validation expects field
|
|
# names to be strings.
|
|
metadata = {str(key): value for key, value in metadata.items()}
|
|
|
|
return (
|
|
SkillMarkdownParts(
|
|
metadata=metadata,
|
|
frontmatter_text=frontmatter_text,
|
|
body=content[match.end() :],
|
|
),
|
|
None,
|
|
)
|