177 lines
6.4 KiB
Python
177 lines
6.4 KiB
Python
from collections.abc import Mapping
|
|
from pathlib import PurePosixPath
|
|
|
|
import yaml
|
|
from pydantic import Field
|
|
from pydantic import field_validator
|
|
from pydantic import model_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
|
|
|
|
@classmethod
|
|
def from_raw_yaml(cls, raw: str | None) -> "SkillFrontmatter":
|
|
if raw is None:
|
|
raise ValueError("missing YAML frontmatter")
|
|
try:
|
|
data = yaml.safe_load(raw)
|
|
except yaml.YAMLError as e:
|
|
raise ValueError(f"invalid YAML in frontmatter: {e}") from e
|
|
if not isinstance(data, dict):
|
|
raise TypeError("frontmatter must parse to an object")
|
|
return cls.model_validate(data)
|
|
|
|
|
|
class StoredSkillReference(StrictFrozenModel):
|
|
"""Structured representation of a skill reference markdown document."""
|
|
|
|
ref_id: str
|
|
relpath: PurePosixPath
|
|
content: str
|
|
entry: ReferenceEntry
|
|
|
|
|
|
class StoredSkill(StrictFrozenModel):
|
|
"""Structured representation of a skill markdown document."""
|
|
|
|
skill_id: str
|
|
relpath: PurePosixPath
|
|
content: str
|
|
frontmatter: SkillFrontmatter
|
|
references: Mapping[str, StoredSkillReference] = Field(default_factory=frozen_mapping)
|
|
|
|
@field_validator("frontmatter", mode="before")
|
|
@classmethod
|
|
def parse_frontmatter_yaml(cls, value: SkillFrontmatter | str | None) -> SkillFrontmatter:
|
|
if isinstance(value, SkillFrontmatter):
|
|
return value
|
|
return SkillFrontmatter.from_raw_yaml(value)
|
|
|
|
@field_validator("references", mode="before")
|
|
@classmethod
|
|
def freeze_references(cls, value: Mapping[str, StoredSkillReference] | None) -> Mapping[str, StoredSkillReference]:
|
|
return frozen_mapping(value)
|
|
|
|
@model_validator(mode="after")
|
|
def validate_contract(self) -> "StoredSkill":
|
|
parts = self.relpath.parts
|
|
if len(parts) < 3 or parts[0] != "skills":
|
|
raise ValueError("skill relpath must be under skills/<slug>/")
|
|
|
|
skill_dir_name = parts[1]
|
|
if self.frontmatter.name != skill_dir_name:
|
|
raise ValueError("frontmatter name must exactly match skill directory name")
|
|
if self.frontmatter.x_personal_mcp.id != self.frontmatter.name:
|
|
raise ValueError("x-personal-mcp.id must exactly match name")
|
|
|
|
expected_capability = f"resource://skills/{self.frontmatter.name}/document"
|
|
if expected_capability not in self.frontmatter.x_personal_mcp.capabilities:
|
|
raise ValueError(f"capabilities must include {expected_capability}")
|
|
|
|
if self.skill_id != self.frontmatter.x_personal_mcp.id:
|
|
raise ValueError("skill_id must exactly match x-personal-mcp.id")
|
|
|
|
for ref_id, ref in self.references.items():
|
|
if ref.ref_id != ref_id:
|
|
raise ValueError(f"reference key must match ref_id: {ref_id}")
|
|
return self
|