| """SKILL.md parser & writer. |
| |
| Reads/writes a single skill from a `SKILL.md` file with YAML frontmatter |
| and a structured markdown body. Inspired by Hermes' skills format |
| (https://hermes-agent.nousresearch.com/docs/user-guide/features/skills). |
| |
| Frontmatter shape (YAML): |
| |
| --- |
| name: open-pr-from-branch |
| description: One-line summary surfaced in the skills index. |
| version: 1.0.0 |
| category: dev |
| tags: [git, github] |
| platforms: [linux, macos] # optional |
| requires_toolsets: [] # optional |
| fallback_for_toolsets: [] # optional |
| status: published # draft | published |
| confidence: 0.8 # 0..1 |
| source: learned # learned | taught | imported |
| teacher_model: claude-opus-4-7 # optional |
| created: 2026-05-09T21:43:00Z |
| --- |
| |
| Body sections (any subset; rendered as headings): |
| |
| ## When to Use |
| Trigger conditions in plain English. |
| |
| ## Procedure |
| 1. First step |
| 2. Second step |
| |
| ## Pitfalls |
| - Common failure mode + how to recover |
| |
| ## Verification |
| - How to confirm success |
| |
| Anything else (raw paragraphs after the last known section) is preserved |
| in `body_extra` and round-trips on save. |
| |
| Usage counters (`uses`, `last_used`) live in a sidecar `_usage.json` keyed |
| by skill name, so the SKILL.md file doesn't churn on every retrieval. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import logging |
| import re |
| from dataclasses import dataclass, field |
| from datetime import datetime |
| from typing import Any, Dict, List, Optional |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| |
| |
|
|
| _SLUG_RE = re.compile(r"[^a-z0-9]+") |
|
|
|
|
| def slugify(text: str, fallback: str = "skill") -> str: |
| """Convert a free-form title to a kebab-case slug suitable for a directory |
| name. Strips non-alphanumerics, collapses runs, trims leading/trailing |
| dashes. Caps at 60 chars.""" |
| s = str(text or "").strip().lower() |
| s = _SLUG_RE.sub("-", s) |
| s = s.strip("-") |
| return (s or fallback)[:60] |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
|
|
| _FM_KEY_RE = re.compile(r"^([a-z_][a-z0-9_]*):\s*(.*)$", re.IGNORECASE) |
| _FM_BLOCK_LIST_RE = re.compile(r"^\s*-\s*(.*)$") |
|
|
|
|
| def _parse_scalar(raw: str) -> Any: |
| raw = raw.strip() |
| if raw == "": |
| return "" |
| if raw.startswith("[") and raw.endswith("]"): |
| inner = raw[1:-1].strip() |
| if not inner: |
| return [] |
| return [_parse_scalar(p) for p in _split_top_level(inner, ",")] |
| if raw.lower() in ("true", "yes"): |
| return True |
| if raw.lower() in ("false", "no"): |
| return False |
| if raw.lower() in ("null", "none", "~"): |
| return None |
| if (raw[0] == raw[-1]) and raw[0] in ("'", '"'): |
| return raw[1:-1] |
| |
| try: |
| if "." in raw: |
| return float(raw) |
| return int(raw) |
| except ValueError: |
| pass |
| return raw |
|
|
|
|
| def _split_top_level(s: str, sep: str) -> List[str]: |
| """Split `s` on `sep` ignoring separators inside [] or quotes.""" |
| out, buf, depth, quote = [], [], 0, None |
| for ch in s: |
| if quote: |
| buf.append(ch) |
| if ch == quote: |
| quote = None |
| continue |
| if ch in ("'", '"'): |
| quote = ch |
| buf.append(ch) |
| continue |
| if ch == "[": |
| depth += 1 |
| elif ch == "]": |
| depth = max(0, depth - 1) |
| if ch == sep and depth == 0: |
| out.append("".join(buf).strip()) |
| buf = [] |
| continue |
| buf.append(ch) |
| if buf: |
| out.append("".join(buf).strip()) |
| return out |
|
|
|
|
| def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]: |
| """Pull the YAML frontmatter out of a SKILL.md and return (fm, body).""" |
| if not text.startswith("---"): |
| return {}, text |
| end = text.find("\n---", 3) |
| if end < 0: |
| return {}, text |
| fm_text = text[3:end].lstrip("\n") |
| body = text[end + 4:].lstrip("\n") |
| fm: Dict[str, Any] = {} |
| pending_key: Optional[str] = None |
| for line in fm_text.splitlines(): |
| if not line.strip() or line.lstrip().startswith("#"): |
| continue |
| m = _FM_KEY_RE.match(line) |
| if m: |
| key, val = m.group(1), m.group(2) |
| if val.strip() == "": |
| pending_key = key |
| fm[key] = [] |
| else: |
| fm[key] = _parse_scalar(val) |
| pending_key = None |
| continue |
| m2 = _FM_BLOCK_LIST_RE.match(line) |
| if m2 and pending_key: |
| existing = fm.get(pending_key) |
| if not isinstance(existing, list): |
| fm[pending_key] = [] |
| fm[pending_key].append(_parse_scalar(m2.group(1))) |
| return fm, body |
|
|
|
|
| def _emit_scalar(v: Any) -> str: |
| if v is None: |
| return "null" |
| if isinstance(v, bool): |
| return "true" if v else "false" |
| if isinstance(v, (int, float)): |
| return str(v) |
| if isinstance(v, list): |
| return "[" + ", ".join(_emit_scalar(x) for x in v) + "]" |
| s = str(v) |
| if any(c in s for c in (":", "#", "\n", "[", "]", "{", "}", ",", "&", "*", "!", "|", ">", "'", '"', "%", "@")): |
| return json.dumps(s) |
| return s |
|
|
|
|
| def _as_list(v: Any) -> List[str]: |
| if v is None: |
| return [] |
| if isinstance(v, list): |
| return [str(x) for x in v if x not in (None, "")] |
| return [str(v)] |
|
|
|
|
| def _as_float(v: Any, default: float = 0.8) -> float: |
| try: |
| return float(v) |
| except (TypeError, ValueError): |
| return default |
|
|
|
|
| def emit_frontmatter(fm: Dict[str, Any]) -> str: |
| lines = [] |
| for k, v in fm.items(): |
| if v is None or v == [] or v == "": |
| continue |
| lines.append(f"{k}: {_emit_scalar(v)}") |
| return "\n".join(lines) |
|
|
|
|
| |
| |
| |
|
|
| _KNOWN_SECTIONS = ("when_to_use", "procedure", "pitfalls", "verification") |
| _HEADING_TO_KEY = { |
| "when to use": "when_to_use", |
| "procedure": "procedure", |
| "steps": "procedure", |
| "pitfalls": "pitfalls", |
| "verification": "verification", |
| } |
| _KEY_TO_HEADING = { |
| "when_to_use": "When to Use", |
| "procedure": "Procedure", |
| "pitfalls": "Pitfalls", |
| "verification": "Verification", |
| } |
|
|
|
|
| def parse_body(body: str) -> Dict[str, Any]: |
| """Split a SKILL.md body into known sections. |
| |
| Returns: |
| { |
| "when_to_use": str, |
| "procedure": list[str], # numbered/bulleted lines |
| "pitfalls": list[str], |
| "verification": list[str], |
| "body_extra": str, # anything not under a known heading |
| } |
| """ |
| out = {k: ([] if k != "when_to_use" else "") for k in _KNOWN_SECTIONS} |
| out["body_extra"] = "" |
| if not body or not body.strip(): |
| return out |
|
|
| sections: List[tuple[Optional[str], List[str]]] = [(None, [])] |
| for line in body.splitlines(): |
| m = re.match(r"^##\s+(.*?)\s*$", line) |
| if m: |
| heading = m.group(1).strip().lower() |
| key = _HEADING_TO_KEY.get(heading) |
| sections.append((key, [])) |
| continue |
| sections[-1][1].append(line) |
|
|
| for key, lines in sections: |
| text = "\n".join(lines).strip("\n") |
| if key is None: |
| extras = text.strip() |
| if extras: |
| out["body_extra"] = (out["body_extra"] + "\n\n" + extras).strip() |
| continue |
| if key == "when_to_use": |
| out["when_to_use"] = text.strip() |
| else: |
| out[key] = _parse_list_lines(text) |
| return out |
|
|
|
|
| def _parse_list_lines(text: str) -> List[str]: |
| """Pull bullet/numbered lines out of a section body. Plain paragraphs are |
| treated as a single entry.""" |
| items: List[str] = [] |
| for line in (text or "").splitlines(): |
| s = line.strip() |
| if not s: |
| continue |
| m = re.match(r"^(?:[-*]|\d+[.)])\s+(.*)$", s) |
| if m: |
| items.append(m.group(1).strip()) |
| elif items: |
| |
| items[-1] = items[-1] + " " + s |
| else: |
| items.append(s) |
| return items |
|
|
|
|
| def emit_body(sections: Dict[str, Any]) -> str: |
| parts: List[str] = [] |
| when = (sections.get("when_to_use") or "").strip() |
| if when: |
| parts.append(f"## {_KEY_TO_HEADING['when_to_use']}\n\n{when}") |
| for key in ("procedure", "pitfalls", "verification"): |
| items = sections.get(key) or [] |
| if not items: |
| continue |
| heading = _KEY_TO_HEADING[key] |
| if key == "procedure": |
| body = "\n".join(f"{i + 1}. {x}" for i, x in enumerate(items)) |
| else: |
| body = "\n".join(f"- {x}" for x in items) |
| parts.append(f"## {heading}\n\n{body}") |
| extra = (sections.get("body_extra") or "").strip() |
| if extra: |
| parts.append(extra) |
| return "\n\n".join(parts) + ("\n" if parts else "") |
|
|
|
|
| |
| |
| |
|
|
|
|
| @dataclass |
| class Skill: |
| name: str |
| description: str = "" |
| version: str = "1.0.0" |
| category: str = "general" |
| tags: List[str] = field(default_factory=list) |
| platforms: List[str] = field(default_factory=list) |
| requires_toolsets: List[str] = field(default_factory=list) |
| fallback_for_toolsets: List[str] = field(default_factory=list) |
| status: str = "draft" |
| confidence: float = 0.8 |
| source: str = "learned" |
| teacher_model: Optional[str] = None |
| owner: Optional[str] = None |
| created: str = "" |
| when_to_use: str = "" |
| procedure: List[str] = field(default_factory=list) |
| pitfalls: List[str] = field(default_factory=list) |
| verification: List[str] = field(default_factory=list) |
| body_extra: str = "" |
| |
| uses: int = 0 |
| last_used: Optional[int] = None |
| |
| path: Optional[str] = None |
|
|
| |
| |
| |
|
|
| def to_frontmatter(self) -> Dict[str, Any]: |
| fm: Dict[str, Any] = { |
| "name": self.name, |
| "description": self.description, |
| "version": self.version, |
| "category": self.category, |
| } |
| if self.tags: fm["tags"] = list(self.tags) |
| if self.platforms: fm["platforms"] = list(self.platforms) |
| if self.requires_toolsets: fm["requires_toolsets"] = list(self.requires_toolsets) |
| if self.fallback_for_toolsets: fm["fallback_for_toolsets"] = list(self.fallback_for_toolsets) |
| fm["status"] = self.status |
| fm["confidence"] = round(float(self.confidence), 3) |
| fm["source"] = self.source |
| if self.teacher_model: fm["teacher_model"] = self.teacher_model |
| if self.owner: fm["owner"] = self.owner |
| fm["created"] = self.created or _now_iso() |
| return fm |
|
|
| def to_dict(self) -> Dict[str, Any]: |
| d = { |
| "id": self.name, |
| "name": self.name, |
| "description": self.description, |
| "version": self.version, |
| "category": self.category, |
| "tags": list(self.tags), |
| "platforms": list(self.platforms), |
| "requires_toolsets": list(self.requires_toolsets), |
| "fallback_for_toolsets": list(self.fallback_for_toolsets), |
| "status": self.status, |
| "confidence": round(float(self.confidence), 3), |
| "source": self.source, |
| "teacher_model": self.teacher_model, |
| "owner": self.owner, |
| "created": self.created, |
| "when_to_use": self.when_to_use, |
| "procedure": list(self.procedure), |
| "pitfalls": list(self.pitfalls), |
| "verification": list(self.verification), |
| "body_extra": self.body_extra, |
| "uses": int(self.uses or 0), |
| "last_used": self.last_used, |
| "path": self.path, |
| } |
| |
| d["title"] = self.description or self.name.replace("-", " ").title() |
| d["problem"] = self.when_to_use |
| d["solution"] = (self.procedure[0] if self.procedure else "") if not self.body_extra else self.body_extra |
| d["steps"] = list(self.procedure) |
| return d |
|
|
| @classmethod |
| def from_markdown(cls, text: str, *, path: Optional[str] = None) -> "Skill": |
| fm, body = parse_frontmatter(text) |
| sections = parse_body(body) |
| raw_name = fm.get("name") |
| name = slugify(raw_name if raw_name not in (None, "") else fm.get("description", ""), fallback="skill") |
| return cls( |
| name=name, |
| description=str(fm.get("description", "") or ""), |
| version=str(fm.get("version", "1.0.0") or "1.0.0"), |
| category=str(fm.get("category", "general") or "general"), |
| tags=_as_list(fm.get("tags")), |
| platforms=_as_list(fm.get("platforms")), |
| requires_toolsets=_as_list(fm.get("requires_toolsets")), |
| fallback_for_toolsets=_as_list(fm.get("fallback_for_toolsets")), |
| status=str(fm.get("status", "draft") or "draft"), |
| confidence=_as_float(fm.get("confidence", 0.8), 0.8), |
| source=str(fm.get("source", "learned") or "learned"), |
| teacher_model=str(fm.get("teacher_model")) if fm.get("teacher_model") else None, |
| owner=str(fm.get("owner")) if fm.get("owner") else None, |
| created=str(fm.get("created") or _now_iso()), |
| when_to_use=sections["when_to_use"], |
| procedure=list(sections["procedure"]), |
| pitfalls=list(sections["pitfalls"]), |
| verification=list(sections["verification"]), |
| body_extra=sections["body_extra"], |
| path=path, |
| ) |
|
|
| def to_markdown(self) -> str: |
| fm = emit_frontmatter(self.to_frontmatter()) |
| body = emit_body({ |
| "when_to_use": self.when_to_use, |
| "procedure": self.procedure, |
| "pitfalls": self.pitfalls, |
| "verification": self.verification, |
| "body_extra": self.body_extra, |
| }) |
| return f"---\n{fm}\n---\n\n{body}" |
|
|
|
|
| def _now_iso() -> str: |
| return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") |
|
|