diff --git a/plugins/catalog.json b/plugins/catalog.json new file mode 100644 index 0000000..08c1063 --- /dev/null +++ b/plugins/catalog.json @@ -0,0 +1,199 @@ +{ + "schema_version": 1, + "repository": "https://github.com/vintasoftware/django-ai-plugins", + "defaults": { + "author": { + "name": "Vinta Software", + "url": "https://www.vinta.com.br/" + }, + "homepage": "https://github.com/vintasoftware/django-ai-plugins", + "license": "MIT", + "category": "Developer Tools" + }, + "marketplaces": { + "claude": { + "name": "django-ai-plugins", + "display_name": "Django AI Plugins" + }, + "codex": { + "name": "vinta-django-ai-plugin", + "display_name": "Vinta Django AI" + }, + "cursor": { + "name": "django-ai-plugins", + "display_name": "Django AI Plugins" + } + }, + "plugins": [ + { + "id": "django-expert", + "version": "1.0.0", + "description": "Comprehensive Django backend development guidelines and best practices. Expert guidance for models, views, templates, DRF, testing, and deployment.", + "package": "plugins/django-expert", + "capability": { + "kind": "skill", + "package_path": "skills/SKILL.md" + }, + "hosts": [ + "claude", + "codex", + "cursor", + "opencode", + "agent-skills" + ], + "keywords": [ + "django", + "backend", + "drf", + "python" + ], + "interface": { + "display_name": "Django Expert", + "short_description": "Django backend guidance.", + "long_description": "Expert Django backend development guidance covering models, views, DRF, testing, security, performance, and deployment.", + "default_prompts": [ + "Create a Django model for user profiles.", + "Build a DRF endpoint with filtering and pagination.", + "Review this queryset for N+1 issues." + ] + } + }, + { + "id": "django-celery-expert", + "version": "1.0.0", + "description": "Django and Celery best practices for asynchronous task processing. Expert guidance for task design, worker configuration, monitoring, error handling, and production deployment.", + "package": "plugins/django-celery-expert", + "capability": { + "kind": "skill", + "package_path": "skills/SKILL.md" + }, + "hosts": [ + "claude", + "codex", + "cursor", + "opencode", + "agent-skills" + ], + "keywords": [ + "django", + "celery", + "async", + "tasks" + ], + "interface": { + "display_name": "Django Celery Expert", + "short_description": "Celery patterns for Django.", + "long_description": "Expert guidance for background task design, worker configuration, retries, scheduling, observability, and production Celery deployments in Django projects.", + "default_prompts": [ + "Create a Celery task with retry and backoff.", + "Set up Celery Beat for scheduled reports.", + "Review this task for idempotency risks." + ] + } + }, + { + "id": "cdrf-expert", + "version": "1.0.0", + "description": "Expert guidance for Django REST Framework class-based views using Classy DRF (cdrf.co). Helps choose the right DRF generic/viewset class and override methods safely.", + "package": "plugins/cdrf-expert", + "capability": { + "kind": "skill", + "package_path": "skills/SKILL.md" + }, + "hosts": [ + "claude", + "codex", + "cursor", + "opencode", + "agent-skills" + ], + "keywords": [ + "django-rest-framework", + "drf", + "cdrf", + "api" + ], + "interface": { + "display_name": "CDRF Expert", + "short_description": "DRF class and override guidance.", + "long_description": "Helps choose the correct Django REST Framework class-based view, trace request lifecycle and MRO, and select the safest override hook.", + "default_prompts": [ + "Should this be APIView or ModelViewSet?", + "Which hook should I override: create or perform_create?", + "Trace this DRF method through the MRO." + ] + } + }, + { + "id": "django-safe-migration", + "version": "1.0.0", + "description": "Write, review, and rewrite Django migrations for PostgreSQL with zero-downtime guarantees. Covers two-file splits, AddIndexConcurrently, FK NOT VALID + VALIDATE, db_default for NOT NULL columns, and RunPython safety rules.", + "package": "plugins/django-safe-migration", + "capability": { + "kind": "skill", + "package_path": "skills/django-safe-migration/SKILL.md" + }, + "hosts": [ + "claude", + "codex", + "cursor", + "opencode", + "agent-skills" + ], + "keywords": [ + "django", + "migrations", + "postgresql", + "zero-downtime" + ], + "interface": { + "display_name": "Django Safe Migration", + "short_description": "Zero-downtime Django migrations.", + "long_description": "Guidance for writing, reviewing, and rewriting Django migrations for PostgreSQL rolling deploys.", + "default_prompts": [ + "Review this migration for zero-downtime safety." + ] + }, + "overrides": { + "codex": { + "website_url": "https://github.com/vintasoftware/django-safe-migration-plugin", + "privacy_policy_url": "https://www.vinta.com.br/privacy-policy", + "terms_of_service_url": "https://github.com/vintasoftware/django-safe-migration-plugin/blob/main/LICENSE" + } + } + }, + { + "id": "django-reviewer", + "version": "1.0.0", + "description": "Reviews and refines Django/Python code for clarity, consistency, and maintainability while preserving all functionality. Applies Django best practices, PEP 8, and project standards.", + "package": "plugins/django-reviewer", + "capability": { + "kind": "hybrid", + "package_path": "agents/django-reviewer.md" + }, + "hosts": [ + "claude", + "codex", + "cursor", + "opencode", + "agent-skills" + ], + "keywords": [ + "django", + "code-review", + "python", + "agent" + ], + "interface": { + "display_name": "Django Reviewer", + "short_description": "Django and Python code review.", + "long_description": "Code review focused on Django and Python clarity, consistency, maintainability, and common ORM or DRF anti-patterns.", + "default_prompts": [ + "Review my recent Django changes for anti-patterns.", + "Check this queryset for performance regressions.", + "Refine this serializer without changing behavior." + ] + } + } + ] +} diff --git a/scripts/validate_plugins.py b/scripts/validate_plugins.py new file mode 100644 index 0000000..ea9c4af --- /dev/null +++ b/scripts/validate_plugins.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""Validate the canonical plugin catalog and generated distribution metadata.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path, PurePosixPath +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +CATALOG_PATH = ROOT / "plugins" / "catalog.json" +MARKETPLACE_PATHS = { + "claude": ROOT / ".claude-plugin" / "marketplace.json", + "codex": ROOT / ".agents" / "plugins" / "marketplace.json", +} +REQUIRED_PLUGIN_FIELDS = { + "id", + "version", + "description", + "package", + "capability", + "hosts", + "keywords", + "interface", +} +ALLOWED_CAPABILITY_KINDS = {"skill", "agent", "hybrid"} +ALLOWED_HOSTS = {"claude", "codex", "cursor", "opencode", "agent-skills"} +SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") + + +class ValidationFailure(RuntimeError): + """Raised when an input file cannot be parsed for validation.""" + + +def load_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text()) + except FileNotFoundError as error: + raise ValidationFailure(f"{path}: file not found") from error + except json.JSONDecodeError as error: + raise ValidationFailure( + f"{path}: invalid JSON at line {error.lineno}, column {error.colno}" + ) from error + if not isinstance(value, dict): + raise ValidationFailure(f"{path}: root value must be an object") + return value + + +def _safe_relative_path(value: object) -> PurePosixPath | None: + if not isinstance(value, str) or not value: + return None + path = PurePosixPath(value) + if path.is_absolute() or ".." in path.parts or "." in path.parts: + return None + return path + + +def _plugin_label(plugin: object, index: int) -> str: + if isinstance(plugin, dict) and isinstance(plugin.get("id"), str): + return f"plugin '{plugin['id']}'" + return f"plugin at index {index}" + + +def validate_catalog(catalog: dict[str, Any], root: Path = ROOT) -> list[str]: + errors: list[str] = [] + if catalog.get("schema_version") != 1: + errors.append("catalog schema_version must be 1") + + plugins = catalog.get("plugins") + if not isinstance(plugins, list) or not plugins: + return errors + ["catalog plugins must be a non-empty array"] + + seen_ids: set[str] = set() + for index, plugin in enumerate(plugins): + label = _plugin_label(plugin, index) + if not isinstance(plugin, dict): + errors.append(f"{label} must be an object") + continue + + missing = sorted(REQUIRED_PLUGIN_FIELDS - plugin.keys()) + for field in missing: + errors.append(f"{label} is missing required field '{field}'") + + plugin_id = plugin.get("id") + if not isinstance(plugin_id, str) or not plugin_id: + errors.append(f"{label} has an invalid id") + elif plugin_id in seen_ids: + errors.append(f"duplicate plugin id '{plugin_id}'") + else: + seen_ids.add(plugin_id) + + version = plugin.get("version") + if not isinstance(version, str) or not SEMVER.fullmatch(version): + errors.append(f"{label} has invalid version '{version}'") + + description = plugin.get("description") + if not isinstance(description, str) or not description.strip(): + errors.append(f"{label} has invalid description") + + package_value = plugin.get("package") + package_relative = _safe_relative_path(package_value) + package_root: Path | None = None + if package_relative is None: + errors.append(f"{label} has unsafe package path '{package_value}'") + else: + package_root = root.joinpath(*package_relative.parts) + if not package_root.is_dir(): + errors.append(f"{label} references unknown package root '{package_value}'") + + capability = plugin.get("capability") + if not isinstance(capability, dict): + errors.append(f"{label} capability must be an object") + else: + kind = capability.get("kind") + if kind not in ALLOWED_CAPABILITY_KINDS: + errors.append(f"{label} has unsupported capability kind '{kind}'") + surface_value = capability.get("package_path") + surface_relative = _safe_relative_path(surface_value) + if surface_relative is None: + errors.append(f"{label} has unsafe capability path '{surface_value}'") + elif package_root is not None and package_root.is_dir(): + surface = package_root.joinpath(*surface_relative.parts) + if surface.is_symlink() or not surface.is_file(): + errors.append( + f"{label} advertises no usable surface at " + f"'{package_value}/{surface_value}'" + ) + + hosts = plugin.get("hosts") + if not isinstance(hosts, list) or not hosts: + errors.append(f"{label} hosts must be a non-empty array") + else: + unknown_hosts = sorted( + host for host in hosts if not isinstance(host, str) or host not in ALLOWED_HOSTS + ) + for host in unknown_hosts: + errors.append(f"{label} has unsupported host '{host}'") + if len(hosts) != len(set(host for host in hosts if isinstance(host, str))): + errors.append(f"{label} has duplicate hosts") + + return errors + + +def _marketplace_source(entry: dict[str, Any]) -> object: + source = entry.get("source") + if isinstance(source, dict): + return source.get("path") + return source + + +def validate_marketplace( + catalog: dict[str, Any], marketplace: dict[str, Any], target: str +) -> list[str]: + catalog_plugins = { + plugin["id"]: plugin + for plugin in catalog.get("plugins", []) + if isinstance(plugin, dict) and isinstance(plugin.get("id"), str) + } + entries = marketplace.get("plugins") + if not isinstance(entries, list): + return [f"{target} marketplace plugins must be an array"] + + errors: list[str] = [] + seen: set[str] = set() + for entry in entries: + if not isinstance(entry, dict) or not isinstance(entry.get("name"), str): + errors.append(f"{target} marketplace contains an invalid plugin record") + continue + plugin_id = entry["name"] + if plugin_id in seen: + errors.append(f"{target} marketplace has duplicate plugin '{plugin_id}'") + continue + seen.add(plugin_id) + plugin = catalog_plugins.get(plugin_id) + if plugin is None: + errors.append( + f"{target} marketplace has orphan plugin '{plugin_id}' " + "(generator-owned record)" + ) + continue + + expected_source = f"./{plugin['package']}" + source = _marketplace_source(entry) + if source != expected_source: + errors.append( + f"{target} marketplace plugin '{plugin_id}' source is '{source}', " + f"expected '{expected_source}'" + ) + if "description" in entry and entry["description"] != plugin["description"]: + errors.append( + f"{target} marketplace plugin '{plugin_id}' description differs " + "from catalog" + ) + + for plugin_id in catalog_plugins: + if plugin_id not in seen: + errors.append(f"{target} marketplace is missing plugin '{plugin_id}'") + return errors + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--catalog-only", + action="store_true", + help="validate canonical catalog and package surfaces without generated outputs", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + arguments = parse_args(argv) + try: + catalog = load_json(CATALOG_PATH) + except ValidationFailure as error: + print(error, file=sys.stderr) + return 1 + + errors = validate_catalog(catalog, ROOT) + if not arguments.catalog_only: + for target, path in MARKETPLACE_PATHS.items(): + try: + marketplace = load_json(path) + except ValidationFailure as error: + errors.append(str(error)) + continue + errors.extend(validate_marketplace(catalog, marketplace, target)) + + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + scope = "catalog and package surfaces" if arguments.catalog_only else "repository" + print(f"Validated {len(catalog['plugins'])} plugins ({scope}).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/catalog/marketplaces/invalid-claude-marketplace.json b/tests/fixtures/catalog/marketplaces/invalid-claude-marketplace.json new file mode 100644 index 0000000..6d34d50 --- /dev/null +++ b/tests/fixtures/catalog/marketplaces/invalid-claude-marketplace.json @@ -0,0 +1,29 @@ +{ + "plugins": [ + { + "name": "django-expert", + "source": "./plugins/django-expert", + "description": "Drifted description" + }, + { + "name": "django-celery-expert", + "source": "./plugins/django-celery-expert", + "description": "Django and Celery best practices for asynchronous task processing. Expert guidance for task design, worker configuration, monitoring, error handling, and production deployment." + }, + { + "name": "cdrf-expert", + "source": "./plugins/cdrf-expert", + "description": "Expert guidance for Django REST Framework class-based views using Classy DRF (cdrf.co). Helps choose the right DRF generic/viewset class and override methods safely." + }, + { + "name": "django-reviewer", + "source": "./plugins/django-reviewer", + "description": "Reviews and refines Django/Python code for clarity, consistency, and maintainability while preserving all functionality. Applies Django best practices, PEP 8, and project standards." + }, + { + "name": "orphan-plugin", + "source": "./plugins/orphan-plugin", + "description": "Not declared in the catalog." + } + ] +} diff --git a/tests/test_plugin_catalog.py b/tests/test_plugin_catalog.py new file mode 100644 index 0000000..ea02e9b --- /dev/null +++ b/tests/test_plugin_catalog.py @@ -0,0 +1,133 @@ +import copy +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CATALOG_PATH = ROOT / "plugins" / "catalog.json" +VALIDATOR_PATH = ROOT / "scripts" / "validate_plugins.py" +FIXTURES = ROOT / "tests" / "fixtures" / "catalog" + + +def load_validator(): + spec = importlib.util.spec_from_file_location("validate_plugins", VALIDATOR_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +validator = load_validator() + + +class PluginCatalogTests(unittest.TestCase): + def load_catalog(self): + return json.loads(CATALOG_PATH.read_text()) + + def test_repository_catalog_contains_the_five_stable_plugins(self): + catalog = self.load_catalog() + + errors = validator.validate_catalog(catalog, ROOT) + + self.assertEqual(errors, []) + self.assertEqual( + [plugin["id"] for plugin in catalog["plugins"]], + [ + "django-expert", + "django-celery-expert", + "cdrf-expert", + "django-safe-migration", + "django-reviewer", + ], + ) + + def test_catalog_rejects_duplicate_ids_and_unknown_roots(self): + catalog = self.load_catalog() + duplicate = copy.deepcopy(catalog["plugins"][0]) + duplicate["package"] = "plugins/does-not-exist" + catalog["plugins"].append(duplicate) + + errors = validator.validate_catalog(catalog, ROOT) + + self.assertTrue(any("duplicate plugin id 'django-expert'" in error for error in errors)) + self.assertTrue(any("plugins/does-not-exist" in error for error in errors)) + + def test_catalog_rejects_missing_metadata_invalid_versions_and_kinds(self): + catalog = self.load_catalog() + plugin = catalog["plugins"][0] + del plugin["description"] + plugin["version"] = "next" + plugin["capability"]["kind"] = "workflow" + + errors = validator.validate_catalog(catalog, ROOT) + + self.assertTrue(any("description" in error for error in errors)) + self.assertTrue(any("version 'next'" in error for error in errors)) + self.assertTrue(any("capability kind 'workflow'" in error for error in errors)) + + def test_catalog_rejects_advertised_plugin_without_executable_surface(self): + catalog = self.load_catalog() + plugin = catalog["plugins"][0] + plugin["capability"]["package_path"] = "skills/missing/SKILL.md" + + errors = validator.validate_catalog(catalog, ROOT) + + self.assertTrue(any("usable surface" in error for error in errors)) + + def test_marketplace_validation_reports_missing_extra_and_metadata_drift(self): + catalog = self.load_catalog() + marketplace = json.loads( + (FIXTURES / "marketplaces" / "invalid-claude-marketplace.json").read_text() + ) + + errors = validator.validate_marketplace(catalog, marketplace, "claude") + + self.assertTrue(any("missing plugin 'django-safe-migration'" in error for error in errors)) + self.assertTrue(any("orphan plugin 'orphan-plugin'" in error for error in errors)) + self.assertTrue(any("django-expert" in error and "description" in error for error in errors)) + + def test_removing_catalog_record_reports_only_generated_orphans(self): + catalog = self.load_catalog() + removed = catalog["plugins"].pop() + marketplace = { + "plugins": [ + { + "name": plugin["id"], + "source": f"./{plugin['package']}", + "description": plugin["description"], + } + for plugin in catalog["plugins"] + ] + + [ + { + "name": removed["id"], + "source": f"./{removed['package']}", + "description": removed["description"], + } + ] + } + + errors = validator.validate_marketplace(catalog, marketplace, "claude") + + self.assertEqual( + errors, + [ + "claude marketplace has orphan plugin 'django-reviewer' " + "(generator-owned record)" + ], + ) + self.assertTrue((ROOT / removed["package"]).is_dir()) + + def test_load_json_reports_malformed_input(self): + with tempfile.TemporaryDirectory() as temporary_directory: + path = Path(temporary_directory) / "bad.json" + path.write_text("{") + + with self.assertRaisesRegex(validator.ValidationFailure, "invalid JSON"): + validator.load_json(path) + + +if __name__ == "__main__": + unittest.main()