from __future__ import annotations import re from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, Iterable, List, Optional import yaml @dataclass(slots=True) class Skill: name: str description: str path: Path content: str metadata: Dict[str, Any] = field(default_factory=dict) def summary(self) -> Dict[str, str]: return { "name": self.name, "description": self.description, "path": str(self.path), } class SkillStore: """Loads skills from one or more directories using SKILL.md frontmatter.""" def __init__(self, roots: Iterable[str | Path]) -> None: self.roots = [Path(root).resolve() for root in roots] def list_skills(self) -> List[Dict[str, str]]: return [skill.summary() for skill in self._discover()] def load_skill(self, name: str) -> Skill: normalized = name.strip().lower() for skill in self._discover(): if skill.name.lower() == normalized: return skill raise KeyError(f"Skill not found: {name}") def build_prompt_fragment(self, active_skills: Iterable[str]) -> str: chunks: List[str] = [] for skill_name in active_skills: skill = self.load_skill(skill_name) chunks.append(f"## Skill: {skill.name}\n{skill.content.strip()}") return "\n\n".join(chunks) def _discover(self) -> List[Skill]: skills: List[Skill] = [] for root in self.roots: if not root.exists(): continue for path in sorted(root.rglob("SKILL.md")): parsed = self._parse_skill(path) if parsed: skills.append(parsed) return skills def _parse_skill(self, path: Path) -> Optional[Skill]: raw = path.read_text(encoding="utf-8") frontmatter, body = _split_frontmatter(raw) data = yaml.safe_load(frontmatter) if frontmatter else {} if not isinstance(data, dict): data = {} name = str(data.get("name") or path.parent.name).strip() description = str(data.get("description") or "").strip() return Skill( name=name, description=description, path=path, content=body.strip(), metadata=data, ) _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", re.DOTALL) def _split_frontmatter(raw: str) -> tuple[str, str]: match = _FRONTMATTER_RE.match(raw) if not match: return "", raw return match.group(1), match.group(2)