2.2: Create cheddahbot/skills.py — markdown skill registry
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 <noreply@anthropic.com>cora-start
parent
4a646373b6
commit
c651ba22b7
|
|
@ -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 ""
|
||||
Loading…
Reference in New Issue