#!/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]()`. 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 ") 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()