Ctrl K
ComunidadGuías…

scripts/audit_skill_commands.py

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Keep the published agent skill in step with both CLIs.

skills/mole tells other people's agents which `mo` commands and flags exist.
A flag that was renamed or never shipped sends those agents into a rejected
command at best and an improvised workaround at worst, so every `mo <command>
--flag` the skill names must still appear in that command's source:
SKILL.md against both CLIs, references/macos.md against this branch, and
references/windows.md against the Windows CLI read from a Git ref (default
origin/windows). Missing that ref skips the Windows side with a notice unless
--require-windows is passed, which CI does after fetching the branch.

Codex installs plugins by copying files and drops symbolic links, so
plugins/mole/skills/mole is a real copy of skills/mole; it must match byte for
byte.

A `version` in a Claude plugin manifest keeps users on their cached copy until
that string changes, and npm refuses to republish a version, so a skill change
must raise the one version plugins/mole/package.json and its Claude manifest
share. The version must differ from the one just before the latest skill commit.
The Check job deepens its shallow clone for this, since a commit whose parent is
out of reach cannot be judged and passes.
"""

from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from pathlib import Path


PROJECT_ROOT = Path(__file__).resolve().parents[1]
CATALOG_DESCRIPTION_LIMIT = 500
INLINE_CODE = re.compile(r"`([^`\n]+)`")
STOP_TOKENS = {"|", "||", "&&", ";", "#", "or", "then"}

MACOS_SOURCES = {
    "analyze": ["cmd/analyze/main.go", "cmd/analyze/json.go"],
    "status": ["cmd/status/main.go"],
    "update": ["lib/manage/update.sh"],
    "remove": ["lib/manage/remove.sh"],
}
WINDOWS_SOURCES = {
    "analyze": ["bin/analyze.ps1", "cmd/analyze/cli.go"],
    "status": ["bin/status.ps1", "cmd/status/main.go"],
}


def mo_commands(text: str) -> list[tuple[int, str, list[str]]]:
    """Return (line, command, flags) for every `mo ...` invocation in Markdown."""
    found: list[tuple[int, str, list[str]]] = []
    in_block = False
    for number, line in enumerate(text.splitlines(), 1):
        if line.lstrip().startswith("```"):
            in_block = not in_block
            continue
        snippets = [line.strip()] if in_block else INLINE_CODE.findall(line)
        for snippet in snippets:
            # mo(?![\w-]) keeps "mole" and "mo-foo" from counting as commands.
            for match in re.finditer(r"(?:^|[\s(|])mo(?![\w-])((?:\s+\S+)*)", snippet):
                command = ""
                flags: list[str] = []
                for token in match.group(1).split():
                    if token in STOP_TOKENS or token.startswith("#"):
                        break
                    if token.startswith("--"):
                        flags.append(token.split("=", 1)[0].rstrip(".,)"))
                    elif not command and not flags and re.fullmatch(r"[a-z]+", token):
                        command = token
                found.append((number, command, flags))
    return found


def read_ref_file(ref: str, path: str) -> str | None:
    result = subprocess.run(
        ["git", "-C", str(PROJECT_ROOT), "show", f"{ref}:{path}"],
        capture_output=True,
        text=True,
    )
    return result.stdout if result.returncode == 0 else None


class Platform:
    def __init__(self, name: str, reader, router: str, router_pattern: str, script: str, extra: dict):
        self.name = name
        self.reader = reader
        router_text = reader(router) or ""
        self.commands = {
            word
            for label in re.findall(router_pattern, router_text, re.MULTILINE)
            for word in re.findall(r'"([a-z]+)"', label)
        }
        self.router_text = router_text
        self.script = script
        self.extra = extra

    def source(self, command: str) -> str:
        if not command:
            return self.router_text
        paths = [self.script.format(command)] + self.extra.get(command, [])
        text = "\n".join(self.reader(path) or "" for path in paths)
        # A flag named only in a comment is not a flag the command accepts.
        return "\n".join(
            line for line in text.splitlines() if not line.lstrip().startswith(("#", "//"))
        )

    def problems(self, command: str, flags: list[str]) -> list[str]:
        if command and command not in self.commands:
            return [f"`mo {command}` is not a {self.name} command"]
        text = self.source(command)
        return [
            f"`mo {command} {flag}`".replace("  ", " ") + f" is not in the {self.name} source"
            for flag in flags
            if not re.search(re.escape(flag) + r"(?![\w-])", text)
        ]


def macos_platform() -> Platform:
    def reader(path: str) -> str | None:
        file = PROJECT_ROOT / path
        return file.read_text(encoding="utf-8") if file.is_file() else None

    return Platform(
        "macOS",
        reader,
        "mole",
        # Case labels in the dispatcher, plus history, which dispatches early.
        r'(?:^\s*|MOLE_CLI_ARGS\[0\]:-\}" == )("[a-z]+"(?:\s*\|\s*"[a-z]+")*)(?:\)|\s*\]\])',
        "bin/{}.sh",
        MACOS_SOURCES,
    )


def windows_platform(ref: str) -> Platform | None:
    if read_ref_file(ref, "mole.ps1") is None:
        return None
    return Platform(
        "Windows",
        lambda path: read_ref_file(ref, path),
        "mole.ps1",
        r"\$validCommands = @\(([^)]*)\)",
        "bin/{}.ps1",
        WINDOWS_SOURCES,
    )


def copy_problems(source: Path, copy: Path) -> list[str]:
    def files(root: Path) -> dict[str, bytes]:
        return {
            str(path.relative_to(root)): path.read_bytes()
            for path in sorted(root.rglob("*"))
            if path.is_file()
        }

    if not copy.is_dir():
        return [f"{copy}: missing"]
    expected, actual = files(source), files(copy)
    stale = sorted(name for name in expected.keys() | actual.keys() if expected.get(name) != actual.get(name))
    if not stale:
        return []
    fix = f"rm -rf {copy} && cp -R {source} {copy}"
    return [f"{copy}/{name}: differs from {source}" for name in stale] + [f"fix with: {fix}"]


def catalog_problems(skill_md: Path) -> list[str]:
    """plugins/mole/index.js joins these two fields; DeepSeek Harness cuts the result at 500 characters."""
    text = skill_md.read_text(encoding="utf-8")
    fields = [re.search(rf'^{key}: "(.*)"$', text, re.M) for key in ("description", "when_to_use")]
    if not all(fields):
        return [f"{skill_md}: description and when_to_use must be single-line quoted strings"]
    length = sum(len(field.group(1)) for field in fields) + len(" Triggers: ")
    if length > CATALOG_DESCRIPTION_LIMIT:
        return [f"{skill_md}: description and when_to_use join to {length} characters, over {CATALOG_DESCRIPTION_LIMIT}"]
    return []


def version_problems(root: Path) -> list[str]:
    package = "plugins/mole/package.json"
    manifest = "plugins/mole/.claude-plugin/plugin.json"
    versions = [json.loads((root / path).read_text(encoding="utf-8")).get("version") for path in (package, manifest)]
    if versions[0] is None or versions[0] != versions[1]:
        return [f"{package} has version {versions[0]} but {manifest} has {versions[1]}; they must match"]

    def git(*args: str) -> subprocess.CompletedProcess[str]:
        return subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True)

    skill_commit = git("log", "-1", "--format=%H", "--", "skills/mole").stdout.strip()
    before = git("show", f"{skill_commit}^:{manifest}") if skill_commit else None
    if before is None or before.returncode != 0 or json.loads(before.stdout).get("version") != versions[1]:
        return []
    return [
        f"skills/mole changed in {skill_commit[:8]} without a plugin version bump: "
        f"raise version in {package} and {manifest}, then npm publish from plugins/mole"
    ]


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--skill-dir", type=Path, default=PROJECT_ROOT / "skills" / "mole")
    parser.add_argument("--windows-ref", default="origin/windows")
    parser.add_argument("--require-windows", action="store_true")
    parser.add_argument("--plugin-copy", type=Path)
    args = parser.parse_args()
    if args.plugin_copy is None and args.skill_dir == PROJECT_ROOT / "skills" / "mole":
        args.plugin_copy = PROJECT_ROOT / "plugins" / "mole" / "skills" / "mole"

    macos = macos_platform()
    windows = windows_platform(args.windows_ref)
    if windows is None:
        if args.require_windows:
            print(f"Windows CLI ref {args.windows_ref} is not available", file=sys.stderr)
            return 1
        print(f"skill-commands-audit: {args.windows_ref} not available, Windows side skipped")

    targets = {
        "SKILL.md": [macos, windows],
        "references/macos.md": [macos],
        "references/windows.md": [windows],
    }
    failures: list[str] = []
    checked = 0
    for relative, platforms in targets.items():
        path = args.skill_dir / relative
        if not path.is_file():
            failures.append(f"{path}: missing")
            continue
        for line, command, flags in mo_commands(path.read_text(encoding="utf-8")):
            for platform in platforms:
                if platform is None:
                    continue
                checked += 1
                for problem in platform.problems(command, flags):
                    failures.append(f"{path}:{line}: {problem}")

    if args.plugin_copy is not None:
        failures += copy_problems(args.skill_dir, args.plugin_copy)
    if (args.skill_dir / "SKILL.md").is_file():
        failures += catalog_problems(args.skill_dir / "SKILL.md")
    if args.skill_dir == PROJECT_ROOT / "skills" / "mole":
        failures += version_problems(PROJECT_ROOT)

    if failures:
        print("\n".join(failures))
        return 1
    if checked == 0:
        print("skill-commands-audit: no `mo` commands found; the parser is broken", file=sys.stderr)
        return 1
    print(f"skill-commands-audit-ok ({checked} command checks)")
    return 0


if __name__ == "__main__":
    sys.exit(main())