From c651ba22b7d24702243ceb07fd3de8f7b59f0b93 Mon Sep 17 00:00:00 2001 From: PeninsulaInd Date: Tue, 17 Feb 2026 10:01:17 -0600 Subject: [PATCH] =?UTF-8?q?2.2:=20Create=20cheddahbot/skills.py=20?= =?UTF-8?q?=E2=80=94=20markdown=20skill=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New module (not package) that discovers .md files with YAML frontmatter in the skills/ directory. Provides: - SkillDef dataclass: name, description, content, tools, agents, file_path - SkillRegistry: discovers skills, filters by agent, builds prompt sections - _parse_frontmatter(): splits YAML frontmatter from markdown body - get_prompt_section(agent_name): builds system prompt injection - get_body(name): returns skill content without frontmatter Files without frontmatter (data files) are automatically skipped. Co-Authored-By: Claude Opus 4.6 --- cheddahbot/skills.py | 132 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 cheddahbot/skills.py diff --git a/cheddahbot/skills.py b/cheddahbot/skills.py new file mode 100644 index 0000000..97538b4 --- /dev/null +++ b/cheddahbot/skills.py @@ -0,0 +1,132 @@ +"""Markdown skill registry. + +Skills are .md files in the skills/ directory with YAML frontmatter: + + --- + name: press-release-writer + description: Write professional press releases + tools: [write_press_releases, submit_press_release] + agents: [writer, default] + --- + # Press Release Workflow + ... + +Files without frontmatter (like companies.md, headlines.md) are data +files and get skipped. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from pathlib import Path + +import yaml + +log = logging.getLogger(__name__) + + +@dataclass +class SkillDef: + name: str + description: str + content: str + file_path: Path + tools: list[str] = field(default_factory=list) + agents: list[str] = field(default_factory=list) + + +def _parse_frontmatter(text: str) -> tuple[dict, str]: + """Split YAML frontmatter from markdown content. + + Returns (frontmatter_dict, body) or ({}, full_text) if no frontmatter. + """ + if not text.startswith("---"): + return {}, text + + end = text.find("---", 3) + if end == -1: + return {}, text + + yaml_block = text[3:end].strip() + body = text[end + 3 :].strip() + + try: + meta = yaml.safe_load(yaml_block) or {} + except yaml.YAMLError as e: + log.warning("Failed to parse YAML frontmatter: %s", e) + return {}, text + + if not isinstance(meta, dict): + return {}, text + + return meta, body + + +class SkillRegistry: + """Discovers and holds markdown skill definitions.""" + + def __init__(self, skills_dir: Path): + self._skills: dict[str, SkillDef] = {} + self._skills_dir = skills_dir + self._discover() + + def _discover(self): + """Load all .md files with valid frontmatter from the skills directory.""" + if not self._skills_dir.exists(): + log.warning("Skills directory not found: %s", self._skills_dir) + return + + for path in sorted(self._skills_dir.glob("*.md")): + try: + text = path.read_text(encoding="utf-8") + except Exception as e: + log.warning("Failed to read skill file %s: %s", path.name, e) + continue + + meta, body = _parse_frontmatter(text) + if not meta.get("name"): + # No frontmatter or no name — skip (data file) + continue + + skill = SkillDef( + name=meta["name"], + description=meta.get("description", ""), + content=body, + file_path=path, + tools=meta.get("tools", []), + agents=meta.get("agents", []), + ) + self._skills[skill.name] = skill + log.info("Loaded skill: %s (%s)", skill.name, path.name) + + def get(self, name: str) -> SkillDef | None: + return self._skills.get(name) + + def list_skills(self) -> list[SkillDef]: + return list(self._skills.values()) + + def get_prompt_section(self, agent_name: str = "default") -> str: + """Build a prompt section with skills relevant to an agent. + + If a skill's agents list is empty, it's available to all agents. + Otherwise, only agents listed in the skill's agents list get it. + """ + parts = [] + for skill in self._skills.values(): + if skill.agents and agent_name not in skill.agents: + continue + parts.append( + f"### Skill: {skill.name}\n" + f"{skill.description}\n" + ) + + if not parts: + return "" + + return "# Available Skills\n" + "\n".join(parts) + + def get_body(self, name: str) -> str: + """Get the body content of a skill (without frontmatter).""" + skill = self._skills.get(name) + return skill.content if skill else ""