86 lines
2.6 KiB
Python
86 lines
2.6 KiB
Python
|
|
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)
|