"""Skill data model and loading utilities for AgentSkills.io skills. This module defines the Skill dataclass and provides classmethods for discovering, parsing, and loading skills from the filesystem, raw content, or HTTPS URLs. Skills are directories containing a SKILL.md file with YAML frontmatter metadata and markdown instructions. """ from __future__ import annotations import logging import re import urllib.error import urllib.request from dataclasses import dataclass, field from pathlib import Path from typing import Any import yaml logger = logging.getLogger(__name__) _SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$") _MAX_SKILL_NAME_LENGTH = 64 def _find_skill_md(skill_dir: Path) -> Path: """Find the SKILL.md file in a skill directory. Searches for SKILL.md (case-sensitive preferred) or skill.md as a fallback. Args: skill_dir: Path to the skill directory. Returns: Path to the SKILL.md file. Raises: FileNotFoundError: If no SKILL.md file is found in the directory. """ for name in ("SKILL.md", "skill.md"): candidate = skill_dir / name if candidate.is_file(): return candidate raise FileNotFoundError(f"path=<{skill_dir}> | no SKILL.md found in skill directory") def _parse_frontmatter(content: str) -> tuple[dict[str, Any], str]: """Parse YAML frontmatter and body from SKILL.md content. Extracts the YAML frontmatter between ``---`` delimiters at line boundaries and returns parsed key-value pairs along with the remaining markdown body. Args: content: Full content of a SKILL.md file. Returns: Tuple of (frontmatter_dict, body_string). Raises: ValueError: If the frontmatter is malformed or missing required delimiters. """ stripped = content.strip() if not stripped.startswith("---"): raise ValueError("SKILL.md must start with --- frontmatter delimiter") # Find the closing --- delimiter (first line after the opener that is only dashes) match = re.search(r"\n^---\s*$", stripped, re.MULTILINE) if match is None: raise ValueError("SKILL.md frontmatter missing closing --- delimiter") frontmatter_str = stripped[3 : match.start()].strip() body = stripped[match.end() :].strip() try: result = yaml.safe_load(frontmatter_str) except yaml.YAMLError: # AgentSkills spec recommends handling malformed YAML (e.g. unquoted colons in values) # to improve cross-client compatibility. See: agentskills.io/client-implementation/adding-skills-support logger.warning("YAML parse failed, retrying with colon-quoting fallback") fixed = _fix_yaml_colons(frontmatter_str) try: result = yaml.safe_load(fixed) except yaml.YAMLError as error: raise ValueError(f"malformed YAML frontmatter: {error}") from error frontmatter: dict[str, Any] = result if isinstance(result, dict) else {} return frontmatter, body def _fix_yaml_colons(yaml_str: str) -> str: """Attempt to fix common YAML issues like unquoted colons in values. Wraps values containing colons in double quotes to handle cases like: ``description: Use this skill when: the user asks about PDFs`` Args: yaml_str: The raw YAML string to fix. Returns: The fixed YAML string. """ lines: list[str] = [] for line in yaml_str.splitlines(): # Match key: value where value contains another colon match = re.match(r"^(\s*\w[\w-]*):\s+(.+)$", line) if match: key, value = match.group(1), match.group(2) # If value contains a colon and isn't already quoted if ":" in value and not (value.startswith('"') or value.startswith("'")): line = f'{key}: "{value}"' lines.append(line) return "\n".join(lines) def _validate_skill_name(name: str, dir_path: Path | None = None, *, strict: bool = False) -> None: """Validate a skill name per the AgentSkills.io specification. In lenient mode (default), logs warnings for cosmetic issues but does not raise. In strict mode, raises ValueError for any validation failure. Rules checked: - 1-64 characters long - Lowercase alphanumeric characters and hyphens only - Cannot start or end with a hyphen - No consecutive hyphens - Must match parent directory name (if loaded from disk) Args: name: The skill name to validate. dir_path: Optional path to the skill directory for name matching. strict: If True, raise ValueError on any issue. If False (default), log warnings. Raises: ValueError: If the skill name is empty, or if strict=True and any rule is violated. """ if not name: raise ValueError("Skill name cannot be empty") if len(name) > _MAX_SKILL_NAME_LENGTH: msg = "name=<%s> | skill name exceeds %d character limit" if strict: raise ValueError(msg % (name, _MAX_SKILL_NAME_LENGTH)) logger.warning(msg, name, _MAX_SKILL_NAME_LENGTH) if not _SKILL_NAME_PATTERN.match(name): msg = ( "name=<%s> | skill name should be 1-64 lowercase alphanumeric characters or hyphens, " "should not start/end with hyphen" ) if strict: raise ValueError(msg % name) logger.warning(msg, name) if "--" in name: msg = "name=<%s> | skill name contains consecutive hyphens" if strict: raise ValueError(msg % name) logger.warning(msg, name) if dir_path is not None and dir_path.name != name: msg = "name=<%s>, directory=<%s> | skill name does not match parent directory name" if strict: raise ValueError(msg % (name, dir_path.name)) logger.warning(msg, name, dir_path.name) def _build_skill_from_frontmatter( frontmatter: dict[str, Any], body: str, ) -> Skill: """Build a Skill instance from parsed frontmatter and body. Args: frontmatter: Parsed YAML frontmatter dict. body: Markdown body content. Returns: A populated Skill instance. """ # Parse allowed-tools (space-delimited string or YAML list) allowed_tools_raw = frontmatter.get("allowed-tools") or frontmatter.get("allowed_tools") allowed_tools: list[str] | None = None if isinstance(allowed_tools_raw, str) and allowed_tools_raw.strip(): allowed_tools = allowed_tools_raw.strip().split() elif isinstance(allowed_tools_raw, list): allowed_tools = [str(item) for item in allowed_tools_raw if item] # Parse metadata (nested mapping) metadata_raw = frontmatter.get("metadata", {}) metadata: dict[str, Any] = {} if isinstance(metadata_raw, dict): metadata = {str(k): v for k, v in metadata_raw.items()} skill_license = frontmatter.get("license") compatibility = frontmatter.get("compatibility") return Skill( name=frontmatter["name"], description=frontmatter["description"], instructions=body, allowed_tools=allowed_tools, metadata=metadata, license=str(skill_license) if skill_license else None, compatibility=str(compatibility) if compatibility else None, ) @dataclass class Skill: r"""Represents an agent skill with metadata and instructions. A skill encapsulates a set of instructions and metadata that can be dynamically loaded by an agent at runtime. Skills support progressive disclosure: metadata is shown upfront in the system prompt, and full instructions are loaded on demand via a tool. Skills can be created directly or via convenience classmethods:: # From a skill directory on disk skill = Skill.from_file("./skills/my-skill") # From raw SKILL.md content skill = Skill.from_content("---\nname: my-skill\n...") # Load all skills from a parent directory skills = Skill.from_directory("./skills/") # From an HTTPS URL skill = Skill.from_url("https://example.com/SKILL.md") Attributes: name: Unique identifier for the skill (1-64 chars, lowercase alphanumeric + hyphens). description: Human-readable description of what the skill does. instructions: Full markdown instructions from the SKILL.md body. path: Filesystem path to the skill directory, if loaded from disk. allowed_tools: List of tool names the skill is allowed to use. (Experimental: not yet enforced) metadata: Additional key-value metadata from the SKILL.md frontmatter. license: License identifier (e.g., "Apache-2.0"). compatibility: Compatibility information string. """ name: str description: str instructions: str = "" path: Path | None = None allowed_tools: list[str] | None = None metadata: dict[str, Any] = field(default_factory=dict) license: str | None = None compatibility: str | None = None @classmethod def from_file(cls, skill_path: str | Path, *, strict: bool = False) -> Skill: """Load a single skill from a directory containing SKILL.md. Resolves the filesystem path, reads the file content, and delegates to ``from_content`` for parsing. After loading, sets the skill's ``path`` and validates the skill name against the parent directory. Args: skill_path: Path to the skill directory or the SKILL.md file itself. strict: If True, raise on any validation issue. If False (default), warn and load anyway. Returns: A Skill instance populated from the SKILL.md file. Raises: FileNotFoundError: If the path does not exist or SKILL.md is not found. ValueError: If the skill metadata is invalid. """ skill_path = Path(skill_path).resolve() if skill_path.is_file() and skill_path.name.lower() == "skill.md": skill_md_path = skill_path skill_dir = skill_path.parent elif skill_path.is_dir(): skill_dir = skill_path skill_md_path = _find_skill_md(skill_dir) else: raise FileNotFoundError( f"path=<{skill_path}> | skill path does not exist or is not a valid skill directory" ) logger.debug("path=<%s> | loading skill", skill_md_path) content = skill_md_path.read_text(encoding="utf-8") skill = cls.from_content(content, strict=strict) # Set path and check directory name match (from_content already validated the name format) skill.path = skill_dir if skill_dir.name != skill.name: msg = "name=<%s>, directory=<%s> | skill name does not match parent directory name" if strict: raise ValueError(msg % (skill.name, skill_dir.name)) logger.warning(msg, skill.name, skill_dir.name) logger.debug("name=<%s>, path=<%s> | skill loaded successfully", skill.name, skill.path) return skill @classmethod def from_content(cls, content: str, *, strict: bool = False) -> Skill: """Parse SKILL.md content into a Skill instance. This is a convenience method for creating a Skill from raw SKILL.md content (YAML frontmatter + markdown body) without requiring a file on disk. Example:: content = '''--- name: my-skill description: Does something useful --- # Instructions Follow these steps... ''' skill = Skill.from_content(content) Args: content: Raw SKILL.md content with YAML frontmatter and markdown body. strict: If True, raise on any validation issue. If False (default), warn and load anyway. Returns: A Skill instance populated from the parsed content. Raises: ValueError: If the content is missing required fields or has invalid frontmatter. """ frontmatter, body = _parse_frontmatter(content) name = frontmatter.get("name") if not isinstance(name, str) or not name: raise ValueError("SKILL.md content must have a 'name' field in frontmatter") description = frontmatter.get("description") if not isinstance(description, str) or not description: raise ValueError("SKILL.md content must have a 'description' field in frontmatter") _validate_skill_name(name, strict=strict) return _build_skill_from_frontmatter(frontmatter, body) @classmethod def from_url(cls, url: str, *, strict: bool = False) -> Skill: """Load a skill by fetching its SKILL.md content from an HTTPS URL. Fetches the raw SKILL.md content over HTTPS and parses it using :meth:`from_content`. The URL must point directly to the raw file content (not an HTML page). Example:: skill = Skill.from_url( "https://raw.githubusercontent.com/org/repo/main/SKILL.md" ) Args: url: An ``https://`` URL pointing directly to raw SKILL.md content. strict: If True, raise on any validation issue. If False (default), warn and load anyway. Returns: A Skill instance populated from the fetched SKILL.md content. Raises: ValueError: If ``url`` is not an ``https://`` URL. RuntimeError: If the SKILL.md content cannot be fetched. """ if not url.startswith("https://"): raise ValueError(f"url=<{url}> | not a valid HTTPS URL") logger.info("url=<%s> | fetching skill content", url) try: req = urllib.request.Request(url, headers={"User-Agent": "strands-agents-sdk"}) # noqa: S310 with urllib.request.urlopen(req, timeout=30) as response: # noqa: S310 content: str = response.read().decode("utf-8") except urllib.error.HTTPError as e: raise RuntimeError(f"url=<{url}> | HTTP {e.code}: {e.reason}") from e except urllib.error.URLError as e: raise RuntimeError(f"url=<{url}> | failed to fetch skill: {e.reason}") from e return cls.from_content(content, strict=strict) @classmethod def from_directory(cls, skills_dir: str | Path, *, strict: bool = False) -> list[Skill]: """Load all skills from a parent directory containing skill subdirectories. Each subdirectory containing a SKILL.md file is treated as a skill. Subdirectories without SKILL.md are silently skipped. Args: skills_dir: Path to the parent directory containing skill subdirectories. strict: If True, raise on any validation issue. If False (default), warn and load anyway. Returns: List of Skill instances loaded from the directory. Raises: FileNotFoundError: If the skills directory does not exist. """ skills_dir = Path(skills_dir).resolve() if not skills_dir.is_dir(): raise FileNotFoundError(f"path=<{skills_dir}> | skills directory does not exist") skills: list[Skill] = [] for child in sorted(skills_dir.iterdir()): if not child.is_dir(): continue try: _find_skill_md(child) except FileNotFoundError: logger.debug("path=<%s> | skipping directory without SKILL.md", child) continue try: skill = cls.from_file(child, strict=strict) skills.append(skill) except (ValueError, FileNotFoundError) as e: logger.warning("path=<%s> | skipping skill due to error: %s", child, e) logger.debug("path=<%s>, count=<%d> | loaded skills from directory", skills_dir, len(skills)) return skills