Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: CI

on:
pull_request:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
validate:
name: Validate and test
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- name: Check out repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: requirements-dev.txt

- name: Install CI dependencies
run: python -m pip install --requirement requirements-dev.txt

- name: Lint Python
run: ruff check .

- name: Check Python formatting
run: ruff format --check .

- name: Validate repository files
run: python scripts/check_repository.py

- name: Run tests
run: python -m unittest discover --start-directory tests --verbose
2 changes: 2 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
PyYAML==6.0.3
ruff==0.15.22
5 changes: 5 additions & 0 deletions ruff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
target-version = "py312"
line-length = 100

[lint]
select = ["E", "F", "I", "UP"]
178 changes: 178 additions & 0 deletions scripts/check_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Validate the repository's structured and text files."""

from __future__ import annotations

import json
import re
import sys
import tomllib
import urllib.parse
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any

import yaml

ROOT = Path(__file__).resolve().parents[1]
SKIPPED_DIRECTORIES = {
".git",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".venv",
"__pycache__",
}
TEXT_SUFFIXES = {
".json",
".md",
".py",
".svg",
".toml",
".txt",
".yaml",
".yml",
}
MARKDOWN_LINK = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")


class DuplicateKeyError(ValueError):
"""Raised when a JSON object contains a duplicate key."""


def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise DuplicateKeyError(f"duplicate key {key!r}")
result[key] = value
return result


def repository_files() -> list[Path]:
return sorted(
path
for path in ROOT.rglob("*")
if path.is_file() and not SKIPPED_DIRECTORIES.intersection(path.relative_to(ROOT).parts)
)


def validate_text(path: Path, errors: list[str]) -> str | None:
if path.suffix.lower() not in TEXT_SUFFIXES:
return None

relative = path.relative_to(ROOT)
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
errors.append(f"{relative}: not valid UTF-8 ({exc})")
return None

if text and not text.endswith("\n"):
errors.append(f"{relative}: missing final newline")
for number, line in enumerate(text.splitlines(), start=1):
if line != line.rstrip():
errors.append(f"{relative}:{number}: trailing whitespace")
return text


def validate_json(path: Path, text: str, errors: list[str]) -> None:
try:
json.loads(text, object_pairs_hook=reject_duplicate_keys)
except (json.JSONDecodeError, DuplicateKeyError) as exc:
errors.append(f"{path.relative_to(ROOT)}: invalid JSON ({exc})")


def validate_toml(path: Path, text: str, errors: list[str]) -> None:
try:
tomllib.loads(text)
except tomllib.TOMLDecodeError as exc:
errors.append(f"{path.relative_to(ROOT)}: invalid TOML ({exc})")


def validate_yaml(path: Path, text: str, errors: list[str]) -> None:
try:
yaml.safe_load(text)
except yaml.YAMLError as exc:
errors.append(f"{path.relative_to(ROOT)}: invalid YAML ({exc})")


def validate_svg(path: Path, errors: list[str]) -> None:
try:
ET.parse(path)
except ET.ParseError as exc:
errors.append(f"{path.relative_to(ROOT)}: invalid XML/SVG ({exc})")


def markdown_target(raw_target: str) -> str:
target = raw_target.strip()
if target.startswith("<") and target.endswith(">"):
target = target[1:-1]
elif " " in target:
target = target.split(" ", maxsplit=1)[0]
return urllib.parse.unquote(target.split("#", maxsplit=1)[0])


def validate_markdown_links(path: Path, text: str, errors: list[str]) -> None:
for match in MARKDOWN_LINK.finditer(text):
target = markdown_target(match.group(1))
if not target or target.startswith(("http://", "https://", "mailto:")):
continue
resolved = (path.parent / target).resolve()
if not resolved.exists():
errors.append(f"{path.relative_to(ROOT)}: broken local link {target!r}")


def validate_skill(path: Path, text: str, errors: list[str]) -> None:
relative = path.relative_to(ROOT)
if not text.startswith("---\n"):
errors.append(f"{relative}: missing YAML frontmatter")
return
try:
frontmatter, _body = text[4:].split("\n---\n", maxsplit=1)
metadata = yaml.safe_load(frontmatter)
except (ValueError, yaml.YAMLError) as exc:
errors.append(f"{relative}: invalid YAML frontmatter ({exc})")
return
if not isinstance(metadata, dict):
errors.append(f"{relative}: frontmatter must be a mapping")
return
if metadata.get("name") != path.parent.name:
errors.append(f"{relative}: skill name must match directory name {path.parent.name!r}")
if not isinstance(metadata.get("description"), str) or not metadata["description"].strip():
errors.append(f"{relative}: skill description must be a non-empty string")


def main() -> int:
errors: list[str] = []
files = repository_files()

for path in files:
text = validate_text(path, errors)
suffix = path.suffix.lower()
if text is not None and suffix == ".json":
validate_json(path, text, errors)
elif text is not None and suffix == ".toml":
validate_toml(path, text, errors)
elif text is not None and suffix in {".yaml", ".yml"}:
validate_yaml(path, text, errors)
elif suffix == ".svg":
validate_svg(path, errors)

if text is not None and suffix == ".md":
validate_markdown_links(path, text, errors)
if text is not None and path.name == "SKILL.md":
validate_skill(path, text, errors)

if errors:
print("Repository validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
return 1

print(f"Validated {len(files)} repository files.")
return 0


if __name__ == "__main__":
raise SystemExit(main())
48 changes: 48 additions & 0 deletions tests/test_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from __future__ import annotations

import json
import unittest
from pathlib import Path
from urllib.parse import urlparse

ROOT = Path(__file__).resolve().parents[1]


def load_json(path: Path) -> dict:
with path.open(encoding="utf-8") as handle:
return json.load(handle)


class CursorPluginRepositoryTests(unittest.TestCase):
def setUp(self) -> None:
self.marketplace = load_json(ROOT / ".cursor-plugin/marketplace.json")
self.listing = self.marketplace["plugins"][0]
self.plugin_root = ROOT / self.listing["source"]
self.manifest = load_json(self.plugin_root / ".cursor-plugin/plugin.json")

def test_marketplace_points_to_plugin(self) -> None:
self.assertEqual(self.marketplace["name"], self.manifest["name"])
self.assertEqual(self.listing["name"], self.manifest["name"])
self.assertTrue(self.plugin_root.is_dir())

def test_manifest_references_existing_assets(self) -> None:
self.assertTrue((self.plugin_root / self.manifest["logo"]).is_file())
self.assertTrue((self.plugin_root / "skills/sprites/SKILL.md").is_file())

def test_manifest_repository_matches_this_repository(self) -> None:
self.assertEqual(
self.manifest["repository"],
"https://github.com/superfly/sprites-cursor-plugin",
)

def test_mcp_server_uses_https(self) -> None:
config = load_json(self.plugin_root / "mcp.json")
server = config["mcpServers"]["sprites"]
parsed = urlparse(server["url"])
self.assertEqual(parsed.scheme, "https")
self.assertEqual(parsed.netloc, "sprites.dev")
self.assertEqual(parsed.path, "/mcp")


if __name__ == "__main__":
unittest.main()
Loading