frozen pydantic models
This commit is contained in:
@@ -1,80 +1,17 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from dataclasses import field
|
|
||||||
|
|
||||||
from .models.prompt import PromptArgumentEntry
|
from .models.prompt import PromptArgumentEntry
|
||||||
|
from .models.registry import DocsRegistry
|
||||||
|
from .models.registry import PromptRecord
|
||||||
|
from .models.registry import PromptSummaryRecord
|
||||||
|
from .models.registry import ReferenceRecord
|
||||||
|
from .models.registry import SkillRecord
|
||||||
|
from .models.registry import SkillSummaryRecord
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
@dataclass(frozen=True)
|
"DocsRegistry",
|
||||||
class ReferenceRecord:
|
"PromptArgumentEntry",
|
||||||
ref_id: str
|
"PromptRecord",
|
||||||
uri: str
|
"PromptSummaryRecord",
|
||||||
relpath: str
|
"ReferenceRecord",
|
||||||
mime_type: str
|
"SkillRecord",
|
||||||
title: str | None
|
"SkillSummaryRecord",
|
||||||
content: str
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SkillRecord:
|
|
||||||
skill_id: str
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
version: str
|
|
||||||
tags: tuple[str, ...]
|
|
||||||
capabilities: tuple[str, ...]
|
|
||||||
depends_on: tuple[str, ...]
|
|
||||||
document_uri: str
|
|
||||||
document_relpath: str
|
|
||||||
document_content: str
|
|
||||||
references: dict[str, ReferenceRecord]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SkillSummaryRecord:
|
|
||||||
skill_id: str
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
tags: tuple[str, ...]
|
|
||||||
capabilities: tuple[str, ...]
|
|
||||||
document_uri: str
|
|
||||||
version: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PromptRecord:
|
|
||||||
prompt_id: str
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
version: str
|
|
||||||
tags: tuple[str, ...]
|
|
||||||
capabilities: tuple[str, ...]
|
|
||||||
arguments: dict[str, PromptArgumentEntry]
|
|
||||||
document_uri: str
|
|
||||||
document_relpath: str
|
|
||||||
document_content: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PromptSummaryRecord:
|
|
||||||
prompt_id: str
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
tags: tuple[str, ...]
|
|
||||||
capabilities: tuple[str, ...]
|
|
||||||
document_uri: str
|
|
||||||
version: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DocsRegistry:
|
|
||||||
skills_by_id: dict[str, SkillRecord]
|
|
||||||
skills_in_load_order: tuple[str, ...]
|
|
||||||
skills_summary_in_load_order: tuple[SkillSummaryRecord, ...]
|
|
||||||
docs_markdown_by_path: dict[str, str]
|
|
||||||
docs_markdown_path_index: tuple[str, ...]
|
|
||||||
tag_to_skill_ids: dict[str, tuple[str, ...]]
|
|
||||||
capability_to_skill_ids: dict[str, tuple[str, ...]]
|
|
||||||
prompts_by_id: dict[str, PromptRecord] = field(default_factory=dict)
|
|
||||||
prompts_in_load_order: tuple[str, ...] = ()
|
|
||||||
prompts_summary_in_load_order: tuple[PromptSummaryRecord, ...] = ()
|
|
||||||
tag_to_prompt_ids: dict[str, tuple[str, ...]] = field(default_factory=dict)
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Functions to produce immutable dataclasses representing the document registry."""
|
||||||
@@ -6,7 +6,7 @@ from itertools import groupby
|
|||||||
from itertools import starmap
|
from itertools import starmap
|
||||||
from typing import Self
|
from typing import Self
|
||||||
|
|
||||||
from .file import MarkdownDocument
|
from .document import MarkdownDocument
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from .common import SEMVER_RE
|
||||||
|
from .common import SKILL_ID_RE
|
||||||
|
from .common import ReferenceEntry
|
||||||
|
from .common import StrictFrozenModel
|
||||||
|
from .document import MarkdownDocumentModel
|
||||||
|
from .document import SkillFilesBundleModel
|
||||||
|
from .document import to_markdown_document_model
|
||||||
|
from .prompt import PromptArgumentEntry
|
||||||
|
from .prompt import PromptDocumentModel
|
||||||
|
from .prompt import PromptFrontmatter
|
||||||
|
from .prompt import PromptMetadata
|
||||||
|
from .registry import DocsRegistry
|
||||||
|
from .registry import PromptRecord
|
||||||
|
from .registry import PromptSummaryRecord
|
||||||
|
from .registry import ReferenceRecord
|
||||||
|
from .registry import SkillRecord
|
||||||
|
from .registry import SkillSummaryRecord
|
||||||
|
from .skill import PersonalMcpMetadata
|
||||||
|
from .skill import SkillDocumentModel
|
||||||
|
from .skill import SkillFrontmatter
|
||||||
|
from .skill import SkillMetadata
|
||||||
|
from .skill import SkillReferenceDocumentModel
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SEMVER_RE",
|
||||||
|
"SKILL_ID_RE",
|
||||||
|
"DocsRegistry",
|
||||||
|
"MarkdownDocumentModel",
|
||||||
|
"PersonalMcpMetadata",
|
||||||
|
"PromptArgumentEntry",
|
||||||
|
"PromptDocumentModel",
|
||||||
|
"PromptFrontmatter",
|
||||||
|
"PromptMetadata",
|
||||||
|
"PromptRecord",
|
||||||
|
"PromptSummaryRecord",
|
||||||
|
"ReferenceEntry",
|
||||||
|
"ReferenceRecord",
|
||||||
|
"SkillDocumentModel",
|
||||||
|
"SkillFilesBundleModel",
|
||||||
|
"SkillFrontmatter",
|
||||||
|
"SkillMetadata",
|
||||||
|
"SkillRecord",
|
||||||
|
"SkillReferenceDocumentModel",
|
||||||
|
"SkillSummaryRecord",
|
||||||
|
"StrictFrozenModel",
|
||||||
|
"to_markdown_document_model",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import re
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
|
from types import MappingProxyType
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
@@ -8,10 +11,25 @@ from pydantic import field_validator
|
|||||||
SKILL_ID_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
SKILL_ID_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
||||||
SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$")
|
SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$")
|
||||||
|
|
||||||
|
K = TypeVar("K")
|
||||||
|
V = TypeVar("V")
|
||||||
|
|
||||||
class ReferenceEntry(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
|
||||||
|
|
||||||
|
class StrictFrozenModel(BaseModel):
|
||||||
|
model_config = ConfigDict(
|
||||||
|
extra="forbid",
|
||||||
|
frozen=True,
|
||||||
|
validate_by_alias=True,
|
||||||
|
validate_by_name=True,
|
||||||
|
str_strip_whitespace=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def frozen_mapping(value: Mapping[K, V] | None = None) -> Mapping[K, V]:
|
||||||
|
return MappingProxyType(dict(value or {}))
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceEntry(StrictFrozenModel):
|
||||||
path: str
|
path: str
|
||||||
mime_type: str = "text/markdown"
|
mime_type: str = "text/markdown"
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
|
from .common import StrictFrozenModel
|
||||||
|
|
||||||
|
|
||||||
|
class MarkdownDocumentModel(StrictFrozenModel):
|
||||||
|
relpath: str
|
||||||
|
content: str
|
||||||
|
frontmatter: str | None = None
|
||||||
|
skill_slug: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SkillFilesBundleModel(StrictFrozenModel):
|
||||||
|
slug: str
|
||||||
|
skill: MarkdownDocumentModel
|
||||||
|
references: tuple[MarkdownDocumentModel, ...] = ()
|
||||||
|
other: tuple[MarkdownDocumentModel, ...] = ()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_parts(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
slug: str,
|
||||||
|
skill: MarkdownDocumentModel,
|
||||||
|
references: tuple[MarkdownDocumentModel, ...] = (),
|
||||||
|
other: tuple[MarkdownDocumentModel, ...] = (),
|
||||||
|
) -> "SkillFilesBundleModel":
|
||||||
|
# Ensure deterministic ordering even when the source collection is unordered.
|
||||||
|
references_sorted = tuple(sorted(references, key=lambda doc: doc.relpath))
|
||||||
|
other_sorted = tuple(sorted(other, key=lambda doc: doc.relpath))
|
||||||
|
return cls(
|
||||||
|
slug=slug,
|
||||||
|
skill=skill,
|
||||||
|
references=references_sorted,
|
||||||
|
other=other_sorted,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def to_markdown_document_model(
|
||||||
|
*,
|
||||||
|
relpath: PurePosixPath,
|
||||||
|
content: str,
|
||||||
|
frontmatter: str | None,
|
||||||
|
skill_slug: str | None,
|
||||||
|
) -> MarkdownDocumentModel:
|
||||||
|
return MarkdownDocumentModel(
|
||||||
|
relpath=relpath.as_posix(),
|
||||||
|
content=content,
|
||||||
|
frontmatter=frontmatter,
|
||||||
|
skill_slug=skill_slug,
|
||||||
|
)
|
||||||
@@ -1,23 +1,22 @@
|
|||||||
import re
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from pydantic import ConfigDict
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
|
|
||||||
from .common import SEMVER_RE
|
from .common import SEMVER_RE
|
||||||
from .common import SKILL_ID_RE
|
from .common import SKILL_ID_RE
|
||||||
|
from .common import StrictFrozenModel
|
||||||
|
from .common import frozen_mapping
|
||||||
|
|
||||||
|
|
||||||
class PromptArgumentEntry(BaseModel):
|
class PromptArgumentEntry(StrictFrozenModel):
|
||||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
|
||||||
|
|
||||||
type: str = Field(min_length=1)
|
type: str = Field(min_length=1)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
required: bool = False
|
required: bool = False
|
||||||
default: Any | None = None
|
default: Any | None = None
|
||||||
enum: list[str] | None = None
|
enum: tuple[str, ...] | None = None
|
||||||
|
|
||||||
@field_validator("type")
|
@field_validator("type")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -36,20 +35,18 @@ class PromptArgumentEntry(BaseModel):
|
|||||||
|
|
||||||
@field_validator("enum")
|
@field_validator("enum")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_enum(cls, value: list[str] | None) -> list[str] | None:
|
def validate_enum(cls, value: tuple[str, ...] | None) -> tuple[str, ...] | None:
|
||||||
if value is not None and not value:
|
if value is not None and not value:
|
||||||
raise ValueError("enum must contain at least one value when provided")
|
raise ValueError("enum must contain at least one value when provided")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class PromptMetadata(BaseModel):
|
class PromptMetadata(StrictFrozenModel):
|
||||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
version: str
|
version: str
|
||||||
tags: list[str] = Field(default_factory=list)
|
tags: tuple[str, ...] = ()
|
||||||
capabilities: list[str] = Field(min_length=1)
|
capabilities: tuple[str, ...] = Field(min_length=1)
|
||||||
arguments: dict[str, PromptArgumentEntry] = Field(default_factory=dict)
|
arguments: Mapping[str, PromptArgumentEntry] = Field(default_factory=frozen_mapping)
|
||||||
|
|
||||||
@field_validator("id")
|
@field_validator("id")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -67,24 +64,27 @@ class PromptMetadata(BaseModel):
|
|||||||
|
|
||||||
@field_validator("tags")
|
@field_validator("tags")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_tags(cls, value: list[str]) -> list[str]:
|
def validate_tags(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||||
for tag in value:
|
for tag in value:
|
||||||
if not SKILL_ID_RE.fullmatch(tag):
|
if not SKILL_ID_RE.fullmatch(tag):
|
||||||
raise ValueError(f"invalid tag: {tag}")
|
raise ValueError(f"invalid tag: {tag}")
|
||||||
return value
|
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")
|
@field_validator("arguments")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_argument_names(cls, value: dict[str, PromptArgumentEntry]) -> dict[str, PromptArgumentEntry]:
|
def validate_argument_names(cls, value: Mapping[str, PromptArgumentEntry]) -> Mapping[str, PromptArgumentEntry]:
|
||||||
for name in value:
|
for name in value:
|
||||||
if not re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
|
if not re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
|
||||||
raise ValueError(f"invalid prompt argument name: {name}")
|
raise ValueError(f"invalid prompt argument name: {name}")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class PromptFrontmatter(BaseModel):
|
class PromptFrontmatter(StrictFrozenModel):
|
||||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
|
||||||
|
|
||||||
name: str = Field(min_length=1, max_length=64)
|
name: str = Field(min_length=1, max_length=64)
|
||||||
description: str = Field(min_length=1, max_length=1024)
|
description: str = Field(min_length=1, max_length=1024)
|
||||||
x_personal_mcp: PromptMetadata = Field(alias="x-personal-mcp")
|
x_personal_mcp: PromptMetadata = Field(alias="x-personal-mcp")
|
||||||
@@ -97,3 +97,10 @@ class PromptFrontmatter(BaseModel):
|
|||||||
if "anthropic" in value or "claude" in value:
|
if "anthropic" in value or "claude" in value:
|
||||||
raise ValueError("name must not contain reserved words anthropic or claude")
|
raise ValueError("name must not contain reserved words anthropic or claude")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class PromptDocumentModel(StrictFrozenModel):
|
||||||
|
prompt_id: str
|
||||||
|
relpath: str
|
||||||
|
content: str
|
||||||
|
frontmatter: PromptFrontmatter
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic import field_validator
|
||||||
|
|
||||||
|
from .common import StrictFrozenModel
|
||||||
|
from .common import frozen_mapping
|
||||||
|
from .prompt import PromptArgumentEntry
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceRecord(StrictFrozenModel):
|
||||||
|
ref_id: str
|
||||||
|
uri: str
|
||||||
|
relpath: str
|
||||||
|
mime_type: str
|
||||||
|
title: str | None
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class SkillRecord(StrictFrozenModel):
|
||||||
|
skill_id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
version: str
|
||||||
|
tags: tuple[str, ...]
|
||||||
|
capabilities: tuple[str, ...]
|
||||||
|
depends_on: tuple[str, ...]
|
||||||
|
document_uri: str
|
||||||
|
document_relpath: str
|
||||||
|
document_content: str
|
||||||
|
references: Mapping[str, ReferenceRecord] = Field(default_factory=frozen_mapping)
|
||||||
|
|
||||||
|
@field_validator("references", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def freeze_references(cls, value: Mapping[str, ReferenceRecord] | None) -> Mapping[str, ReferenceRecord]:
|
||||||
|
return frozen_mapping(value)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillSummaryRecord(StrictFrozenModel):
|
||||||
|
skill_id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
tags: tuple[str, ...]
|
||||||
|
capabilities: tuple[str, ...]
|
||||||
|
document_uri: str
|
||||||
|
version: str
|
||||||
|
|
||||||
|
|
||||||
|
class PromptRecord(StrictFrozenModel):
|
||||||
|
prompt_id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
version: str
|
||||||
|
tags: tuple[str, ...]
|
||||||
|
capabilities: tuple[str, ...]
|
||||||
|
arguments: Mapping[str, PromptArgumentEntry] = Field(default_factory=frozen_mapping)
|
||||||
|
document_uri: str
|
||||||
|
document_relpath: str
|
||||||
|
document_content: str
|
||||||
|
|
||||||
|
@field_validator("arguments", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def freeze_arguments(cls, value: Mapping[str, PromptArgumentEntry] | None) -> Mapping[str, PromptArgumentEntry]:
|
||||||
|
return frozen_mapping(value)
|
||||||
|
|
||||||
|
|
||||||
|
class PromptSummaryRecord(StrictFrozenModel):
|
||||||
|
prompt_id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
tags: tuple[str, ...]
|
||||||
|
capabilities: tuple[str, ...]
|
||||||
|
document_uri: str
|
||||||
|
version: str
|
||||||
|
|
||||||
|
|
||||||
|
class DocsRegistry(StrictFrozenModel):
|
||||||
|
skills_by_id: Mapping[str, SkillRecord] = Field(default_factory=frozen_mapping)
|
||||||
|
skills_in_load_order: tuple[str, ...]
|
||||||
|
skills_summary_in_load_order: tuple[SkillSummaryRecord, ...]
|
||||||
|
docs_markdown_by_path: Mapping[str, str] = Field(default_factory=frozen_mapping)
|
||||||
|
docs_markdown_path_index: tuple[str, ...]
|
||||||
|
tag_to_skill_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
|
||||||
|
capability_to_skill_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
|
||||||
|
prompts_by_id: Mapping[str, PromptRecord] = Field(default_factory=frozen_mapping)
|
||||||
|
prompts_in_load_order: tuple[str, ...] = ()
|
||||||
|
prompts_summary_in_load_order: tuple[PromptSummaryRecord, ...] = ()
|
||||||
|
tag_to_prompt_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
|
||||||
|
|
||||||
|
@field_validator(
|
||||||
|
"skills_by_id",
|
||||||
|
"docs_markdown_by_path",
|
||||||
|
"tag_to_skill_ids",
|
||||||
|
"capability_to_skill_ids",
|
||||||
|
"prompts_by_id",
|
||||||
|
"tag_to_prompt_ids",
|
||||||
|
mode="before",
|
||||||
|
)
|
||||||
|
@classmethod
|
||||||
|
def freeze_mappings(cls, value: Mapping[str, object] | None) -> Mapping[str, object]:
|
||||||
|
return frozen_mapping(value)
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
from pydantic import BaseModel
|
from collections.abc import Mapping
|
||||||
from pydantic import ConfigDict
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
|
|
||||||
from .common import SEMVER_RE
|
from .common import SEMVER_RE
|
||||||
from .common import SKILL_ID_RE
|
from .common import SKILL_ID_RE
|
||||||
from .common import ReferenceEntry
|
from .common import ReferenceEntry
|
||||||
|
from .common import StrictFrozenModel
|
||||||
|
from .common import frozen_mapping
|
||||||
|
|
||||||
|
|
||||||
class PersonalMcpMetadata(BaseModel):
|
class SkillMetadata(StrictFrozenModel):
|
||||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
version: str
|
version: str
|
||||||
tags: list[str] = Field(default_factory=list)
|
tags: tuple[str, ...] = ()
|
||||||
capabilities: list[str] = Field(min_length=1)
|
capabilities: tuple[str, ...] = Field(min_length=1)
|
||||||
depends_on: list[str] = Field(default_factory=list)
|
depends_on: tuple[str, ...] = ()
|
||||||
references: dict[str, ReferenceEntry] = Field(default_factory=dict)
|
references: Mapping[str, ReferenceEntry] = Field(default_factory=frozen_mapping)
|
||||||
|
|
||||||
@field_validator("id")
|
@field_validator("id")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -34,7 +34,7 @@ class PersonalMcpMetadata(BaseModel):
|
|||||||
|
|
||||||
@field_validator("tags")
|
@field_validator("tags")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_tags(cls, value: list[str]) -> list[str]:
|
def validate_tags(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||||
for tag in value:
|
for tag in value:
|
||||||
if not SKILL_ID_RE.fullmatch(tag):
|
if not SKILL_ID_RE.fullmatch(tag):
|
||||||
raise ValueError(f"invalid tag: {tag}")
|
raise ValueError(f"invalid tag: {tag}")
|
||||||
@@ -42,29 +42,32 @@ class PersonalMcpMetadata(BaseModel):
|
|||||||
|
|
||||||
@field_validator("depends_on")
|
@field_validator("depends_on")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_depends_on(cls, value: list[str]) -> list[str]:
|
def validate_depends_on(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||||
for dep in value:
|
for dep in value:
|
||||||
if not SKILL_ID_RE.fullmatch(dep):
|
if not SKILL_ID_RE.fullmatch(dep):
|
||||||
raise ValueError(f"invalid depends_on skill id: {dep}")
|
raise ValueError(f"invalid depends_on skill id: {dep}")
|
||||||
return value
|
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")
|
@field_validator("references")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_reference_ids(cls, value: dict[str, ReferenceEntry]) -> dict[str, ReferenceEntry]:
|
def validate_reference_ids(cls, value: Mapping[str, ReferenceEntry]) -> Mapping[str, ReferenceEntry]:
|
||||||
for ref_id in value:
|
for ref_id in value:
|
||||||
if not SKILL_ID_RE.fullmatch(ref_id):
|
if not SKILL_ID_RE.fullmatch(ref_id):
|
||||||
raise ValueError(f"invalid reference id: {ref_id}")
|
raise ValueError(f"invalid reference id: {ref_id}")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class SkillFrontmatter(BaseModel):
|
class SkillFrontmatter(StrictFrozenModel):
|
||||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
|
||||||
|
|
||||||
name: str = Field(min_length=1, max_length=64)
|
name: str = Field(min_length=1, max_length=64)
|
||||||
description: str = Field(min_length=1, max_length=1024)
|
description: str = Field(min_length=1, max_length=1024)
|
||||||
when_to_use: str | None = None
|
when_to_use: str | None = None
|
||||||
allowed_tools: str | list[str] | None = Field(default=None, alias="allowed-tools")
|
allowed_tools: str | tuple[str, ...] | None = Field(default=None, alias="allowed-tools")
|
||||||
disallowed_tools: str | list[str] | None = Field(
|
disallowed_tools: str | tuple[str, ...] | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
alias="disallowed-tools",
|
alias="disallowed-tools",
|
||||||
)
|
)
|
||||||
@@ -74,11 +77,18 @@ class SkillFrontmatter(BaseModel):
|
|||||||
)
|
)
|
||||||
user_invocable: bool | None = Field(default=None, alias="user-invocable")
|
user_invocable: bool | None = Field(default=None, alias="user-invocable")
|
||||||
argument_hint: str | None = Field(default=None, alias="argument-hint")
|
argument_hint: str | None = Field(default=None, alias="argument-hint")
|
||||||
arguments: str | list[str] | None = None
|
arguments: str | tuple[str, ...] | None = None
|
||||||
license: str | None = None
|
license: str | None = None
|
||||||
compatibility: str | None = None
|
compatibility: str | None = None
|
||||||
metadata: dict[str, str] | None = None
|
metadata: Mapping[str, str] | None = None
|
||||||
x_personal_mcp: PersonalMcpMetadata = Field(alias="x-personal-mcp")
|
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")
|
@field_validator("name")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -88,3 +98,21 @@ class SkillFrontmatter(BaseModel):
|
|||||||
if "anthropic" in value or "claude" in value:
|
if "anthropic" in value or "claude" in value:
|
||||||
raise ValueError("name must not contain reserved words anthropic or claude")
|
raise ValueError("name must not contain reserved words anthropic or claude")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class SkillDocumentModel(StrictFrozenModel):
|
||||||
|
skill_id: str
|
||||||
|
relpath: str
|
||||||
|
content: str
|
||||||
|
frontmatter: SkillFrontmatter
|
||||||
|
|
||||||
|
|
||||||
|
class SkillReferenceDocumentModel(StrictFrozenModel):
|
||||||
|
ref_id: str
|
||||||
|
relpath: str
|
||||||
|
content: str
|
||||||
|
entry: ReferenceEntry
|
||||||
|
|
||||||
|
|
||||||
|
# Transitional alias while callers move to neutral naming.
|
||||||
|
PersonalMcpMetadata = SkillMetadata
|
||||||
|
|||||||
Reference in New Issue
Block a user