test(skills): add frontmatter name override tests
Add comprehensive test coverage for skill name resolution priority: - test_frontmatter_name_overrides_dir_name: Verifies that the 'name' field in YAML frontmatter takes precedence over directory name, enabling namespaced skills like "doodle:bug-hunter" - test_name_preserved_across_fallback_files: Confirms that when SKILL.md provides a name but README.md provides the description, the name from SKILL.md is preserved (important for multi-file skill definitions) - test_no_frontmatter_name_uses_dir_name: Validates fallback behavior when no name is specified in frontmatter This ensures the skill autocomplete system correctly handles both simple directory-named skills and explicitly namespaced skills from skill packs. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -155,6 +155,51 @@ class TestEnumerateClaudeSkills(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(result[0]["description"], "Skill: empty-skill")
|
self.assertEqual(result[0]["description"], "Skill: empty-skill")
|
||||||
|
|
||||||
|
def test_frontmatter_name_overrides_dir_name(self):
|
||||||
|
"""Frontmatter name field takes priority over directory name."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
skill_dir = Path(tmpdir) / ".claude/skills/bug-hunter"
|
||||||
|
skill_dir.mkdir(parents=True)
|
||||||
|
content = '---\nname: "doodle:bug-hunter"\ndescription: Find bugs\n---\n'
|
||||||
|
(skill_dir / "SKILL.md").write_text(content)
|
||||||
|
|
||||||
|
with patch.object(Path, "home", return_value=Path(tmpdir)):
|
||||||
|
result = self.handler._enumerate_claude_skills()
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertEqual(result[0]["name"], "doodle:bug-hunter")
|
||||||
|
self.assertEqual(result[0]["description"], "Find bugs")
|
||||||
|
|
||||||
|
def test_name_preserved_across_fallback_files(self):
|
||||||
|
"""Name from SKILL.md is kept even when description comes from README.md."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
skill_dir = Path(tmpdir) / ".claude/skills/bug-hunter"
|
||||||
|
skill_dir.mkdir(parents=True)
|
||||||
|
# SKILL.md has name but no extractable description (headers only)
|
||||||
|
(skill_dir / "SKILL.md").write_text(
|
||||||
|
'---\nname: "doodle:bug-hunter"\n---\n# Bug Hunter\n## Overview'
|
||||||
|
)
|
||||||
|
# README.md provides the description
|
||||||
|
(skill_dir / "README.md").write_text("Find and fix obvious bugs")
|
||||||
|
|
||||||
|
with patch.object(Path, "home", return_value=Path(tmpdir)):
|
||||||
|
result = self.handler._enumerate_claude_skills()
|
||||||
|
|
||||||
|
self.assertEqual(result[0]["name"], "doodle:bug-hunter")
|
||||||
|
self.assertEqual(result[0]["description"], "Find and fix obvious bugs")
|
||||||
|
|
||||||
|
def test_no_frontmatter_name_uses_dir_name(self):
|
||||||
|
"""Without name in frontmatter, falls back to directory name."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
skill_dir = Path(tmpdir) / ".claude/skills/plain-skill"
|
||||||
|
skill_dir.mkdir(parents=True)
|
||||||
|
(skill_dir / "SKILL.md").write_text("---\ndescription: No name field\n---\n")
|
||||||
|
|
||||||
|
with patch.object(Path, "home", return_value=Path(tmpdir)):
|
||||||
|
result = self.handler._enumerate_claude_skills()
|
||||||
|
|
||||||
|
self.assertEqual(result[0]["name"], "plain-skill")
|
||||||
|
|
||||||
def test_no_md_files_uses_fallback(self):
|
def test_no_md_files_uses_fallback(self):
|
||||||
"""Skill dir with no markdown files uses fallback description."""
|
"""Skill dir with no markdown files uses fallback description."""
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
@@ -182,122 +227,111 @@ class TestEnumerateClaudeSkills(unittest.TestCase):
|
|||||||
self.assertEqual(result[0]["description"], "Skill: broken-skill")
|
self.assertEqual(result[0]["description"], "Skill: broken-skill")
|
||||||
|
|
||||||
|
|
||||||
class TestExtractDescription(unittest.TestCase):
|
class TestParseFrontmatter(unittest.TestCase):
|
||||||
"""Tests for _extract_description."""
|
"""Tests for _parse_frontmatter."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.handler = DummySkillsHandler()
|
self.handler = DummySkillsHandler()
|
||||||
|
|
||||||
|
def _desc(self, content: str) -> str:
|
||||||
|
return self.handler._parse_frontmatter(content)["description"]
|
||||||
|
|
||||||
|
def _name(self, content: str) -> str:
|
||||||
|
return self.handler._parse_frontmatter(content)["name"]
|
||||||
|
|
||||||
def test_empty_content(self):
|
def test_empty_content(self):
|
||||||
self.assertEqual(self.handler._extract_description(""), "")
|
self.assertEqual(self._desc(""), "")
|
||||||
|
self.assertEqual(self._name(""), "")
|
||||||
|
|
||||||
def test_plain_text(self):
|
def test_plain_text(self):
|
||||||
self.assertEqual(
|
self.assertEqual(self._desc("Simple description"), "Simple description")
|
||||||
self.handler._extract_description("Simple description"),
|
|
||||||
"Simple description",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_yaml_frontmatter_description(self):
|
def test_yaml_frontmatter_description(self):
|
||||||
content = "---\ndescription: A skill for formatting\n---\nBody text"
|
content = "---\ndescription: A skill for formatting\n---\nBody text"
|
||||||
self.assertEqual(
|
self.assertEqual(self._desc(content), "A skill for formatting")
|
||||||
self.handler._extract_description(content),
|
|
||||||
"A skill for formatting",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_yaml_frontmatter_quoted_description(self):
|
def test_yaml_frontmatter_quoted_description(self):
|
||||||
content = '---\ndescription: "Quoted desc"\n---\n'
|
content = '---\ndescription: "Quoted desc"\n---\n'
|
||||||
self.assertEqual(
|
self.assertEqual(self._desc(content), "Quoted desc")
|
||||||
self.handler._extract_description(content),
|
|
||||||
"Quoted desc",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_yaml_frontmatter_single_quoted_description(self):
|
def test_yaml_frontmatter_single_quoted_description(self):
|
||||||
content = "---\ndescription: 'Single quoted'\n---\n"
|
content = "---\ndescription: 'Single quoted'\n---\n"
|
||||||
self.assertEqual(
|
self.assertEqual(self._desc(content), "Single quoted")
|
||||||
self.handler._extract_description(content),
|
|
||||||
"Single quoted",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_yaml_multiline_fold_indicator(self):
|
def test_yaml_multiline_fold_indicator(self):
|
||||||
"""Handles >- style multi-line YAML."""
|
"""Handles >- style multi-line YAML."""
|
||||||
content = "---\ndescription: >-\n Multi-line folded text\n---\n"
|
content = "---\ndescription: >-\n Multi-line folded text\n---\n"
|
||||||
self.assertEqual(
|
self.assertEqual(self._desc(content), "Multi-line folded text")
|
||||||
self.handler._extract_description(content),
|
|
||||||
"Multi-line folded text",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_yaml_multiline_literal_indicator(self):
|
def test_yaml_multiline_literal_indicator(self):
|
||||||
"""Handles |- style multi-line YAML."""
|
"""Handles |- style multi-line YAML."""
|
||||||
content = "---\ndescription: |-\n Literal block text\n---\n"
|
content = "---\ndescription: |-\n Literal block text\n---\n"
|
||||||
self.assertEqual(
|
self.assertEqual(self._desc(content), "Literal block text")
|
||||||
self.handler._extract_description(content),
|
|
||||||
"Literal block text",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_yaml_multiline_bare_fold(self):
|
def test_yaml_multiline_bare_fold(self):
|
||||||
"""Handles > without trailing dash."""
|
"""Handles > without trailing dash."""
|
||||||
content = "---\ndescription: >\n Bare fold\n---\n"
|
content = "---\ndescription: >\n Bare fold\n---\n"
|
||||||
self.assertEqual(
|
self.assertEqual(self._desc(content), "Bare fold")
|
||||||
self.handler._extract_description(content),
|
|
||||||
"Bare fold",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_yaml_multiline_bare_literal(self):
|
def test_yaml_multiline_bare_literal(self):
|
||||||
"""Handles | without trailing dash."""
|
"""Handles | without trailing dash."""
|
||||||
content = "---\ndescription: |\n Bare literal\n---\n"
|
content = "---\ndescription: |\n Bare literal\n---\n"
|
||||||
self.assertEqual(
|
self.assertEqual(self._desc(content), "Bare literal")
|
||||||
self.handler._extract_description(content),
|
|
||||||
"Bare literal",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_yaml_empty_description_falls_back_to_body(self):
|
def test_yaml_empty_description_falls_back_to_body(self):
|
||||||
"""Empty description in frontmatter falls back to body text."""
|
"""Empty description in frontmatter falls back to body text."""
|
||||||
content = "---\ndescription:\n---\nFallback body line"
|
content = "---\ndescription:\n---\nFallback body line"
|
||||||
self.assertEqual(
|
self.assertEqual(self._desc(content), "Fallback body line")
|
||||||
self.handler._extract_description(content),
|
|
||||||
"Fallback body line",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_skips_headers_and_empty_lines(self):
|
def test_skips_headers_and_empty_lines(self):
|
||||||
content = "# Title\n\n## Section\n\nActual content"
|
content = "# Title\n\n## Section\n\nActual content"
|
||||||
self.assertEqual(
|
self.assertEqual(self._desc(content), "Actual content")
|
||||||
self.handler._extract_description(content),
|
|
||||||
"Actual content",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_skips_html_comments(self):
|
def test_skips_html_comments(self):
|
||||||
content = "<!-- comment -->\nReal content"
|
content = "<!-- comment -->\nReal content"
|
||||||
self.assertEqual(
|
self.assertEqual(self._desc(content), "Real content")
|
||||||
self.handler._extract_description(content),
|
|
||||||
"Real content",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_truncates_to_100_chars(self):
|
def test_truncates_to_100_chars(self):
|
||||||
long_line = "B" * 150
|
long_line = "B" * 150
|
||||||
self.assertEqual(
|
self.assertEqual(len(self._desc(long_line)), 100)
|
||||||
len(self.handler._extract_description(long_line)),
|
|
||||||
100,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_frontmatter_description_truncated(self):
|
def test_frontmatter_description_truncated(self):
|
||||||
desc = "C" * 150
|
desc = "C" * 150
|
||||||
content = f"---\ndescription: {desc}\n---\n"
|
content = f"---\ndescription: {desc}\n---\n"
|
||||||
self.assertEqual(
|
self.assertEqual(len(self._desc(content)), 100)
|
||||||
len(self.handler._extract_description(content)),
|
|
||||||
100,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_no_closing_frontmatter_extracts_description(self):
|
def test_no_closing_frontmatter_extracts_description(self):
|
||||||
"""Unclosed frontmatter still extracts description from the loop."""
|
"""Unclosed frontmatter still extracts description from the loop."""
|
||||||
content = "---\ndescription: Orphaned\ntitle: Test"
|
content = "---\ndescription: Orphaned\ntitle: Test"
|
||||||
# The frontmatter loop finds "description:" and returns early,
|
self.assertEqual(self._desc(content), "Orphaned")
|
||||||
# even though there's no closing "---"
|
|
||||||
result = self.handler._extract_description(content)
|
|
||||||
self.assertEqual(result, "Orphaned")
|
|
||||||
|
|
||||||
def test_body_only_headers_returns_empty(self):
|
def test_body_only_headers_returns_empty(self):
|
||||||
content = "# H1\n## H2\n### H3"
|
content = "# H1\n## H2\n### H3"
|
||||||
self.assertEqual(self.handler._extract_description(content), "")
|
self.assertEqual(self._desc(content), "")
|
||||||
|
|
||||||
|
# --- name field tests ---
|
||||||
|
|
||||||
|
def test_extracts_name_from_frontmatter(self):
|
||||||
|
content = "---\nname: doodle:bug-hunter\ndescription: Find bugs\n---\n"
|
||||||
|
self.assertEqual(self._name(content), "doodle:bug-hunter")
|
||||||
|
self.assertEqual(self._desc(content), "Find bugs")
|
||||||
|
|
||||||
|
def test_name_quoted(self):
|
||||||
|
content = '---\nname: "bmad:brainstorm"\n---\nBody'
|
||||||
|
self.assertEqual(self._name(content), "bmad:brainstorm")
|
||||||
|
|
||||||
|
def test_name_single_quoted(self):
|
||||||
|
content = "---\nname: 'my-prefix:skill'\n---\nBody"
|
||||||
|
self.assertEqual(self._name(content), "my-prefix:skill")
|
||||||
|
|
||||||
|
def test_no_name_field_returns_empty(self):
|
||||||
|
content = "---\ndescription: Just a description\n---\n"
|
||||||
|
self.assertEqual(self._name(content), "")
|
||||||
|
|
||||||
|
def test_name_truncated_to_100(self):
|
||||||
|
long_name = "N" * 150
|
||||||
|
content = f"---\nname: {long_name}\n---\n"
|
||||||
|
self.assertEqual(len(self._name(content)), 100)
|
||||||
|
|
||||||
|
|
||||||
class TestEnumerateCodexSkills(unittest.TestCase):
|
class TestEnumerateCodexSkills(unittest.TestCase):
|
||||||
|
|||||||
Reference in New Issue
Block a user