133 lines
3.7 KiB
Python
133 lines
3.7 KiB
Python
"""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 ""
|