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