123 lines
3.9 KiB
Python
123 lines
3.9 KiB
Python
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)
|