← Files AMDARCHIVED FILE

.github/scripts/test_cli_install.py

18.5 KB · Nov 12, 2026 · 18:37 UTC

↓ Download file

See the change to this file →

#!/usr/bin/env -S uv run --quiet
# /// script
# requires-python = ">=3.10"
# ///
"""Install this catalog with the `skills` CLI and check what lands on disk.

Everything else in CI reads the repo; this is the one check that goes through
`npx skills add`, the path users actually take. It catches what only shows up
after the CLI has discovered, cloned, copied, and linked the skills:

  discover     `add --list` finds exactly the skill folders under skills/, and
               groups the ones the plugin manifest publishes apart from the rest.
               Catches a nested SKILL.md (e.g. under evals/) leaking out as a
               skill, a skill the CLI cannot parse, or a duplicate folder.
  install      every skill installs for several agents; each installed folder
               holds exactly the git-tracked files of its source folder, with
               the same contents, and tooling files keep LF endings on every OS.
  reinstall    installing a second time succeeds and leaves the same tree.
  remove       removing one skill clears its folder, its agent links, and its
               lock entry, and leaves the others alone.
  global       `-g` writes under the user's home and nothing into the project.
  update       `update -y` succeeds on a remote install (local-path installs
               are not updatable, so this is skipped for them).

Run from the repo root, e.g.:

    uv run .github/scripts/test_cli_install.py --source .
    uv run .github/scripts/test_cli_install.py --source amd/skills --mode copy
    uv run .github/scripts/test_cli_install.py \\
        --source https://github.com/amd/skills/tree/<sha>/skills/local-ai-use \\
        --expect local-ai-use --scenarios install reinstall remove global update

`--source` is passed to the CLI as-is. The expected files are read from the git
checkout at `--repo-root`, so it must be the same revision the source resolves to.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent.parent
DEFAULT_CLI_VERSION = "1.7.0"

# Agents whose project folders are checked after an install. cursor and codex
# share the universal `.agents/skills` folder, which is also where the CLI keeps
# its canonical copy; claude-code and windsurf each get their own folder.
AGENT_DIRS = {
    "claude-code": ".claude/skills",
    "cursor": ".agents/skills",
    "codex": ".agents/skills",
    "windsurf": ".windsurf/skills",
}
CANONICAL_DIR = ".agents/skills"
GLOBAL_AGENT = "claude-code"
GLOBAL_AGENT_DIR = ".claude/skills"

# .gitattributes pins these to LF because they run on Linux; a CRLF copy breaks
# a shebang or a YAML parser on the user's machine.
LF_ONLY_SUFFIXES = {".sh", ".py", ".yml", ".yaml"}

ALL_SCENARIOS = ["discover", "install", "reinstall", "remove", "global", "update"]
ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07")
# The CLI draws its tree with `|` on a terminal and `│` when piped.
LIST_NAME = re.compile(r"^[|│]\s{4}([a-z0-9][a-z0-9._-]*)\s*$")
UNGROUPED_LABEL = "General"


class CheckFailed(Exception):
    pass


class Cli:
    """Runs one pinned `skills` CLI with telemetry off and an isolated home."""

    def __init__(self, version: str, work: Path) -> None:
        npx = shutil.which("npx")
        if not npx:
            raise SystemExit("npx not found on PATH; install Node.js first.")
        self.npx = npx
        self.package = f"skills@{version}"
        self.home = work / "home"
        self.home.mkdir(parents=True, exist_ok=True)
        real_home = Path.home()
        self.env = os.environ.copy()
        # The fake home isolates where skills land, but npm and git still need
        # the real proxy and CA settings to reach the registry and GitHub.
        for var, config in (
            ("npm_config_userconfig", real_home / ".npmrc"),
            ("GIT_CONFIG_GLOBAL", real_home / ".gitconfig"),
        ):
            if config.is_file() and var not in self.env:
                self.env[var] = str(config)
        self.env.update(
            {
                "NO_COLOR": "1",
                "CI": "1",
                "GIT_TERMINAL_PROMPT": "0",
                "HOME": str(self.home),
                "USERPROFILE": str(self.home),
                "npm_config_update_notifier": "false",
            }
        )
        if "npm_config_cache" not in os.environ:
            local = os.environ.get("LOCALAPPDATA")
            cache = Path(local) / "npm-cache" if os.name == "nt" and local else real_home / ".npm"
            self.env["npm_config_cache"] = str(cache)
        if version != "latest":
            self.env["npm_config_prefer_offline"] = "true"

    def run(self, cwd: Path, *args: str, timeout: int = 600) -> str:
        cmd = [self.npx, "-y", self.package, *args]
        print(f"    $ npx {self.package} {' '.join(args)}", flush=True)
        try:
            proc = subprocess.run(
                cmd,
                check=False,
                cwd=cwd,
                env=self.env,
                stdin=subprocess.DEVNULL,
                capture_output=True,
                text=True,
                encoding="utf-8",
                errors="replace",
                timeout=timeout,
            )
        except subprocess.TimeoutExpired as exc:
            raise CheckFailed(f"`{' '.join(args)}` timed out after {timeout}s") from exc
        out = ANSI.sub("", (proc.stdout or "") + (proc.stderr or ""))
        if proc.returncode != 0:
            tail = "\n".join(out.strip().splitlines()[-30:])
            raise CheckFailed(
                f"`{' '.join(args)}` exited {proc.returncode}:\n{tail}"
            )
        return out


def tracked_skills(repo_root: Path) -> dict[str, list[str]]:
    """Map each skill folder under skills/ to its git-tracked files."""
    out = subprocess.run(
        ["git", "-C", str(repo_root), "ls-files", "-z", "--", "skills"],
        check=True,
        capture_output=True,
    ).stdout.decode("utf-8")
    skills: dict[str, list[str]] = {}
    for path in filter(None, out.split("\0")):
        parts = path.split("/", 2)
        if len(parts) == 3:
            skills.setdefault(parts[1], []).append(parts[2])
    return {
        name: sorted(files)
        for name, files in sorted(skills.items())
        if "SKILL.md" in files
    }


def published_skills(repo_root: Path) -> set[str]:
    manifest = repo_root / ".claude-plugin" / "marketplace.json"
    data = json.loads(manifest.read_text(encoding="utf-8"))
    return {
        path.rstrip("/").rsplit("/", 1)[-1]
        for plugin in data.get("plugins", [])
        for path in plugin.get("skills", [])
    }


def parse_list(output: str) -> dict[str, str]:
    """Map skill name -> group label from `skills add <src> --list` output."""
    groups: dict[str, str] = {}
    group = UNGROUPED_LABEL
    listing = False
    for line in output.splitlines():
        stripped = line.strip()
        if not listing:
            listing = "Available Skills" in stripped
            continue
        match = LIST_NAME.match(line.rstrip())
        if match:
            groups[match.group(1)] = group
        elif stripped[:1].isalnum():
            group = stripped
    return groups


def files_under(root: Path) -> list[str]:
    return sorted(
        p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file()
    )


def check_tree(installed: Path, source: Path, expected: list[str]) -> list[str]:
    if not (installed / "SKILL.md").is_file():
        return [f"{installed}: no SKILL.md"]
    errors: list[str] = []
    actual = files_under(installed)
    missing = sorted(set(expected) - set(actual))
    extra = sorted(set(actual) - set(expected))
    if missing:
        errors.append(f"{installed}: missing {missing}")
    if extra:
        errors.append(f"{installed}: unexpected {extra}")
    for rel in sorted(set(expected) & set(actual)):
        got = (installed / rel).read_bytes()
        want = (source / rel).read_bytes()
        if got.replace(b"\r\n", b"\n") != want.replace(b"\r\n", b"\n"):
            errors.append(f"{installed / rel}: contents differ from the source")
        elif Path(rel).suffix in LF_ONLY_SUFFIXES and b"\r\n" in got:
            errors.append(f"{installed / rel}: CRLF line endings in a tooling file")
    return errors


def same_dir(a: Path, b: Path) -> bool:
    return os.path.realpath(a) == os.path.realpath(b)


class Suite:
    def __init__(self, args: argparse.Namespace) -> None:
        local = Path(args.source).expanduser()
        self.remote = not local.exists()
        # Each scenario runs in its own scratch project, so a relative path
        # would resolve against that project instead of the caller's cwd.
        self.source = args.source if self.remote else str(local.resolve())
        self.mode = args.mode
        self.agents = args.agent
        self.repo_root = args.repo_root.resolve()
        catalog = tracked_skills(self.repo_root)
        if not catalog:
            raise SystemExit(f"No tracked skills under {self.repo_root / 'skills'}")
        unknown = sorted(set(args.expect) - set(catalog))
        if unknown:
            raise SystemExit(f"--expect names skills not in the checkout: {unknown}")
        self.catalog = catalog
        self.expected = {n: catalog[n] for n in (args.expect or catalog)}
        self.whole_catalog = not args.expect
        self.work = Path(tempfile.mkdtemp(prefix="skills-cli-test-"))
        self.cli = Cli(args.cli_version, self.work)

    def project(self, name: str) -> Path:
        path = self.work / "projects" / name
        path.mkdir(parents=True)
        return path

    def add_args(self, *extra: str) -> list[str]:
        args = ["add", self.source, *extra]
        for agent in self.agents:
            args += ["-a", agent]
        if self.mode == "copy":
            args.append("--copy")
        return [*args, "-y"]

    def install_all(self, project: Path) -> None:
        self.cli.run(project, *self.add_args("--skill", "*"))

    def check_installed(self, project: Path, names: dict[str, list[str]]) -> None:
        errors: list[str] = []
        canonical_root = project / CANONICAL_DIR
        present = sorted(p.name for p in canonical_root.iterdir()) if canonical_root.is_dir() else []
        if sorted(names) != present:
            errors.append(
                f"{canonical_root}: expected skills {sorted(names)}, found {present}"
            )
        for name, files in names.items():
            source = self.repo_root / "skills" / name
            canonical = canonical_root / name
            errors += check_tree(canonical, source, files)
            for agent in self.agents:
                agent_dir = project / AGENT_DIRS[agent] / name
                if agent_dir == canonical:
                    continue
                if self.mode == "symlink" and not same_dir(agent_dir, canonical):
                    errors.append(f"{agent_dir}: not linked to {canonical}")
                errors += check_tree(agent_dir, source, files)
        lock = project / "skills-lock.json"
        if lock.is_file():
            locked = set(json.loads(lock.read_text(encoding="utf-8")).get("skills", {}))
            if locked != set(names):
                errors.append(
                    f"{lock}: lists {sorted(locked)}, expected {sorted(names)}"
                )
        else:
            errors.append(f"{lock}: not written")
        if errors:
            raise CheckFailed("\n".join(errors))

    def discover(self) -> str:
        if not self.whole_catalog:
            return "skipped: --expect narrows the source to part of the catalog"
        out = self.cli.run(self.project("discover"), "add", self.source, "--list")
        groups = parse_list(out)
        errors: list[str] = []
        found, want = set(groups), set(self.catalog)
        if found != want:
            errors.append(
                f"--list found {sorted(found)}; skills/ has {sorted(want)}"
                f" (missing {sorted(want - found)}, extra {sorted(found - want)})"
            )
        published = published_skills(self.repo_root)
        grouped = {n for n, g in groups.items() if g != UNGROUPED_LABEL}
        if grouped != published & found:
            errors.append(
                f"plugin group holds {sorted(grouped)}; the manifest publishes"
                f" {sorted(published)}"
            )
        if errors:
            raise CheckFailed("\n".join(errors))
        return f"{len(found)} skills, {len(grouped)} in the plugin group"

    def install(self) -> str:
        project = self.project("install")
        self.install_all(project)
        self.check_installed(project, self.expected)
        listed = self.cli.run(project, "list")
        missing = [n for n in self.expected if n not in listed]
        if missing:
            raise CheckFailed(f"`skills list` does not show {missing}")
        files = sum(len(f) for f in self.expected.values())
        return f"{len(self.expected)} skills, {files} files, agents {', '.join(self.agents)}"

    def reinstall(self) -> str:
        project = self.project("reinstall")
        self.install_all(project)
        self.install_all(project)
        self.check_installed(project, self.expected)
        return "second install left an identical tree"

    def remove(self) -> str:
        project = self.project("remove")
        self.install_all(project)
        target = min(self.expected)
        self.cli.run(project, "remove", target, "-y")
        leftovers = [
            str(project / d / target)
            for d in {CANONICAL_DIR, *(AGENT_DIRS[a] for a in self.agents)}
            if os.path.lexists(project / d / target)
        ]
        if leftovers:
            raise CheckFailed(f"`remove {target}` left {leftovers}")
        rest = {n: f for n, f in self.expected.items() if n != target}
        if rest:
            self.check_installed(project, rest)
        return f"removed {target}, {len(rest)} left intact"

    def global_install(self) -> str:
        project = self.project("global")
        target = min(self.expected)
        args = ["add", self.source, "--skill", target, "-a", GLOBAL_AGENT, "-g"]
        if self.mode == "copy":
            args.append("--copy")
        self.cli.run(project, *args, "-y")
        installed = self.cli.home / GLOBAL_AGENT_DIR / target
        errors = check_tree(installed, self.repo_root / "skills" / target, self.expected[target])
        written = sorted(p.name for p in project.iterdir())
        if written:
            errors.append(f"`-g` also wrote into the project: {written}")
        if errors:
            raise CheckFailed("\n".join(errors))
        return f"{target} under ~/{GLOBAL_AGENT_DIR}"

    def update(self) -> str:
        if not self.remote:
            return "skipped: local-path installs are not updatable"
        project = self.project("update")
        self.install_all(project)
        self.cli.run(project, "update", "-y")
        self.check_installed(project, self.expected)
        return "update ran clean against the installed revision"

    def run(self, scenarios: list[str]) -> int:
        steps = {
            "discover": self.discover,
            "install": self.install,
            "reinstall": self.reinstall,
            "remove": self.remove,
            "global": self.global_install,
            "update": self.update,
        }
        print(f"source={self.source} mode={self.mode} cli={self.cli.package}")
        print(f"checking against {self.repo_root} ({len(self.expected)} skills)\n")
        try:
            self.cli.run(self.work, "--version", timeout=300)
        except CheckFailed as exc:
            print(f"The CLI did not start, so no scenario can run:\n{exc}")
            return 1
        results: list[tuple[str, bool, str, float]] = []
        for name in scenarios:
            print(f"[{name}]", flush=True)
            start = time.monotonic()
            try:
                detail = steps[name]()
                ok = True
            except CheckFailed as exc:
                detail, ok = str(exc), False
            elapsed = time.monotonic() - start
            results.append((name, ok, detail, elapsed))
            print(f"  {'PASS' if ok else 'FAIL'} ({elapsed:.0f}s) {detail}\n", flush=True)
        shutil.rmtree(self.work, ignore_errors=True)
        self.summarize(results)
        failed = [r[0] for r in results if not r[1]]
        print(f"Summary: {len(results) - len(failed)}/{len(results)} passed"
              + (f"; failed: {', '.join(failed)}" if failed else ""))
        return 1 if failed else 0

    def summarize(self, results: list[tuple[str, bool, str, float]]) -> None:
        path = os.environ.get("GITHUB_STEP_SUMMARY")
        if not path:
            return
        lines = [
            f"### `skills add {self.source}` ({self.mode}, {self.cli.package})",
            "",
            "| Scenario | Result | Time | Detail |",
            "|---|---|---|---|",
        ]
        for name, ok, detail, elapsed in results:
            first = detail.splitlines()[0] if detail else ""
            lines.append(
                f"| {name} | {'pass' if ok else '**fail**'} | {elapsed:.0f}s"
                f" | {first.replace('|', '/')} |"
            )
        with open(path, "a", encoding="utf-8") as fh:
            fh.write("\n".join(lines) + "\n\n")


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument("--source", required=True,
                        help="Source passed to `skills add` (path, owner/repo, or URL).")
    parser.add_argument("--repo-root", type=Path, default=REPO_ROOT,
                        help="Git checkout at the revision the source resolves to.")
    parser.add_argument("--mode", choices=["symlink", "copy"], default="symlink")
    parser.add_argument("--cli-version",
                        default=os.environ.get("SKILLS_CLI_VERSION", DEFAULT_CLI_VERSION),
                        help="`skills` npm version, or `latest`.")
    parser.add_argument("--agent", action="append", choices=sorted(AGENT_DIRS),
                        help="Agents to install for (repeatable; default: all four).")
    parser.add_argument("--expect", action="append", default=[],
                        help="Skill the source should yield (repeatable; default: all).")
    parser.add_argument("--scenarios", nargs="+", choices=ALL_SCENARIOS,
                        default=ALL_SCENARIOS)
    args = parser.parse_args(argv)
    args.agent = args.agent or list(AGENT_DIRS)
    for stream in (sys.stdout, sys.stderr):
        stream.reconfigure(encoding="utf-8", errors="replace")
    return Suite(args).run(args.scenarios)


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 90c76c0dbf0ed4f8148fcc751433c9634047e10fb97308b502f135c7ed6a4c35