from collections.abc import Mapping from pathlib import PurePosixPath import yaml from pydantic import Field from pydantic import field_validator from pydantic import model_validator from .common import SEMVER_RE from .common import SKILL_ID_RE from .common import ReferenceEntry from .common import StrictFrozenModel from .common import frozen_mapping class SkillMetadata(StrictFrozenModel): """Canonical metadata describing a skill.""" id: str version: str tags: tuple[str, ...] = () capabilities: tuple[str, ...] = Field(min_length=1) references: Mapping[str, ReferenceEntry] = Field(default_factory=frozen_mapping) @field_validator("id") @classmethod def validate_id(cls, value: str) -> str: if not SKILL_ID_RE.fullmatch(value): raise ValueError("id must be lowercase kebab-case and start with a letter") return value @field_validator("version") @classmethod def validate_version(cls, value: str) -> str: if not SEMVER_RE.fullmatch(value): raise ValueError("version must be semver") return value @field_validator("tags") @classmethod def validate_tags(cls, value: tuple[str, ...]) -> tuple[str, ...]: for tag in value: if not SKILL_ID_RE.fullmatch(tag): raise ValueError(f"invalid tag: {tag}") return value @field_validator("references", mode="before") @classmethod def freeze_references(cls, value: Mapping[str, ReferenceEntry] | None) -> Mapping[str, ReferenceEntry]: return frozen_mapping(value) @field_validator("references") @classmethod def validate_reference_ids(cls, value: Mapping[str, ReferenceEntry]) -> Mapping[str, ReferenceEntry]: for ref_id in value: if not SKILL_ID_RE.fullmatch(ref_id): raise ValueError(f"invalid reference id: {ref_id}") return value class SkillFrontmatter(StrictFrozenModel): """Parsed SKILL frontmatter including standard and personal-mcp fields.""" name: str = Field(min_length=1, max_length=64) description: str = Field(min_length=1, max_length=1024) x_personal_mcp: SkillMetadata = Field(alias="x-personal-mcp") @field_validator("name") @classmethod def validate_name(cls, value: str) -> str: if not SKILL_ID_RE.fullmatch(value): raise ValueError("name must be lowercase kebab-case and start with a letter") if "anthropic" in value or "claude" in value: raise ValueError("name must not contain reserved words anthropic or claude") return value @classmethod def from_raw_yaml(cls, raw: str | None) -> "SkillFrontmatter": if raw is None: raise ValueError("missing YAML frontmatter") try: data = yaml.safe_load(raw) except yaml.YAMLError as e: raise ValueError(f"invalid YAML in frontmatter: {e}") from e if not isinstance(data, dict): raise TypeError("frontmatter must parse to an object") return cls.model_validate(data) class StoredSkillReference(StrictFrozenModel): """Structured representation of a skill reference markdown document.""" ref_id: str relpath: PurePosixPath content: str entry: ReferenceEntry class StoredSkill(StrictFrozenModel): """Structured representation of a skill markdown document.""" skill_id: str relpath: PurePosixPath content: str frontmatter: SkillFrontmatter references: Mapping[str, StoredSkillReference] = Field(default_factory=frozen_mapping) @field_validator("frontmatter", mode="before") @classmethod def parse_frontmatter_yaml(cls, value: SkillFrontmatter | str | None) -> SkillFrontmatter: if isinstance(value, SkillFrontmatter): return value return SkillFrontmatter.from_raw_yaml(value) @field_validator("references", mode="before") @classmethod def freeze_references(cls, value: Mapping[str, StoredSkillReference] | None) -> Mapping[str, StoredSkillReference]: return frozen_mapping(value) @model_validator(mode="after") def validate_contract(self) -> "StoredSkill": parts = self.relpath.parts if len(parts) < 3 or parts[0] != "skills": raise ValueError("skill relpath must be under skills//") skill_dir_name = parts[1] if self.frontmatter.name != skill_dir_name: raise ValueError("frontmatter name must exactly match skill directory name") if self.frontmatter.x_personal_mcp.id != self.frontmatter.name: raise ValueError("x-personal-mcp.id must exactly match name") expected_capability = f"resource://skills/{self.frontmatter.name}/document" if expected_capability not in self.frontmatter.x_personal_mcp.capabilities: raise ValueError(f"capabilities must include {expected_capability}") if self.skill_id != self.frontmatter.x_personal_mcp.id: raise ValueError("skill_id must exactly match x-personal-mcp.id") for ref_id, ref in self.references.items(): if ref.ref_id != ref_id: raise ValueError(f"reference key must match ref_id: {ref_id}") return self