diff --git a/scripts/skills.py b/scripts/skills.py index 3c7e2da..42d64d4 100644 --- a/scripts/skills.py +++ b/scripts/skills.py @@ -101,6 +101,7 @@ _COPILOT_EVENTS, _check_hook_event_names, check_plugin_components, + check_skill_frontmatter, _HOOK_PY_RE, _check_hook_wiring, check_cursor_plugin, diff --git a/scripts/skillsgen/cli.py b/scripts/skillsgen/cli.py index 08b09fe..fafb755 100644 --- a/scripts/skillsgen/cli.py +++ b/scripts/skillsgen/cli.py @@ -30,6 +30,7 @@ check_cursor_plugin, check_plugin_components, check_routing_tables, + check_skill_frontmatter, ) @@ -103,6 +104,16 @@ def main() -> None: print(f" - {err}", file=sys.stderr) ok = False + frontmatter_errors = check_skill_frontmatter(repo_root) + if frontmatter_errors: + print( + "ERROR: skill SKILL.md frontmatter is invalid:", + file=sys.stderr, + ) + for err in frontmatter_errors: + print(f" - {err}", file=sys.stderr) + ok = False + if not validate_manifest(repo_root): ok = False diff --git a/scripts/skillsgen/validators.py b/scripts/skillsgen/validators.py index 8d3c6c6..79cf4ad 100644 --- a/scripts/skillsgen/validators.py +++ b/scripts/skillsgen/validators.py @@ -5,6 +5,7 @@ from pathlib import Path from skillsgen.common import _norm_rel_path, _read_frontmatter +from skillsgen.discovery import iter_all_skill_dirs # --------------------------------------------------------------------------- @@ -156,6 +157,34 @@ def check_plugin_components(repo_root: Path) -> list[str]: return errors +def check_skill_frontmatter(repo_root: Path) -> list[str]: + """Flag SKILL.md frontmatter that a strict YAML parser would reject. + + Checked with a regex, not yaml.safe_load, because this package is + stdlib-only (the protected CI runner has no pypi). The failure that bites in + practice: an unquoted ':' in `description`, which strict-YAML skill loaders + read as a mapping separator and silently drop. Skills counterpart to the + commands check in check_plugin_components. + + Returns a list of error strings (empty means all good). + """ + errors: list[str] = [] + for skill_dir in iter_all_skill_dirs(repo_root): + rel = (skill_dir / "SKILL.md").relative_to(repo_root) + frontmatter = _read_frontmatter(skill_dir / "SKILL.md") + if frontmatter is None: + errors.append(f"Skill '{rel}' is missing YAML frontmatter.") + elif not re.search(r"^description:\s*\S", frontmatter, re.MULTILINE): + errors.append(f"Skill '{rel}' frontmatter is missing a 'description'.") + elif re.search(r"^description:[ \t]*[^\s\"'>|].*:(?:\s|$)", frontmatter, re.MULTILINE): + errors.append( + f"Skill '{rel}' has an unquoted ':' in its description, which " + "strict YAML parsers reject (the skill is then silently dropped " + "at load time). Quote the whole description string." + ) + return errors + + # Any hooks/*.py mentioned in a hooks wiring file, regardless of how the # platform prefixes the path (${CLAUDE_PLUGIN_ROOT}/, plugin-root-relative, …). _HOOK_PY_RE = re.compile(r"hooks/[\w.-]+\.py") diff --git a/skills/databricks-app-design/SKILL.md b/skills/databricks-app-design/SKILL.md index e6f07a4..1e5d023 100644 --- a/skills/databricks-app-design/SKILL.md +++ b/skills/databricks-app-design/SKILL.md @@ -1,7 +1,7 @@ --- name: databricks-app-design parent: databricks-apps -description: Design the UX of Databricks data apps — dashboards, KPI pages, reports, charts, tables, and Genie/chat data assistants — mapped to concrete AppKit components. Use when BUILDING or reviewing any UI that displays data or answers data questions: choosing genre, layout, charts, KPIs, semantic color, required states (loading/empty/error), IBCS notation, and AI-result trust (showing generated SQL/sources for Genie/chat). NOT for authoring managed AI/BI (Lakeview) dashboards (→ databricks-aibi-dashboards), non-data frontend (forms, settings, auth, marketing), or scaffolding/build/deploy (→ databricks-apps). Complements databricks-apps; use it alongside whenever the app has a dashboard, chart, table, KPI, report, or Genie/chat/AI surface. +description: "Design the UX of Databricks data apps — dashboards, KPI pages, reports, charts, tables, and Genie/chat data assistants — mapped to concrete AppKit components. Use when BUILDING or reviewing any UI that displays data or answers data questions: choosing genre, layout, charts, KPIs, semantic color, required states (loading/empty/error), IBCS notation, and AI-result trust (showing generated SQL/sources for Genie/chat). NOT for authoring managed AI/BI (Lakeview) dashboards (→ databricks-aibi-dashboards), non-data frontend (forms, settings, auth, marketing), or scaffolding/build/deploy (→ databricks-apps). Complements databricks-apps; use it alongside whenever the app has a dashboard, chart, table, KPI, report, or Genie/chat/AI surface." metadata: version: 0.1.0 --- diff --git a/tests/skills_generator_test.py b/tests/skills_generator_test.py index a12f951..9ec6508 100644 --- a/tests/skills_generator_test.py +++ b/tests/skills_generator_test.py @@ -167,5 +167,49 @@ def test_missing_keyword(self): self.assertTrue(any("keyword" in e for e in errors), errors) +class SkillFrontmatterTest(unittest.TestCase): + def _make_skill(self, root: Path, name: str, description: str) -> None: + skill_dir = root / "skills" / name + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {description}\n---\n" + ) + + def test_repo_skills_are_clean(self): + # Pins the databricks-app-design fix and guards every shipped skill: no + # SKILL.md may carry a description a strict YAML parser would reject. + self.assertEqual(skills.check_skill_frontmatter(_REPO), []) + + def test_unquoted_colon_flagged(self): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + self._make_skill( + root, "databricks-bad", "Use when answering questions: pick a chart." + ) + errors = skills.check_skill_frontmatter(root) + self.assertTrue( + any("databricks-bad" in e and "unquoted ':'" in e for e in errors), + errors, + ) + + def test_quoted_colon_ok(self): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + self._make_skill( + root, "databricks-good", '"Use when answering questions: pick a chart."' + ) + self.assertEqual(skills.check_skill_frontmatter(root), []) + + def test_unquoted_no_colon_ok(self): + # The common, valid shape: a plain bare scalar with no colon. Guards + # against the regex over-matching and flagging legitimate descriptions. + with tempfile.TemporaryDirectory() as d: + root = Path(d) + self._make_skill( + root, "databricks-plain", "Use when building dashboards and charts." + ) + self.assertEqual(skills.check_skill_frontmatter(root), []) + + if __name__ == "__main__": unittest.main()