Files
prompts/src/personal_mcp/registry/models/skill.py
T
2026-06-21 16:15:37 -05:00

126 lines
4.2 KiB
Python

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