#!/usr/bin/env python3 """Проверка фронтматтеров скиллов и charter'ов этого репозитория и описаний плагинов. Фронтматтер — единственная часть скилла, которую читает не человек, а загрузчик: по `name` он разрешает вызов, по `description` решает, звать ли скилл вообще. Ошибка здесь не выглядит ошибкой. Текст остаётся читаемым, `git diff` показывает разумную строку, а скилл либо не находится по имени, либо загружается с обрезанным описанием и потому не срабатывает на своих же триггерах. Ловится четыре класса. **Двоеточие с пробелом в описании без кавычек.** В YAML `: ` внутри простого скаляра начинает вложенное отображение — строка «конвейер ревью: гейт, сверка…» это не текст с двоеточием, а синтаксическая ошибка. Так были написаны три описания из четырнадцати; заметить это чтением нельзя, потому что читается оно правильно. **Имя, разошедшееся с каталогом.** Скилл зовётся по имени каталога, а `name` внутри — то, чем он представляется. Разъехались — вызов не разрешается, и сообщение об этом говорит «нет такого скилла», а не «имя не то». **Цвет, не отвечающий модели.** Цвет charter'а кодирует **модель**, на которой идёт проход, а не его роль: раскладка — в `av-dev/skills/code-review/SKILL.md`, раздел «Модель по проходу». Правило существует ровно затем, чтобы стоимость прогона читалась взглядом по списку агентов, и держаться вниманием оно не может: цвет ставится один раз при заведении charter'а, а модель потом меняется калибровкой. **Описание плагина, разошедшееся между манифестами.** У описания два дома: `<плагин>/.claude-plugin/plugin.json` его показывает установленному плагину, корневой `.claude-plugin/marketplace.json` — тому, кто выбирает, ставить ли. Правят обычно один, и разойтись они успели уже трижды из четырёх. `copies.py` этот класс не берёт: он смотрит markdown, а манифест — json. Коды выхода — тот же словарь, что у tasks.py, docs.py, copies.py и diagrams.py: 0 все фронтматтеры и описания в порядке 1 расхождение 2 ошибка употребления: аргументы 3 окружение: не тот каталог 4 внутренний сбой """ from __future__ import annotations import argparse import json import sys from pathlib import Path OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4 # Дом раскладки — «Модель по проходу» в av-dev/skills/code-review/SKILL.md; здесь её # механизация. Порядок цветов — порядок стоимости прогона. PALETTE = {"sonnet": "green", "opus": "yellow"} SKILL_KEYS = {"name", "description"} AGENT_KEYS = {"name", "description", "tools", "model", "color"} class Sheet: """Разобранный фронтматтер одного файла.""" def __init__(self, path: Path, root: Path) -> None: self.path = path self.where = path.relative_to(root).as_posix() self.fields: dict[str, str] = {} self.problems: list[str] = [] # Разбор дошёл до полей. Ложь — фронтматтера нет вовсе, и спрашивать с # него имя, набор полей и цвет бессмысленно: ответ будет один и тот же. self.parsed = False self._parse() def _parse(self) -> None: lines = self.path.read_text(encoding="utf-8").splitlines() if not lines or lines[0].strip() != "---": self.problems.append("нет фронтматтера: первая строка не `---`") return try: end = lines.index("---", 1) except ValueError: self.problems.append("фронтматтер не закрыт строкой `---`") return self.parsed = True for number, line in enumerate(lines[1:end], start=2): if not line.strip(): continue key, sep, value = line.partition(":") if not sep or not key or key != key.strip(): self.problems.append(f"строка {number}: не `ключ: значение`") continue value = value.strip() self.fields[key] = value if value[:1] in ('"', "'"): continue if ": " in value: self.problems.append( f"строка {number}: у `{key}` двоеточие с пробелом в значении" f" без кавычек — для YAML это вложенное отображение," f" а не текст. Обернуть значение в двойные кавычки" ) def check(self, expected_name: str, required: set[str]) -> None: missing = sorted(required - self.fields.keys()) if missing: self.problems.append(f"нет обязательных полей: {', '.join(missing)}") name = self.fields.get("name", "").strip("\"'") if name and name != expected_name: self.problems.append( f"`name: {name}` разошлось с ожидаемым `{expected_name}`" f" — вызов разрешается по второму" ) model = self.fields.get("model", "").strip("\"'") color = self.fields.get("color", "").strip("\"'") if model and color: if model not in PALETTE: self.problems.append( f"модель `{model}` не в раскладке цветов" f" ({', '.join(sorted(PALETTE))}) — назначить ей цвет" f" в «Модель по проходу» и здесь" ) elif color != PALETTE[model]: self.problems.append( f"цвет `{color}` не отвечает модели `{model}`:" f" по раскладке — `{PALETTE[model]}`" ) def collect(root: Path) -> list[tuple[Sheet, str, set[str]]]: """Все фронтматтеры репозитория: лист, ожидаемое имя, обязательные поля.""" found: list[tuple[Sheet, str, set[str]]] = [] for plugin in sorted(root.glob("av-*/")): for skill in sorted(plugin.glob("skills/*/SKILL.md")): found.append((Sheet(skill, root), skill.parent.name, SKILL_KEYS)) for agent in sorted(plugin.glob("agents/*.md")): found.append((Sheet(agent, root), agent.stem, AGENT_KEYS)) return found def manifests(root: Path) -> list[tuple[str, list[str]]]: """Описание каждого плагина: `plugin.json` против `marketplace.json`.""" market = root / ".claude-plugin" / "marketplace.json" where = market.relative_to(root).as_posix() try: listed = { str(entry.get("name", "")): str(entry.get("description", "")) for entry in json.loads(market.read_text(encoding="utf-8"))["plugins"] } except (OSError, ValueError, KeyError, TypeError) as e: return [(where, [f"манифест маркетплейса не разбирается: {e}"])] found: list[tuple[str, list[str]]] = [] for plugin in sorted(root.glob("av-*/")): card = plugin / ".claude-plugin" / "plugin.json" rel = card.relative_to(root).as_posix() try: own = json.loads(card.read_text(encoding="utf-8")) except (OSError, ValueError) as e: found.append((rel, [f"манифест плагина не разбирается: {e}"])) continue name = str(own.get("name", plugin.name)) if name not in listed: found.append((rel, [ f"плагина `{name}` нет в {where} — маркетплейс его не отдаёт" ])) elif str(own.get("description", "")) != listed[name]: found.append((rel, [ f"`description` разошлось с записью `{name}` в {where}:" f" у описания один текст на два манифеста, и правят обычно один" ])) return found def main() -> int: ap = argparse.ArgumentParser(description="Проверка фронтматтеров.") ap.add_argument("--dir", default=".", help="корень репозитория") args = ap.parse_args() root = Path(args.dir).resolve() if not (root / ".claude-plugin").is_dir(): print(f"окружение: {root} не похож на корень репозитория" f" (нет .claude-plugin)", file=sys.stderr) return ENV sheets = collect(root) if not sheets: print("окружение: не нашлось ни одного SKILL.md или charter'а", file=sys.stderr) return ENV for sheet, expected, required in sheets: if sheet.parsed: sheet.check(expected, required) cards = manifests(root) plugins = len(list(root.glob("av-*/"))) skills = sum(1 for _, _, required in sheets if required is SKILL_KEYS) print(f"фронтматтеров {len(sheets)}: скиллов {skills}," f" charter'ов {len(sheets) - skills}") print(f"манифестов плагинов {plugins}: описание сверено с marketplace.json") broken = [sheet for sheet, _, _ in sheets if sheet.problems] if broken or cards: print() for sheet in broken: for problem in sheet.problems: print(f"ОШИБКА {sheet.where}\n {problem}") for rel, problems in cards: for problem in problems: print(f"ОШИБКА {rel}\n {problem}") print(f"\nИтог: с ошибками {len(broken) + len(cards)}" f" из {len(sheets) + plugins}.") return DRIFT print("все в порядке") return OK if __name__ == "__main__": try: sys.exit(main()) except KeyboardInterrupt: sys.exit(INTERNAL) except Exception as e: # noqa: BLE001 — последний рубеж, код 4 по словарю print(f"внутренний сбой ({type(e).__name__}): {e}", file=sys.stderr) sys.exit(INTERNAL)