Ctrl K
ComunidadGuías…

scripts/validate_skills.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Validate Basic Memory SKILL.md source directories.

Checks every ``skills/memory-*/SKILL.md`` against the rules the hosts that load
or upload our skills enforce, and keeps the hand-maintained skill lists in the
repo in sync with the skill directories themselves.
"""

from __future__ import annotations

import argparse
import json
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path


PLAIN_SCALAR_MAPPING_VALUE = re.compile(r":(?:\s|$)")
BLOCK_SCALAR_INDICATORS = {"|", ">", "|-", ">-", "|+", ">+"}

# Agent Skills spec: lowercase letters, digits, and single hyphens, at most 64
# characters. Claude's uploader also rejects names containing reserved words.
SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
MAX_NAME_LENGTH = 64
RESERVED_NAME_WORDS = ("anthropic", "claude")
# The spec allows 1024 characters, but Claude's skill upload (help center) caps
# descriptions at 200, and we ship the same SKILL.md everywhere.
MAX_DESCRIPTION_LENGTH = 200

# A Markdown link destination, with an optional "title" or 'title' after it.
MARKDOWN_LINK = re.compile(r"""\]\(([^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'))?\)""")
# Backticked paths into a skill's own resource folders, e.g. `references/x.md`.
# A reference-style link definition, `[label]: target`. Nothing rewrites these
# when the combined skill moves a resource, so the validator rejects them.
LINK_DEFINITION = re.compile(r"^ {0,3}\[[^\]]+\]:\s*\S", re.MULTILINE)
# An angle-bracket destination, `[text](<path with spaces.md>)`. Neither the link
# check nor the combined skill's rewriter reads this form, so it is rejected.
ANGLE_LINK = re.compile(r"\]\(\s*<")
CODE_RESOURCE_PATH = re.compile(r"`((?:references|assets|scripts)/[^`\s]+)`")
METADATA_INTERNAL = re.compile(r"^\s+internal:\s*true\s*(?:#.*)?$")


def strip_matching_quotes(value: str) -> str:
    if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
        return value[1:-1]
    return value


def validate_plain_scalar(path: Path, line_number: int, key: str, value: str) -> None:
    """Catch invalid plain-scalar YAML that Codex rejects while loading skills."""
    stripped = value.strip()
    if not stripped or stripped[0] in {'"', "'"} or stripped in BLOCK_SCALAR_INDICATORS:
        return
    if PLAIN_SCALAR_MAPPING_VALUE.search(stripped):
        raise SystemExit(
            f"{path}:{line_number}: invalid YAML frontmatter for {key!r}: "
            "unquoted ':' followed by whitespace; quote the value"
        )


def frontmatter_lines(path: Path) -> list[str]:
    """Return the raw lines between the opening and closing `---` fences."""
    lines = path.read_text(encoding="utf-8").splitlines()
    if not lines or lines[0] != "---":
        raise SystemExit(f"{path}: missing YAML frontmatter")
    for index, line in enumerate(lines[1:], start=1):
        if line == "---":
            return lines[1:index]
    raise SystemExit(f"{path}: unclosed YAML frontmatter")


def parse_frontmatter(path: Path) -> dict[str, str]:
    """Extract top-level frontmatter keys from a Markdown file.

    A deliberately minimal parser (no PyYAML — this runs under bare `python3` in
    CI). It only captures **top-level** `key: value` lines. Indented lines are
    skipped, so nested blocks (a schema note's `schema:`/`settings:` children) can't
    overwrite a top-level key like `type` or `entity` via last-write-wins. It does
    not interpret block scalars or multi-line values; callers rely on single-line
    top-level fields (name, description, type, entity). Keep the Codex-facing YAML
    guard here dependency-free so package checks work under bare `python3`.
    """
    frontmatter: dict[str, str] = {}
    for line_number, line in enumerate(frontmatter_lines(path), start=2):
        if line[:1] in (" ", "\t"):  # nested key — not a top-level field
            continue
        if ":" not in line:
            continue
        key, value = line.split(":", 1)
        key = key.strip()
        value = value.strip()
        validate_plain_scalar(path, line_number, key, value)
        frontmatter[key] = strip_matching_quotes(value)
    return frontmatter


def is_internal_skill(skill_file: Path) -> bool:
    """True when frontmatter carries `metadata:` with a nested `internal: true`.

    This is the marker the `skills` CLI (vercel-labs/skills) uses to hide a skill
    from discovery unless INSTALL_INTERNAL_SKILLS=1, so one flag keeps a skill out
    of both `npx skills add` and our release archives.
    """
    in_metadata = False
    child_indent: int | None = None
    for line in frontmatter_lines(skill_file):
        stripped = line.strip()
        # YAML keeps a mapping open across blank and comment lines.
        if not stripped or stripped.startswith("#"):
            continue
        if line[:1] not in (" ", "\t"):
            in_metadata = line.split(":", 1)[0].strip() == "metadata"
            child_indent = None
            continue
        if not in_metadata:
            continue
        # Only a direct child counts: metadata.sub.internal is not the marker.
        indent = len(line) - len(line.lstrip())
        if child_indent is None:
            child_indent = indent
        if indent == child_indent and METADATA_INTERNAL.match(line):
            return True
    return False


def skill_body(skill_file: Path) -> str:
    """SKILL.md content after the frontmatter block."""
    text = skill_file.read_text(encoding="utf-8")
    _, _, body = text.partition("\n---\n")
    return body


def relative_link_targets(markdown: str) -> list[str]:
    """Relative file paths a skill points at: Markdown links and backticked resources.

    External URLs, in-page anchors, and site-absolute paths are not files in the
    skill folder, so they are skipped. Anchors on file links are dropped.
    """
    targets: list[str] = []
    for target in MARKDOWN_LINK.findall(markdown):
        if target.startswith(("#", "/")) or re.match(r"^[a-z][a-z0-9+.-]*:", target):
            continue
        targets.append(target.split("#", 1)[0])
    targets.extend(CODE_RESOURCE_PATH.findall(markdown))
    return targets


def memory_skill_dirs(skills_root: Path) -> list[Path]:
    if not skills_root.exists():
        raise SystemExit(f"Skills directory not found: {skills_root}")
    skill_dirs = sorted(path for path in skills_root.glob("memory-*") if path.is_dir())
    if not skill_dirs:
        raise SystemExit(f"No memory-* skill directories found in {skills_root}")
    return skill_dirs


def distributed_skill_names(skills_root: Path) -> set[str]:
    """Skills we publish: every memory-* directory not marked internal."""
    return {
        skill_dir.name
        for skill_dir in memory_skill_dirs(skills_root)
        if not is_internal_skill(skill_dir / "SKILL.md")
    }


def validate_name(skill_file: Path, name: str) -> None:
    if len(name) > MAX_NAME_LENGTH or not SKILL_NAME_PATTERN.match(name):
        raise SystemExit(
            f"{skill_file}: name {name!r} must be lowercase letters, digits, and single "
            f"hyphens, at most {MAX_NAME_LENGTH} characters"
        )
    for word in RESERVED_NAME_WORDS:
        if word in name:
            raise SystemExit(f"{skill_file}: name {name!r} must not contain {word!r}")


def validate_description(skill_file: Path, description: str | None) -> None:
    if not description:
        raise SystemExit(f"{skill_file}: missing description")
    if description in BLOCK_SCALAR_INDICATORS:
        raise SystemExit(
            f"{skill_file}: description must be a single-line string, not a block scalar"
        )
    if len(description) > MAX_DESCRIPTION_LENGTH:
        raise SystemExit(
            f"{skill_file}: description is {len(description)} characters; "
            f"the limit is {MAX_DESCRIPTION_LENGTH}"
        )
    if "<" in description or ">" in description:
        raise SystemExit(f"{skill_file}: description must not contain '<' or '>'")


def validate_skill_file(skill_file: Path, expected_name: str) -> None:
    """Validate one SKILL.md: frontmatter rules plus links into its own folder."""
    frontmatter = parse_frontmatter(skill_file)
    name = frontmatter.get("name")
    if name != expected_name:
        raise SystemExit(f"{skill_file}: name {name!r} does not match directory")
    validate_name(skill_file, name)
    validate_description(skill_file, frontmatter.get("description"))

    # Each zip holds only the skill's own folder, so a link that leaves it (for
    # example ../README.md) breaks in the published skill even when the target
    # exists in the repo.
    body = skill_body(skill_file)
    if ANGLE_LINK.search(body):
        raise SystemExit(f"{skill_file}: use plain link destinations, not <angle brackets>")
    if definition := LINK_DEFINITION.search(body):
        raise SystemExit(
            f"{skill_file}: use inline links, not reference-style definitions "
            f"({definition.group(0).strip()!r})"
        )
    skill_root = skill_file.parent.resolve()
    for target in relative_link_targets(body):
        resolved = (skill_file.parent / target).resolve()
        if not resolved.is_relative_to(skill_root):
            raise SystemExit(f"{skill_file}: links outside the skill folder {target!r}")
        if not resolved.is_file():
            raise SystemExit(f"{skill_file}: links to missing file {target!r}")


def validate_skills(skills_root: Path) -> None:
    skill_dirs = memory_skill_dirs(skills_root)
    for skill_dir in skill_dirs:
        skill_file = skill_dir / "SKILL.md"
        if not skill_file.exists():
            raise SystemExit(f"{skill_dir}: missing SKILL.md")
        validate_skill_file(skill_file, skill_dir.name)

    print(f"validated {len(skill_dirs)} skills in {skills_root}")


# --- Skill lists kept by hand elsewhere in the repo ---


def _pattern_extractor(pattern: str) -> Callable[[str], set[str]]:
    compiled = re.compile(pattern, re.MULTILINE)
    return lambda text: set(compiled.findall(text))


def _openclaw_manifest_skills(text: str) -> set[str]:
    return {Path(entry).name for entry in json.loads(text)["skills"]}


@dataclass(frozen=True)
class SkillList:
    """A hand-maintained list of skills that must match the skill directories."""

    path: str  # relative to the repo root
    extract: Callable[[str], set[str]]
    # Skills this list may omit. Every entry needs a comment saying why.
    may_omit: dict[str, str] = field(default_factory=dict)
    # Developer docs may also list internal skills; user-facing lists may not.
    allows_internal: bool = False


SKILL_LISTS = (
    SkillList("skills/README.md", _pattern_extractor(r"^\| \*\*(memory-[a-z0-9-]+)\*\* \|")),
    SkillList(
        "skills/CLAUDE.md",
        _pattern_extractor(r"^(memory-[a-z0-9-]+)/SKILL\.md"),
        allows_internal=True,
    ),
    SkillList(
        "integrations/openclaw/openclaw.plugin.json",
        _openclaw_manifest_skills,
        may_omit={
            # OpenClaw's tool wrappers drop the `source`/`external_id` and
            # checksum fields memory-quest needs, so OpenClaw users couldn't
            # finish it. A follow-up fixes the wrappers and adds it here.
            "memory-quest": "OpenClaw wrappers lack fields the skill needs",
            # These call tools OpenClaw doesn't register (recent_activity,
            # list_directory) or a metadata-only search_notes, which OpenClaw's
            # schema rejects without `query`. List them once OpenClaw has the tools.
            "memory-capture": "needs a query-less search_notes",
            "memory-continue": "needs recent_activity",
            "memory-curate": "needs list_directory",
            # Agents run in Basic Memory Cloud; OpenClaw registers no agent tools.
            "memory-agents": "needs the Cloud agent tools",
            # Doctor is a CLI command; OpenClaw agents have no `bm` shell.
            "memory-doctor": "needs the bm CLI",
        },
    ),
    SkillList(
        "integrations/openclaw/README.md",
        _pattern_extractor(r"^- \*\*(memory-[a-z0-9-]+)\*\* —"),
        may_omit={
            # Not in the OpenClaw manifest yet either (see above); list it here
            # when the manifest gains it.
            "memory-quest": "not shipped for OpenClaw yet",
            "memory-capture": "not shipped for OpenClaw yet",
            "memory-continue": "not shipped for OpenClaw yet",
            "memory-curate": "not shipped for OpenClaw yet",
            "memory-agents": "not shipped for OpenClaw yet",
            "memory-doctor": "not shipped for OpenClaw yet",
        },
    ),
)


def validate_skill_lists(repo_root: Path, skill_lists: tuple[SkillList, ...] = SKILL_LISTS) -> None:
    skills_root = repo_root / "skills"
    all_skills = {skill_dir.name for skill_dir in memory_skill_dirs(skills_root)}
    distributed = distributed_skill_names(skills_root)
    internal = all_skills - distributed

    for skill_list in skill_lists:
        listed = skill_list.extract((repo_root / skill_list.path).read_text(encoding="utf-8"))
        missing = distributed - listed - set(skill_list.may_omit)
        allowed_extra = internal if skill_list.allows_internal else set()
        unexpected = listed - distributed - allowed_extra
        if missing:
            raise SystemExit(f"{skill_list.path}: missing skills {sorted(missing)}")
        if unexpected:
            raise SystemExit(
                f"{skill_list.path}: lists unknown or internal skills {sorted(unexpected)}"
            )

    print(f"skill lists match {len(distributed)} distributed skills")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("skills_root", nargs="?", default="skills")
    args = parser.parse_args()
    skills_root = (Path.cwd() / args.skills_root).resolve()
    validate_skills(skills_root)
    validate_skill_lists(skills_root.parent)


if __name__ == "__main__":
    main()