from collections.abc import Mapping from pathlib import PurePosixPath from pydantic import Field from pydantic import field_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 type ToolSelector = str | tuple[str, ...] class SkillMetadata(StrictFrozenModel): """Canonical metadata describing a skill and its dependencies.""" id: str version: str tags: tuple[str, ...] = () capabilities: tuple[str, ...] = Field(min_length=1) depends_on: tuple[str, ...] = () 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("depends_on") @classmethod def validate_depends_on(cls, value: tuple[str, ...]) -> tuple[str, ...]: for dep in value: if not SKILL_ID_RE.fullmatch(dep): raise ValueError(f"invalid depends_on skill id: {dep}") 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) when_to_use: str | None = None allowed_tools: ToolSelector | None = Field(default=None, alias="allowed-tools") disallowed_tools: ToolSelector | None = Field( default=None, alias="disallowed-tools", ) disable_model_invocation: bool | None = Field( default=None, alias="disable-model-invocation", ) user_invocable: bool | None = Field(default=None, alias="user-invocable") argument_hint: str | None = Field(default=None, alias="argument-hint") arguments: ToolSelector | None = None license: str | None = None compatibility: str | None = None metadata: Mapping[str, str] | None = None x_personal_mcp: SkillMetadata = Field(alias="x-personal-mcp") @field_validator("metadata", mode="before") @classmethod def freeze_metadata(cls, value: Mapping[str, str] | None) -> Mapping[str, str] | None: if value is None: return None return frozen_mapping(value) @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 class SkillDocumentModel(StrictFrozenModel): """Structured representation of a skill markdown document.""" skill_id: str relpath: PurePosixPath content: str frontmatter: SkillFrontmatter class SkillReferenceDocumentModel(StrictFrozenModel): """Structured representation of a skill reference markdown document.""" ref_id: str relpath: PurePosixPath content: str entry: ReferenceEntry