"""
Parsing and validation of ``SKILL.md`` documents, per the Agent Skills specification.
An Agent Skill is a directory containing, at minimum, a ``SKILL.md`` file: a YAML
frontmatter block followed by Markdown instructions. The directory may also contain
bundled files, conventionally in ``scripts/``, ``references/`` and ``assets/``, which
``SKILL.md`` refers to by paths relative to the skill root.
.. code-block:: markdown
---
name: pdf-processing
description: Extract PDF text, fill forms, merge files. Use when handling PDFs.
license: Apache-2.0
allowed-tools: Bash(python:*) Read
metadata:
author: example-org
version: "1.0"
---
# PDF processing
## Filling forms
Read [the forms guide](references/FORMS.md), then run `scripts/fill_form.py`.
This module is shared by the manifest (Pydantic) layer and the storage (Django ORM)
layer, so that both parse and validate ``SKILL.md`` text identically.
.. seealso::
- Agent Skills specification: https://agentskills.io/specification
- Anthropic's skills repository: https://github.com/anthropics/skills
.. note::
**Experimental.** The SkillPlugin was designed and coded by Claude Code (Anthropic's
Claude Opus 5.5), with Lawrence McDaniel as co-author. It is experimental, and will
be documented.
"""
import posixpath
import re
from dataclasses import dataclass, field
from typing import Any, Optional
import yaml
from smarter.common.exceptions import SmarterValueError
from smarter.lib import json
from smarter.lib.typing import SmarterDictType
SKILL_FILENAME = "SKILL.md"
# frontmatter field constraints, per the Agent Skills specification
MAX_NAME_LENGTH = 64
MAX_DESCRIPTION_LENGTH = 1024
MAX_COMPATIBILITY_LENGTH = 500
NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
# Smarter limits, to bound database rows and LLM context
MAX_SKILL_DOCUMENT_LENGTH = 200_000
"""The maximum length of a SKILL.md document, in characters.
The specification recommends < 500 lines.
"""
MAX_RESOURCE_LENGTH = 500_000
"""The maximum length of a single bundled file, in characters."""
MAX_RESOURCES = 100
"""The maximum number of bundled files."""
MAX_RESOURCE_PATH_LENGTH = 512
# a leading YAML frontmatter block delimited by '---' lines, followed by the Markdown body
FRONTMATTER_PATTERN = re.compile(r"\A---[ \t]*\n(.*?)^---[ \t]*$\n?(.*)\Z", re.DOTALL | re.MULTILINE)
# markdown links, e.g. [the forms guide](references/FORMS.md "title")
MARKDOWN_LINK_PATTERN = re.compile(r"\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+\"[^\"]*\")?\s*\)")
# inline code that looks like a path to a bundled file, e.g. `scripts/fill_form.py`
INLINE_PATH_PATTERN = re.compile(r"`((?:\./)?(?:[\w.-]+/)+[\w.-]+\.\w+|[\w.-]+\.md)`")
URL_SCHEME_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:")
[docs]
class SkillDocumentError(SmarterValueError):
"""Raised when a SKILL.md document or a bundled file path is invalid."""
[docs]
@dataclass
class SkillDocument:
"""
A parsed and validated ``SKILL.md`` document.
:ivar document: The verbatim SKILL.md text, with line endings normalized to ``\\n``.
:ivar frontmatter: The complete parsed frontmatter, including any keys beyond the
standard fields, such as the extensions supported by Claude Code.
:ivar body: The Markdown instructions that follow the frontmatter.
:ivar allowed_tools: The ``allowed-tools`` frontmatter field, normalized to a list.
"""
document: str
frontmatter: SmarterDictType
body: str
allowed_tools: list[str] = field(default_factory=list)
@property
def name(self) -> str:
"""The skill name."""
return self.frontmatter["name"]
@property
def description(self) -> str:
"""What the skill does and when to use it."""
return self.frontmatter["description"]
@property
def license(self) -> Optional[str]:
"""The license applied to the skill, if any."""
return self.frontmatter.get("license")
@property
def compatibility(self) -> Optional[str]:
"""The environment requirements of the skill, if any."""
return self.frontmatter.get("compatibility")
@property
def metadata(self) -> SmarterDictType:
"""The ``metadata`` frontmatter field: additional properties not defined by the specification."""
return self.frontmatter.get("metadata") or {}
[docs]
def normalize_line_endings(text: str) -> str:
"""Normalize line endings to ``\\n`` and remove a leading byte order mark."""
return text.replace("\r\n", "\n").replace("\r", "\n").lstrip("")
[docs]
def validate_frontmatter(frontmatter: SmarterDictType) -> None:
"""
Validate the standard frontmatter fields against the Agent Skills specification.
Keys beyond the standard fields are permitted and preserved, since agent
implementations such as Claude Code define their own extensions.
:param frontmatter: The parsed frontmatter.
:raises SkillDocumentError: If a standard field is missing or invalid.
"""
name = frontmatter.get("name")
if not isinstance(name, str) or not name:
raise SkillDocumentError("frontmatter 'name' is required.")
if len(name) > MAX_NAME_LENGTH:
raise SkillDocumentError(f"frontmatter 'name' must be at most {MAX_NAME_LENGTH} characters: {name}")
if not NAME_PATTERN.match(name):
raise SkillDocumentError(
"frontmatter 'name' may only contain lowercase letters, numbers and hyphens, and must not start "
f"or end with a hyphen, nor contain consecutive hyphens: {name}"
)
description = frontmatter.get("description")
if not isinstance(description, str) or not description.strip():
raise SkillDocumentError("frontmatter 'description' is required, and must not be empty.")
if len(description) > MAX_DESCRIPTION_LENGTH:
raise SkillDocumentError(f"frontmatter 'description' must be at most {MAX_DESCRIPTION_LENGTH} characters.")
license_ = frontmatter.get("license")
if license_ is not None and not isinstance(license_, str):
raise SkillDocumentError("frontmatter 'license' must be a string.")
compatibility = frontmatter.get("compatibility")
if compatibility is not None:
if not isinstance(compatibility, str) or not compatibility.strip():
raise SkillDocumentError("frontmatter 'compatibility' must be a non-empty string.")
if len(compatibility) > MAX_COMPATIBILITY_LENGTH:
raise SkillDocumentError(
f"frontmatter 'compatibility' must be at most {MAX_COMPATIBILITY_LENGTH} characters."
)
metadata = frontmatter.get("metadata")
if metadata is not None and not isinstance(metadata, dict):
raise SkillDocumentError("frontmatter 'metadata' must be a mapping of keys to values.")
parse_allowed_tools(frontmatter.get("allowed-tools"))
[docs]
def parse_skill_document(document: str) -> SkillDocument:
"""
Parse and validate a ``SKILL.md`` document.
:param document: The verbatim contents of a SKILL.md file.
:return: The parsed document.
:raises SkillDocumentError: If the document is too long, has no frontmatter block, the
frontmatter is not a valid YAML mapping, or a standard frontmatter field is invalid.
**Example:**
.. code-block:: python
skill = parse_skill_document(open("pdf/SKILL.md").read())
skill.name # 'pdf'
"""
if not isinstance(document, str) or not document.strip():
raise SkillDocumentError("SKILL.md must not be empty.")
document = normalize_line_endings(document)
if len(document) > MAX_SKILL_DOCUMENT_LENGTH:
raise SkillDocumentError(f"SKILL.md must be at most {MAX_SKILL_DOCUMENT_LENGTH} characters.")
match = FRONTMATTER_PATTERN.match(document)
if not match:
raise SkillDocumentError(
"SKILL.md must begin with a YAML frontmatter block, delimited by '---' lines, that contains "
"at least 'name' and 'description'."
)
raw_frontmatter, body = match.group(1), match.group(2)
try:
frontmatter = yaml.safe_load(raw_frontmatter)
except yaml.YAMLError as e:
raise SkillDocumentError(f"SKILL.md frontmatter is not valid YAML: {e}") from e
if not isinstance(frontmatter, dict):
raise SkillDocumentError("SKILL.md frontmatter must be a mapping of keys to values.")
# YAML can produce values that are not JSON serializable, such as dates.
frontmatter = json.loads(json.dumps(frontmatter, default=str))
validate_frontmatter(frontmatter)
return SkillDocument(
document=document,
frontmatter=frontmatter,
body=body.strip("\n"),
allowed_tools=parse_allowed_tools(frontmatter.get("allowed-tools")),
)
[docs]
def normalize_resource_path(path: str) -> str:
"""
Normalize the path of a bundled file, relative to the skill root.
:param path: A relative path, e.g. ``./references/FORMS.md``.
:return: The normalized path, e.g. ``references/FORMS.md``.
:raises SkillDocumentError: If the path is empty, absolute, a URL, contains a backslash
or control characters, or escapes the skill root.
"""
if not isinstance(path, str) or not path.strip():
raise SkillDocumentError("resource paths must be non-empty strings.")
path = path.strip()
if len(path) > MAX_RESOURCE_PATH_LENGTH:
raise SkillDocumentError(f"resource paths must be at most {MAX_RESOURCE_PATH_LENGTH} characters: {path}")
if "\\" in path or any(ord(char) < 32 for char in path):
raise SkillDocumentError(f"resource paths must not contain backslashes or control characters: {path}")
if path.startswith("/") or URL_SCHEME_PATTERN.match(path):
raise SkillDocumentError(f"resource paths must be relative to the skill root: {path}")
normalized = posixpath.normpath(path)
if normalized == "." or normalized == ".." or normalized.startswith("../"):
raise SkillDocumentError(f"resource paths must not refer to files outside of the skill: {path}")
return normalized
[docs]
def normalize_resources(resources: Optional[dict[str, Optional[str]]]) -> dict[str, Optional[str]]:
"""
Validate and normalize a mapping of bundled file paths to their contents.
A ``None`` value denotes a file whose contents are unavailable, such as a binary asset.
:param resources: The bundled files, keyed by path relative to the skill root.
:return: The bundled files, keyed by normalized path, in sorted order.
:raises SkillDocumentError: If a path is invalid or duplicated after normalization, a
file is SKILL.md itself, a file is too long, or there are too many files.
"""
if not resources:
return {}
if not isinstance(resources, dict):
raise SkillDocumentError("resources must be a mapping of relative paths to file contents.")
if len(resources) > MAX_RESOURCES:
raise SkillDocumentError(f"a skill may bundle at most {MAX_RESOURCES} files.")
normalized: dict[str, Optional[str]] = {}
for path, content in resources.items():
key = normalize_resource_path(path)
if key == SKILL_FILENAME:
raise SkillDocumentError(f"{SKILL_FILENAME} must not be included in resources.")
if key in normalized:
raise SkillDocumentError(f"resource path is duplicated: {path}")
if content is not None:
if not isinstance(content, str):
raise SkillDocumentError(f"the contents of resource {path} must be a string.")
if len(content) > MAX_RESOURCE_LENGTH:
raise SkillDocumentError(f"resource {path} must be at most {MAX_RESOURCE_LENGTH} characters.")
content = normalize_line_endings(content)
normalized[key] = content
return dict(sorted(normalized.items()))
[docs]
def find_resource_references(body: str) -> list[str]:
"""
Find the bundled files that a SKILL.md body refers to.
Recognizes relative Markdown links, e.g. ``[guide](./reference/guide.md)``, and inline
code that looks like a path, e.g. ```scripts/fill_form.py```. URLs, absolute paths,
anchors and paths that escape the skill root are ignored.
:param body: The Markdown body of a SKILL.md document.
:return: The normalized paths, without duplicates, in order of first reference.
"""
candidates = MARKDOWN_LINK_PATTERN.findall(body) + INLINE_PATH_PATTERN.findall(body)
paths: list[str] = []
for candidate in candidates:
candidate = candidate.split("#", 1)[0].split("?", 1)[0]
if not candidate:
continue
try:
path = normalize_resource_path(candidate)
except SkillDocumentError:
continue
if path != SKILL_FILENAME and path not in paths:
paths.append(path)
return paths