skills: discover roots and parse SKILL.md frontmatter

A skill is a directory with a SKILL.md; roots are ordered by priority (an
explicit path, then our own user directory, then the directories other
harnesses install into, with the project-scope one behind its own switch). The
first copy of a name wins and the ones it shadows stay listed for reporting
instead of vanishing.

The frontmatter reader covers the subset those files actually use — scalars,
booleans, ints, inline and block lists, | and > blocks, folded plain scalars —
so no YAML dependency is needed; a nested mapping is skipped and a structurally
broken block degrades to "no metadata" with a note, keeping a third-party skill
listable rather than dropping it. Metadata is cached against the directory list
and its SKILL.md timestamps, so a listing is cheap yet notices an edit.
This commit is contained in:
2026-09-22 22:37:42 +08:00
parent bbda658eab
commit a9f58428bf
5 changed files with 826 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
"""Skill discovery: roots, ``SKILL.md`` metadata, and the read/write helpers."""
from .discovery import SKILL_FILE as SKILL_FILE
from .discovery import Skill as Skill
from .discovery import SkillRoot as SkillRoot
from .discovery import SkillStore as SkillStore
from .discovery import build_roots as build_roots
from .discovery import validate_skill_name as validate_skill_name
from .frontmatter import FrontmatterError as FrontmatterError
from .frontmatter import parse_frontmatter as parse_frontmatter
__all__ = [
"SKILL_FILE",
"FrontmatterError",
"Skill",
"SkillRoot",
"SkillStore",
"build_roots",
"parse_frontmatter",
"validate_skill_name",
]
+300
View File
@@ -0,0 +1,300 @@
"""Discover skills under a fixed set of roots.
A skill is a directory holding a ``SKILL.md``. Roots are ordered by priority
(explicit ``[skills].paths`` first, then the built-in sources), so the first
skill with a given name wins and the copies it shadows stay visible for
reporting rather than disappearing. Parsed metadata is cached against the
directory list and its ``SKILL.md`` timestamps, which makes a listing cheap
while still noticing an edit made outside the chat.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
from .frontmatter import FrontmatterError, parse_frontmatter
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from plyngent.config.models import SkillsConfig
SKILL_FILE = "SKILL.md"
DEFAULT_SKILL_DIR = "skills"
# Only the head is needed: frontmatter lives at the top, and ``skill_read``
# reads the file itself when the model actually wants the body.
_METADATA_READ_BYTES = 64 * 1024
_FALLBACK_DESCRIPTION_CHARS = 200
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
_FRONTMATTER_NAME = "name"
_FRONTMATTER_DESCRIPTION = "description"
@dataclass(frozen=True, slots=True)
class SkillRoot:
"""One directory scanned for skills.
``origin`` names where the root came from (``config`` for an explicit path,
``plyngent`` for our own user directory, ``claude`` for another harness);
``scope`` is ``user`` or ``project``. Together they label a skill's
provenance in listings and in the injected catalog.
"""
path: Path
origin: str
scope: str
@property
def label(self) -> str:
"""``<origin>-<scope>``, e.g. ``claude-project``."""
return f"{self.origin}-{self.scope}"
@dataclass(frozen=True, slots=True)
class Skill:
"""A discovered skill directory and the metadata read from its ``SKILL.md``."""
name: str
description: str
path: Path
root: SkillRoot
metadata: Mapping[str, object] = field(default_factory=dict[str, object])
# Non-fatal notes (invalid declared name, missing description, …) surfaced to
# the human in listings so a third-party skill is never repaired silently.
issues: tuple[str, ...] = ()
@property
def skill_file(self) -> Path:
return self.path / SKILL_FILE
@property
def declared_name(self) -> str | None:
"""The name the frontmatter asked for (may differ from :attr:`name`)."""
declared = self.metadata.get(_FRONTMATTER_NAME)
return declared.strip() if isinstance(declared, str) and declared.strip() else None
def build_roots(
config: SkillsConfig,
*,
workspace: Path,
user_config_dir: Path,
home: Path | None = None,
) -> list[SkillRoot]:
"""Resolve ``[skills]`` into ordered roots (highest priority first).
Explicit ``paths`` win, then the built-in discovery sources in the order the
config lists them. A source contributes only directories that exist *or* are
the default write target — a missing root is not an error, it simply holds
no skills (and may hold the next created one).
"""
roots: list[SkillRoot] = []
for raw in config.paths:
token = raw.strip()
if token:
roots.append(SkillRoot(path=_expand(token, home=home), origin="config", scope="user"))
for source in config.discover:
if source == "plyngent":
roots.append(SkillRoot(path=user_config_dir / DEFAULT_SKILL_DIR, origin="plyngent", scope="user"))
elif source == "claude":
if home is not None:
roots.append(SkillRoot(path=home / ".claude" / DEFAULT_SKILL_DIR, origin="claude", scope="user"))
if config.include_project_roots:
project = workspace / ".claude" / DEFAULT_SKILL_DIR
roots.append(SkillRoot(path=project, origin="claude", scope="project"))
return roots
def _expand(token: str, *, home: Path | None) -> Path:
"""Expand a leading ``~`` (against *home* when given) and normalize."""
if token.startswith("~"):
rest = token[1:].lstrip("/\\")
base = home if home is not None else Path.home()
return (base / rest) if rest else base
return Path(token)
@dataclass(frozen=True, slots=True)
class _Entry:
"""One candidate skill directory plus the timestamp that invalidates the cache."""
root: SkillRoot
directory: Path
mtime_ns: int
def validate_skill_name(name: str) -> str | None:
"""Return an error message when *name* is not usable as a skill name."""
token = name.strip()
if not token:
return "skill name must not be empty"
if not _NAME_RE.match(token):
return f"skill name {token!r} must be lowercase letters, digits, and single hyphens (max 64 chars)"
return None
class SkillStore:
"""Skills discovered under *roots*, highest-priority root first."""
_roots: tuple[SkillRoot, ...]
_cache: tuple[tuple[tuple[str, int], ...], list[Skill]] | None
def __init__(self, roots: Sequence[SkillRoot]) -> None:
self._roots = tuple(roots)
self._cache = None
@property
def roots(self) -> tuple[SkillRoot, ...]:
return self._roots
@property
def all_skills(self) -> list[Skill]:
"""Every discovered skill, shadowed copies included (priority order)."""
return list(self._load())
@property
def skills(self) -> list[Skill]:
"""Visible skills by name, sorted for display (shadows removed)."""
by_name: dict[str, Skill] = {}
for skill in self._load():
_ = by_name.setdefault(skill.name, skill)
return [by_name[name] for name in sorted(by_name)]
def shadowed(self) -> dict[str, list[Skill]]:
"""Skill names found in more than one root → the losing copies in order."""
found: dict[str, list[Skill]] = {}
for skill in self._load():
found.setdefault(skill.name, []).append(skill)
return {name: skills for name, skills in sorted(found.items()) if len(skills) > 1}
def find(self, name: str) -> Skill | None:
"""Look up a visible skill by name (exact, then case-insensitive)."""
token = name.strip()
wanted = token.casefold()
fallback: Skill | None = None
for skill in self._load():
if skill.name == token:
return skill
if fallback is None and skill.name.casefold() == wanted:
fallback = skill
return fallback
def default_write_root(self) -> SkillRoot | None:
"""Root a newly created skill goes into: ours when present, else the first."""
for root in self._roots:
if root.origin == "plyngent":
return root
return self._roots[0] if self._roots else None
def reload(self) -> None:
"""Drop the cache so the next access rescans the filesystem."""
self._cache = None
def _load(self) -> list[Skill]:
entries = self._scan()
signature = tuple((str(entry.directory), entry.mtime_ns) for entry in entries)
if self._cache is not None and self._cache[0] == signature:
return self._cache[1]
skills = [skill for entry in entries if (skill := _read_skill(entry)) is not None]
self._cache = (signature, skills)
return skills
def _scan(self) -> list[_Entry]:
return [
_Entry(root=root, directory=directory, mtime_ns=_skill_mtime(directory))
for root in self._roots
for directory in _skill_dirs(root.path)
]
def _skill_dirs(root: Path) -> list[Path]:
"""Skill directories directly under *root* (the root itself may be one)."""
try:
if not root.is_dir():
return []
found = [root] if (root / SKILL_FILE).is_file() else []
children = sorted(child for child in root.iterdir() if child.is_dir())
except OSError:
return []
found.extend(child for child in children if (child / SKILL_FILE).is_file())
return found
def _skill_mtime(directory: Path) -> int:
try:
return (directory / SKILL_FILE).stat().st_mtime_ns
except OSError:
return -1
def _read_skill(entry: _Entry) -> Skill | None:
"""Read one ``SKILL.md`` head and derive the record; ``None`` when unreadable."""
issues: list[str] = []
try:
text = _read_head(entry.directory / SKILL_FILE)
except OSError as exc:
return Skill(
name=entry.directory.name,
description="",
path=entry.directory,
root=entry.root,
issues=(f"cannot read {SKILL_FILE}: {exc}",),
)
try:
metadata, body = parse_frontmatter(text)
except FrontmatterError as exc:
metadata, body = {}, text
issues.append(f"frontmatter not parsed ({exc}); using the directory name")
name = entry.directory.name
declared = metadata.get(_FRONTMATTER_NAME)
if isinstance(declared, str) and declared.strip():
candidate = declared.strip()
if validate_skill_name(candidate) is None:
name = candidate
else:
issues.append(f"declared name {candidate!r} is not usable; using {entry.directory.name!r}")
elif declared is not None:
issues.append(f"declared name is not a string ({type(declared).__name__}); using {entry.directory.name!r}")
description = metadata.get(_FRONTMATTER_DESCRIPTION)
if isinstance(description, str) and description.strip():
text_description = description.strip()
else:
text_description = _fallback_description(body)
issues.append("no frontmatter description; using the first paragraph")
return Skill(
name=name,
description=_one_line(text_description),
path=entry.directory,
root=entry.root,
metadata=metadata,
issues=tuple(issues),
)
def _read_head(path: Path) -> str:
"""Read the head of a file as UTF-8 (frontmatter is at the top)."""
with path.open("r", encoding="utf-8", errors="replace") as handle:
return handle.read(_METADATA_READ_BYTES)
def _fallback_description(body: str) -> str:
"""First plain paragraph of the body, for skills without a description."""
for block in body.split("\n\n"):
lines = [line.strip() for line in block.splitlines() if line.strip()]
if not lines or lines[0].startswith("#"):
continue
return " ".join(lines)
return ""
def _one_line(text: str) -> str:
"""Collapse whitespace and cap the length of a description line."""
collapsed = " ".join(text.split())
if len(collapsed) <= _FALLBACK_DESCRIPTION_CHARS:
return collapsed
return collapsed[:_FALLBACK_DESCRIPTION_CHARS].rstrip() + "…"
+196
View File
@@ -0,0 +1,196 @@
"""YAML-subset frontmatter for ``SKILL.md`` files.
Skills are shared with other harnesses, which write a small YAML block above the
Markdown body. Supporting all of YAML would mean a dependency for a handful of
scalar fields, so this module reads the subset those files actually use:
top-level ``key: value`` pairs, quoted or plain scalars, booleans and integers,
inline ``[a, b]`` lists, block ``- item`` lists, and ``|`` / ``>`` block scalars.
A plain scalar folds the indented lines below it (YAML's multi-line form) and a
nested mapping is skipped rather than guessed at; anything structurally broken
raises :class:`FrontmatterError` so callers can fall back to "no metadata".
"""
from __future__ import annotations
import re
_FENCE = "---"
# Closing markers accepted by the format (``...`` is the YAML document end).
_CLOSERS = frozenset({_FENCE, "..."})
_BLOCK_SCALAR_MARKERS = frozenset({"|", "|-", "|+", ">", ">-", ">+"})
_TRUE = frozenset({"true", "yes", "on"})
_FALSE = frozenset({"false", "no", "off"})
_INT = re.compile(r"^-?\d+$")
_ITEM = "- "
# A quoted scalar needs at least its two quote characters.
_QUOTED_MIN_LEN = 2
class FrontmatterError(ValueError):
"""Raised when a frontmatter block is outside the supported subset."""
def parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
"""Return ``(metadata, body)`` for a ``SKILL.md`` document.
No frontmatter (or an unterminated block) yields empty metadata and the whole
text as the body.
Raises:
FrontmatterError: The block is outside the supported subset.
"""
block, body = split_frontmatter(text)
if block is None:
return {}, body
return parse_yaml_subset(block), body.removeprefix("\n")
def split_frontmatter(text: str) -> tuple[str | None, str]:
"""Split *text* into its raw frontmatter block (or ``None``) and the body.
The block must open with a ``---`` line of its own and close with ``---`` or
``...``; an unterminated block counts as no frontmatter, so the whole text
stays readable as the body.
"""
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
lines = normalized.split("\n")
if not lines or lines[0].strip() != _FENCE:
return None, normalized
for index, line in enumerate(lines[1:], start=1):
if line.strip() in _CLOSERS:
return "\n".join(lines[1:index]), "\n".join(lines[index + 1 :])
return None, normalized
def parse_yaml_subset(block: str) -> dict[str, object]:
"""Parse one frontmatter block into a flat mapping.
Raises:
FrontmatterError: A line is not a top-level ``key: value`` pair.
"""
metadata: dict[str, object] = {}
lines = block.split("\n")
index = 0
while index < len(lines):
raw = lines[index]
index += 1
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith(_ITEM):
msg = f"list item without a key: {raw!r}"
raise FrontmatterError(msg)
if _indent_of(raw) > 0:
msg = f"unexpected indentation: {raw!r}"
raise FrontmatterError(msg)
key, sep, value = raw.partition(":")
if not sep or not key.strip():
msg = f"expected 'key: value', got {raw!r}"
raise FrontmatterError(msg)
name = key.strip()
value = value.strip()
consumed, index = _consume_indented(lines, index)
if value in _BLOCK_SCALAR_MARKERS:
metadata[name] = _join_block_scalar(consumed, folded=value.startswith(">"))
continue
if value:
metadata[name] = _scalar(value, consumed)
continue
items = _list_items(consumed)
if items is not None:
metadata[name] = items
# Empty value with nested mappings: nothing this subset needs to record.
return metadata
def _indent_of(line: str) -> int:
return len(line) - len(line.lstrip(" "))
def _consume_indented(lines: list[str], index: int) -> tuple[list[str], int]:
"""Consume lines indented deeper than the key line; return them and the next index."""
consumed: list[str] = []
while index < len(lines):
line = lines[index]
if line.strip() and _indent_of(line) == 0:
break
consumed.append(line)
index += 1
return consumed, index
def _dedent(lines: list[str]) -> list[str]:
"""Strip the common indentation and trim blank edges."""
indents = [_indent_of(line) for line in lines if line.strip()]
if not indents:
return []
width = min(indents)
dedented = [line[width:] if len(line) >= width else "" for line in lines]
while dedented and not dedented[0].strip():
_ = dedented.pop(0)
while dedented and not dedented[-1].strip():
_ = dedented.pop()
return dedented
def _join_block_scalar(lines: list[str], *, folded: bool) -> str:
"""Join a ``|`` (literal) or ``>`` (folded) block scalar's content."""
body = _dedent(lines)
if not body:
return ""
return " ".join(line.strip() for line in body) if folded else "\n".join(body)
def _list_items(lines: list[str]) -> list[object] | None:
"""Parse a block ``- item`` list; ``None`` when *lines* is not one."""
meaningful = [line for line in lines if line.strip()]
if not meaningful or any(not line.strip().startswith(_ITEM) for line in meaningful):
return None
items: list[object] = []
for line in meaningful:
item = line.strip()[len(_ITEM) :].strip()
if item:
items.append(_coerce(item))
return items
def _unquote(text: str) -> str | None:
"""Return the contents of a quoted scalar, or ``None`` when it is unquoted."""
if len(text) >= _QUOTED_MIN_LEN and text[0] == text[-1] and text[0] in {'"', "'"}:
return text[1:-1]
return None
def _scalar(value: str, continuation: list[str]) -> object:
"""Parse one scalar; a plain scalar folds its indented continuation lines."""
if value.startswith("[") and value.endswith("]"):
return _inline_list(value)
unquoted = _unquote(value)
if unquoted is not None:
return unquoted
folded = [value]
folded.extend(line.strip() for line in continuation if line.strip())
return _coerce(" ".join(folded))
def _inline_list(value: str) -> list[object]:
"""Parse ``[a, "b", 3]`` (no nested collections in this subset)."""
inner = value[1:-1].strip()
if not inner:
return []
return [_coerce(part.strip()) for part in inner.split(",") if part.strip()]
def _coerce(text: str) -> object:
"""Coerce a plain scalar to bool / int / str (YAML's implicit typing)."""
unquoted = _unquote(text)
if unquoted is not None:
return unquoted
lowered = text.lower()
if lowered in _TRUE:
return True
if lowered in _FALSE:
return False
if _INT.match(text):
return int(text)
return text