fix(skills): support parsing multiline YAML strings in SKILL.md frontmatter (#1703)
* fix(skills): support parsing multiline YAML strings in SKILL.md frontmatter * test(skills): add tests for multiline YAML descriptions
This commit is contained in:
parent
68d44f6755
commit
e97c8c9943
|
|
@ -33,15 +33,72 @@ def parse_skill_file(skill_file: Path, category: str, relative_path: Path | None
|
||||||
|
|
||||||
front_matter = front_matter_match.group(1)
|
front_matter = front_matter_match.group(1)
|
||||||
|
|
||||||
# Parse YAML front matter (simple key-value parsing)
|
# Parse YAML front matter with basic multiline string support
|
||||||
metadata = {}
|
metadata = {}
|
||||||
for line in front_matter.split("\n"):
|
lines = front_matter.split("\n")
|
||||||
line = line.strip()
|
current_key = None
|
||||||
if not line:
|
current_value = []
|
||||||
|
is_multiline = False
|
||||||
|
multiline_style = None
|
||||||
|
indent_level = None
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if is_multiline:
|
||||||
|
if not line.strip():
|
||||||
|
current_value.append("")
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_indent = len(line) - len(line.lstrip())
|
||||||
|
|
||||||
|
if indent_level is None:
|
||||||
|
if current_indent > 0:
|
||||||
|
indent_level = current_indent
|
||||||
|
current_value.append(line[indent_level:])
|
||||||
|
continue
|
||||||
|
elif current_indent >= indent_level:
|
||||||
|
current_value.append(line[indent_level:])
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If we reach here, it's either a new key or the end of multiline
|
||||||
|
if current_key and is_multiline:
|
||||||
|
if multiline_style == "|":
|
||||||
|
metadata[current_key] = "\n".join(current_value).rstrip()
|
||||||
|
else:
|
||||||
|
text = "\n".join(current_value).rstrip()
|
||||||
|
# Replace single newlines with spaces for folded blocks
|
||||||
|
metadata[current_key] = re.sub(r"(?<!\n)\n(?!\n)", " ", text)
|
||||||
|
|
||||||
|
current_key = None
|
||||||
|
current_value = []
|
||||||
|
is_multiline = False
|
||||||
|
multiline_style = None
|
||||||
|
indent_level = None
|
||||||
|
|
||||||
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if ":" in line:
|
if ":" in line:
|
||||||
|
# Handle nested dicts simply by ignoring indentation for now,
|
||||||
|
# or just extracting top-level keys
|
||||||
key, value = line.split(":", 1)
|
key, value = line.split(":", 1)
|
||||||
metadata[key.strip()] = value.strip()
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
|
||||||
|
if value in (">", "|"):
|
||||||
|
current_key = key
|
||||||
|
is_multiline = True
|
||||||
|
multiline_style = value
|
||||||
|
current_value = []
|
||||||
|
indent_level = None
|
||||||
|
else:
|
||||||
|
metadata[key] = value
|
||||||
|
|
||||||
|
if current_key and is_multiline:
|
||||||
|
if multiline_style == "|":
|
||||||
|
metadata[current_key] = "\n".join(current_value).rstrip()
|
||||||
|
else:
|
||||||
|
text = "\n".join(current_value).rstrip()
|
||||||
|
metadata[current_key] = re.sub(r"(?<!\n)\n(?!\n)", " ", text)
|
||||||
|
|
||||||
# Extract required fields
|
# Extract required fields
|
||||||
name = metadata.get("name")
|
name = metadata.get("name")
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,27 @@ class TestParseSkillFile:
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.description == "A skill: does things"
|
assert result.description == "A skill: does things"
|
||||||
|
|
||||||
|
def test_multiline_yaml_folded_description(self, tmp_path):
|
||||||
|
skill_file = _write_skill(
|
||||||
|
tmp_path,
|
||||||
|
"---\nname: multiline-skill\ndescription: >\n This is a multiline\n description for a skill.\n\n It spans multiple lines.\nlicense: MIT\n---\n\nBody\n",
|
||||||
|
)
|
||||||
|
result = parse_skill_file(skill_file, "public")
|
||||||
|
assert result is not None
|
||||||
|
assert result.name == "multiline-skill"
|
||||||
|
assert result.description == "This is a multiline description for a skill.\n\nIt spans multiple lines."
|
||||||
|
assert result.license == "MIT"
|
||||||
|
|
||||||
|
def test_multiline_yaml_literal_description(self, tmp_path):
|
||||||
|
skill_file = _write_skill(
|
||||||
|
tmp_path,
|
||||||
|
"---\nname: pipe-skill\ndescription: |\n First line.\n Second line.\n---\n\nBody\n",
|
||||||
|
)
|
||||||
|
result = parse_skill_file(skill_file, "public")
|
||||||
|
assert result is not None
|
||||||
|
assert result.name == "pipe-skill"
|
||||||
|
assert result.description == "First line.\nSecond line."
|
||||||
|
|
||||||
def test_empty_front_matter_returns_none(self, tmp_path):
|
def test_empty_front_matter_returns_none(self, tmp_path):
|
||||||
skill_file = _write_skill(tmp_path, "---\n\n---\n\nBody\n")
|
skill_file = _write_skill(tmp_path, "---\n\n---\n\nBody\n")
|
||||||
assert parse_skill_file(skill_file, "public") is None
|
assert parse_skill_file(skill_file, "public") is None
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue