import re from collections.abc import Mapping from pathlib import PurePosixPath from typing import Any from typing import Literal import yaml from pydantic import Field from pydantic import field_validator from .common import SEMVER_RE from .common import SKILL_ID_RE from .common import StrictFrozenModel from .common import frozen_mapping type PromptArgumentType = Literal[ "string", "number", "integer", "boolean", "array", "object", ] class PromptArgumentEntry(StrictFrozenModel): """Schema for a single prompt argument definition.""" type: PromptArgumentType description: str | None = None required: bool = False default: Any | None = None enum: tuple[str, ...] | None = None @field_validator("enum") @classmethod def validate_enum(cls, value: tuple[str, ...] | None) -> tuple[str, ...] | None: if value is not None and not value: raise ValueError("enum must contain at least one value when provided") return value class PromptMetadata(StrictFrozenModel): """Canonical metadata describing a prompt contract and arguments.""" id: str version: str tags: tuple[str, ...] = () capabilities: tuple[str, ...] = Field(min_length=1) arguments: Mapping[str, PromptArgumentEntry] = 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("arguments", mode="before") @classmethod def freeze_arguments(cls, value: Mapping[str, PromptArgumentEntry] | None) -> Mapping[str, PromptArgumentEntry]: return frozen_mapping(value) @field_validator("arguments") @classmethod def validate_argument_names(cls, value: Mapping[str, PromptArgumentEntry]) -> Mapping[str, PromptArgumentEntry]: for name in value: if not re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]*$", name): raise ValueError(f"invalid prompt argument name: {name}") return value class PromptFrontmatter(StrictFrozenModel): """Parsed PROMPT frontmatter including personal-mcp metadata.""" name: str = Field(min_length=1, max_length=64) description: str = Field(min_length=1, max_length=1024) x_personal_mcp: PromptMetadata = 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 class StoredPrompt(StrictFrozenModel): """Normalized prompt document content with path and frontmatter for storage in the registry.""" prompt_id: str relpath: PurePosixPath content: str frontmatter: PromptFrontmatter | None = None @field_validator("frontmatter", mode="before") @classmethod def parse_frontmatter_yaml(cls, value: str | None): if value is None: return None try: data = yaml.safe_load(value) except yaml.YAMLError as e: raise ValueError(f"invalid YAML in frontmatter: {e}") from e return PromptFrontmatter.model_validate(data)