From 054d84d71ca0cd25de1eb89194d1af39286e9b4f Mon Sep 17 00:00:00 2001 From: Rai Siqueira Date: Thu, 23 Jul 2026 11:37:59 -0300 Subject: [PATCH] feat: add cursor and opencode distribution --- .cursor-plugin/marketplace.json | 37 ++++++++ .opencode/INSTALL.md | 10 +++ .opencode/plugins/django-ai-skills.js | 17 ++++ package.json | 8 ++ .../cdrf-expert/.cursor-plugin/plugin.json | 20 +++++ .../.cursor-plugin/plugin.json | 20 +++++ .../django-expert/.cursor-plugin/plugin.json | 20 +++++ .../.cursor-plugin/plugin.json | 20 +++++ .../.cursor-plugin/plugin.json | 20 +++++ scripts/generate_adapters.py | 47 ++++++++++ scripts/smoke_plugins.py | 88 ++++++++++++++++++- scripts/validate_plugins.py | 1 + tests/fixtures/hosts/cursor/README.md | 8 ++ .../hosts/generic-agent-skills/README.md | 5 ++ tests/fixtures/hosts/opencode/README.md | 5 ++ tests/test_adapter_generation.py | 18 ++++ tests/test_install_lifecycle.py | 37 +++++++- 17 files changed, 374 insertions(+), 7 deletions(-) create mode 100644 .cursor-plugin/marketplace.json create mode 100644 .opencode/INSTALL.md create mode 100644 .opencode/plugins/django-ai-skills.js create mode 100644 package.json create mode 100644 plugins/cdrf-expert/.cursor-plugin/plugin.json create mode 100644 plugins/django-celery-expert/.cursor-plugin/plugin.json create mode 100644 plugins/django-expert/.cursor-plugin/plugin.json create mode 100644 plugins/django-reviewer/.cursor-plugin/plugin.json create mode 100644 plugins/django-safe-migration/.cursor-plugin/plugin.json create mode 100644 tests/fixtures/hosts/cursor/README.md create mode 100644 tests/fixtures/hosts/generic-agent-skills/README.md create mode 100644 tests/fixtures/hosts/opencode/README.md diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 0000000..3f407dd --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,37 @@ +{ + "name": "django-ai-plugins", + "owner": { + "name": "Vinta Software" + }, + "metadata": { + "description": "Django skills for AI-assisted development", + "version": "1.1.0" + }, + "plugins": [ + { + "name": "django-expert", + "source": "./plugins/django-expert", + "description": "Comprehensive Django backend development guidelines and best practices. Expert guidance for models, views, templates, DRF, testing, and deployment." + }, + { + "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-safe-migration", + "source": "./plugins/django-safe-migration", + "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." + }, + { + "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." + } + ] +} diff --git a/.opencode/INSTALL.md b/.opencode/INSTALL.md new file mode 100644 index 0000000..0f29b97 --- /dev/null +++ b/.opencode/INSTALL.md @@ -0,0 +1,10 @@ +# OpenCode installation + +Clone this repository into the project that should expose the skills, or copy +`.opencode/plugins/django-ai-skills.js`, `package.json`, and `skills/` while +preserving their relative paths. OpenCode automatically loads project plugins +from `.opencode/plugins/`; this adapter only registers the canonical `skills/` +directory. + +Do not also install the same skill IDs under `.agents/skills/` or +`.opencode/skills/`, because the duplicate can shadow this registration. diff --git a/.opencode/plugins/django-ai-skills.js b/.opencode/plugins/django-ai-skills.js new file mode 100644 index 0000000..33b5802 --- /dev/null +++ b/.opencode/plugins/django-ai-skills.js @@ -0,0 +1,17 @@ +import path from "node:path" +import { fileURLToPath } from "node:url" + +const pluginDirectory = path.dirname(fileURLToPath(import.meta.url)) +const skillsDirectory = path.resolve(pluginDirectory, "../../skills") + +export const DjangoAiSkillsPlugin = async () => ({ + config: async (config) => { + config.skills = config.skills || {} + config.skills.paths = config.skills.paths || [] + if (!config.skills.paths.includes(skillsDirectory)) { + config.skills.paths.push(skillsDirectory) + } + }, +}) + +export default DjangoAiSkillsPlugin diff --git a/package.json b/package.json new file mode 100644 index 0000000..0ac83cb --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "django-ai-skills", + "private": true, + "type": "module", + "exports": { + "./opencode": "./.opencode/plugins/django-ai-skills.js" + } +} diff --git a/plugins/cdrf-expert/.cursor-plugin/plugin.json b/plugins/cdrf-expert/.cursor-plugin/plugin.json new file mode 100644 index 0000000..894c636 --- /dev/null +++ b/plugins/cdrf-expert/.cursor-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "name": "cdrf-expert", + "displayName": "CDRF Expert", + "version": "1.1.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.", + "author": { + "name": "Vinta Software" + }, + "homepage": "https://github.com/vintasoftware/django-ai-plugins", + "repository": "https://github.com/vintasoftware/django-ai-plugins", + "license": "MIT", + "keywords": [ + "django-rest-framework", + "drf", + "cdrf", + "api" + ], + "category": "Developer Tools", + "skills": "./skills/" +} diff --git a/plugins/django-celery-expert/.cursor-plugin/plugin.json b/plugins/django-celery-expert/.cursor-plugin/plugin.json new file mode 100644 index 0000000..d0f64e6 --- /dev/null +++ b/plugins/django-celery-expert/.cursor-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "name": "django-celery-expert", + "displayName": "Django Celery Expert", + "version": "1.1.0", + "description": "Django and Celery best practices for asynchronous task processing. Expert guidance for task design, worker configuration, monitoring, error handling, and production deployment.", + "author": { + "name": "Vinta Software" + }, + "homepage": "https://github.com/vintasoftware/django-ai-plugins", + "repository": "https://github.com/vintasoftware/django-ai-plugins", + "license": "MIT", + "keywords": [ + "django", + "celery", + "async", + "tasks" + ], + "category": "Developer Tools", + "skills": "./skills/" +} diff --git a/plugins/django-expert/.cursor-plugin/plugin.json b/plugins/django-expert/.cursor-plugin/plugin.json new file mode 100644 index 0000000..1adba43 --- /dev/null +++ b/plugins/django-expert/.cursor-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "name": "django-expert", + "displayName": "Django Expert", + "version": "1.1.0", + "description": "Comprehensive Django backend development guidelines and best practices. Expert guidance for models, views, templates, DRF, testing, and deployment.", + "author": { + "name": "Vinta Software" + }, + "homepage": "https://github.com/vintasoftware/django-ai-plugins", + "repository": "https://github.com/vintasoftware/django-ai-plugins", + "license": "MIT", + "keywords": [ + "django", + "backend", + "drf", + "python" + ], + "category": "Developer Tools", + "skills": "./skills/" +} diff --git a/plugins/django-reviewer/.cursor-plugin/plugin.json b/plugins/django-reviewer/.cursor-plugin/plugin.json new file mode 100644 index 0000000..d05308a --- /dev/null +++ b/plugins/django-reviewer/.cursor-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "name": "django-reviewer", + "displayName": "Django Reviewer", + "version": "1.1.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.", + "author": { + "name": "Vinta Software" + }, + "homepage": "https://github.com/vintasoftware/django-ai-plugins", + "repository": "https://github.com/vintasoftware/django-ai-plugins", + "license": "MIT", + "keywords": [ + "django", + "code-review", + "python", + "agent" + ], + "category": "Developer Tools", + "skills": "./portable-skills/" +} diff --git a/plugins/django-safe-migration/.cursor-plugin/plugin.json b/plugins/django-safe-migration/.cursor-plugin/plugin.json new file mode 100644 index 0000000..c488b32 --- /dev/null +++ b/plugins/django-safe-migration/.cursor-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "name": "django-safe-migration", + "displayName": "Django Safe Migration", + "version": "1.1.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.", + "author": { + "name": "Vinta Software" + }, + "homepage": "https://github.com/vintasoftware/django-ai-plugins", + "repository": "https://github.com/vintasoftware/django-ai-plugins", + "license": "MIT", + "keywords": [ + "django", + "migrations", + "postgresql", + "zero-downtime" + ], + "category": "Developer Tools", + "skills": "./skills/" +} diff --git a/scripts/generate_adapters.py b/scripts/generate_adapters.py index 5416b63..1d846bd 100644 --- a/scripts/generate_adapters.py +++ b/scripts/generate_adapters.py @@ -216,6 +216,7 @@ def _native_outputs( outputs: list[tuple[str, str, Path]] = [] claude_plugins: list[dict[str, Any]] = [] codex_plugins: list[dict[str, Any]] = [] + cursor_plugins: list[dict[str, Any]] = [] versions: list[str] = [] for plugin in catalog["plugins"]: @@ -240,6 +241,23 @@ def _native_outputs( ), "interface": _interface(plugin, defaults), } + cursor_manifest = { + "name": plugin["id"], + "displayName": plugin["interface"]["display_name"], + "version": plugin["version"], + "description": plugin["description"], + "author": {"name": defaults["author"]["name"]}, + "homepage": defaults["homepage"], + "repository": catalog["repository"], + "license": defaults["license"], + "keywords": plugin["keywords"], + "category": defaults["category"], + "skills": ( + "./portable-skills/" + if plugin["capability"]["kind"] == "hybrid" + else "./skills/" + ), + } outputs.extend( ( ( @@ -252,6 +270,11 @@ def _native_outputs( _json_text(codex_manifest), package / ".codex-plugin" / "plugin.json", ), + ( + plugin["id"], + _json_text(cursor_manifest), + package / ".cursor-plugin" / "plugin.json", + ), ) ) claude_plugins.append( @@ -276,6 +299,13 @@ def _native_outputs( "category": defaults["category"], } ) + cursor_plugins.append( + { + "name": plugin["id"], + "source": f"./{plugin['package']}", + "description": plugin["description"], + } + ) collection_version = max(versions) outputs.extend( @@ -308,6 +338,23 @@ def _native_outputs( ), root / ".agents" / "plugins" / "marketplace.json", ), + ( + "cursor-marketplace", + _json_text( + { + "name": catalog["marketplaces"]["cursor"]["name"], + "owner": {"name": defaults["author"]["name"]}, + "metadata": { + "description": ( + "Django skills for AI-assisted development" + ), + "version": collection_version, + }, + "plugins": cursor_plugins, + } + ), + root / ".cursor-plugin" / "marketplace.json", + ), ) ) return outputs diff --git a/scripts/smoke_plugins.py b/scripts/smoke_plugins.py index b5f0894..243d894 100644 --- a/scripts/smoke_plugins.py +++ b/scripts/smoke_plugins.py @@ -6,6 +6,7 @@ from __future__ import annotations import argparse import json import shutil +import subprocess import sys import tempfile from collections import defaultdict @@ -49,10 +50,10 @@ def discover_capabilities(package: Path, target: str) -> list[dict[str, Any]]: "target": target, } ) - elif target == "codex": + elif target in {"codex", "cursor"}: skill_root_value = manifest.get("skills") if not isinstance(skill_root_value, str): - raise SmokeFailure(f"{package}: Codex manifest has no skills path") + raise SmokeFailure(f"{package}: {target} manifest has no skills path") skill_root = package / skill_root_value for path in sorted(skill_root.glob("*/SKILL.md")): capabilities.append( @@ -113,11 +114,88 @@ def find_duplicates( } +def _smoke_opencode( + root: Path, home: Path, catalog: dict[str, Any] +) -> list[dict[str, Any]]: + checkout = home / "checkout" + shutil.copytree(root / ".opencode", checkout / ".opencode") + shutil.copytree(root / "skills", checkout / "skills") + shutil.copy2(root / "package.json", checkout / "package.json") + adapter = checkout / ".opencode" / "plugins" / "django-ai-skills.js" + script = ( + f"import plugin from {json.dumps(adapter.as_uri())};" + "const hooks = await plugin();" + "const config = {};" + "await hooks.config(config);" + "await hooks.config(config);" + "process.stdout.write(JSON.stringify({" + "hooks: Object.keys(hooks), paths: config.skills.paths" + "}));" + ) + completed = subprocess.run( + ["node", "--input-type=module", "--eval", script], + check=False, + capture_output=True, + text=True, + ) + if completed.returncode: + raise SmokeFailure( + f"OpenCode adapter failed to load: {completed.stderr.strip()}" + ) + loaded = json.loads(completed.stdout) + paths = loaded.get("paths", []) + registered_path = Path(paths[0]).resolve() if len(paths) == 1 else None + if ( + loaded.get("hooks") != ["config"] + or registered_path != (checkout / "skills").resolve() + ): + raise SmokeFailure( + "OpenCode adapter must register only the copied canonical skills path" + ) + return [ + { + "plugin": plugin["id"], + "status": "ok", + "installed_root": checkout, + "capability": plugin["id"], + "provenance": "opencode-plugin", + } + for plugin in catalog["plugins"] + ] + + +def _smoke_agent_skills( + root: Path, home: Path, catalog: dict[str, Any] +) -> list[dict[str, Any]]: + skills_home = home / "skills" + shutil.copytree(root / "skills", skills_home) + expected = sorted(plugin["id"] for plugin in catalog["plugins"]) + discovered = sorted(path.parent.name for path in skills_home.glob("*/SKILL.md")) + if discovered != expected: + raise SmokeFailure( + f"generic Agent Skills IDs differ: expected {expected}, found {discovered}" + ) + return [ + { + "plugin": plugin_id, + "status": "ok", + "installed_root": skills_home, + "capability": plugin_id, + "provenance": "direct-agent-skill", + } + for plugin_id in discovered + ] + + def smoke_repository(root: Path, target: str) -> list[dict[str, Any]]: catalog = _load_catalog(root) results: list[dict[str, Any]] = [] with tempfile.TemporaryDirectory(prefix=f"django-ai-{target}-", dir="/tmp") as temporary: home = Path(temporary) + if target == "opencode": + return _smoke_opencode(root, home, catalog) + if target == "agent-skills": + return _smoke_agent_skills(root, home, catalog) for plugin in catalog["plugins"]: installed = install_package(root, home, plugin["id"]) capabilities = discover_capabilities(installed, target) @@ -136,7 +214,11 @@ def smoke_repository(root: Path, target: str) -> list[dict[str, Any]]: def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--target", choices=("claude", "codex"), required=True) + parser.add_argument( + "--target", + choices=("claude", "codex", "cursor", "opencode", "agent-skills"), + required=True, + ) return parser.parse_args(argv) diff --git a/scripts/validate_plugins.py b/scripts/validate_plugins.py index ea9c4af..488636b 100644 --- a/scripts/validate_plugins.py +++ b/scripts/validate_plugins.py @@ -16,6 +16,7 @@ CATALOG_PATH = ROOT / "plugins" / "catalog.json" MARKETPLACE_PATHS = { "claude": ROOT / ".claude-plugin" / "marketplace.json", "codex": ROOT / ".agents" / "plugins" / "marketplace.json", + "cursor": ROOT / ".cursor-plugin" / "marketplace.json", } REQUIRED_PLUGIN_FIELDS = { "id", diff --git a/tests/fixtures/hosts/cursor/README.md b/tests/fixtures/hosts/cursor/README.md new file mode 100644 index 0000000..9a77a69 --- /dev/null +++ b/tests/fixtures/hosts/cursor/README.md @@ -0,0 +1,8 @@ +# Cursor host fixture + +The Cursor smoke test copies every catalog package into a temporary plugin +home, resolves the `skills` path declared by `.cursor-plugin/plugin.json`, and +requires exactly one package-local skill with the matching ID. + +Live Cursor Agent verification is optional because `cursor-agent` is not +available in every development or CI environment. diff --git a/tests/fixtures/hosts/generic-agent-skills/README.md b/tests/fixtures/hosts/generic-agent-skills/README.md new file mode 100644 index 0000000..41ce6a4 --- /dev/null +++ b/tests/fixtures/hosts/generic-agent-skills/README.md @@ -0,0 +1,5 @@ +# Generic Agent Skills fixture + +Generic consumers discover `skills//SKILL.md` directly. The structural +smoke test requires the five catalog IDs exactly once and validates every +canonical directory without host-only frontmatter. diff --git a/tests/fixtures/hosts/opencode/README.md b/tests/fixtures/hosts/opencode/README.md new file mode 100644 index 0000000..b6cda2e --- /dev/null +++ b/tests/fixtures/hosts/opencode/README.md @@ -0,0 +1,5 @@ +# OpenCode host fixture + +The OpenCode fixture imports the repository-local JavaScript adapter with Node, +applies its configuration hook twice, and requires exactly one absolute +registration of the canonical root `skills/` directory. diff --git a/tests/test_adapter_generation.py b/tests/test_adapter_generation.py index e69759a..46cc7d6 100644 --- a/tests/test_adapter_generation.py +++ b/tests/test_adapter_generation.py @@ -117,6 +117,24 @@ class AdapterGenerationTests(unittest.TestCase): self.assertEqual(generator.validate_skill(skill), []) self.assertFalse((isolated / "skills" / "SKILL.md").exists()) + def test_cursor_manifests_reference_generated_package_local_skills(self): + catalog = json.loads((ROOT / "plugins" / "catalog.json").read_text()) + + for plugin in catalog["plugins"]: + with self.subTest(plugin=plugin["id"]): + package = ROOT / plugin["package"] + manifest = json.loads( + (package / ".cursor-plugin" / "plugin.json").read_text() + ) + skill_root = package / manifest["skills"] + skill = skill_root / plugin["id"] + + self.assertTrue(skill.is_dir()) + self.assertFalse(skill.is_symlink()) + self.assertEqual(generator.validate_skill(skill), []) + canonical = ROOT / Path(plugin["capability"]["canonical_path"]).parent + self.assertEqual(generator._tree_files(skill), generator._tree_files(canonical)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_install_lifecycle.py b/tests/test_install_lifecycle.py index 1b884d8..80b3e3c 100644 --- a/tests/test_install_lifecycle.py +++ b/tests/test_install_lifecycle.py @@ -25,18 +25,20 @@ class NativeDistributionTests(unittest.TestCase): def load_catalog(self): return json.loads((ROOT / "plugins" / "catalog.json").read_text()) - def test_native_marketplaces_have_the_same_five_stable_ids(self): + def test_marketplaces_have_the_same_five_stable_ids(self): expected = [plugin["id"] for plugin in self.load_catalog()["plugins"]] claude = json.loads((ROOT / ".claude-plugin" / "marketplace.json").read_text()) codex = json.loads((ROOT / ".agents" / "plugins" / "marketplace.json").read_text()) + cursor = json.loads((ROOT / ".cursor-plugin" / "marketplace.json").read_text()) self.assertEqual([plugin["name"] for plugin in claude["plugins"]], expected) self.assertEqual([plugin["name"] for plugin in codex["plugins"]], expected) + self.assertEqual([plugin["name"] for plugin in cursor["plugins"]], expected) def test_native_manifests_match_catalog_and_expose_one_host_surface(self): for plugin in self.load_catalog()["plugins"]: package = ROOT / plugin["package"] - for target in ("claude", "codex"): + for target in ("claude", "codex", "cursor"): with self.subTest(plugin=plugin["id"], target=target): manifest = json.loads( ( @@ -52,8 +54,8 @@ class NativeDistributionTests(unittest.TestCase): self.assertEqual(len(capabilities), 1) self.assertTrue(capabilities[0]["path"].is_file()) - def test_isolated_native_smoke_never_uses_real_user_homes(self): - for target in ("claude", "codex"): + def test_isolated_smoke_never_uses_real_user_homes(self): + for target in ("claude", "codex", "cursor", "opencode", "agent-skills"): with self.subTest(target=target): results = smoke.smoke_repository(ROOT, target) @@ -146,9 +148,36 @@ class NativeDistributionTests(unittest.TestCase): "plugins/cdrf-expert/.codex-plugin/plugin.json", stale, ) + self.assertIn( + "plugins/cdrf-expert/.cursor-plugin/plugin.json", + stale, + ) generator.generate_adapters(root) self.assertEqual(generator.generate_adapters(root, check=True), []) + def test_opencode_adapter_registers_only_the_canonical_skills_path(self): + results = smoke.smoke_repository(ROOT, "opencode") + + self.assertEqual(len(results), 5) + self.assertTrue(all(result["provenance"] == "opencode-plugin" for result in results)) + + def test_repository_package_is_private_dependency_free_esm(self): + package = json.loads((ROOT / "package.json").read_text()) + + self.assertTrue(package["private"]) + self.assertEqual(package["type"], "module") + self.assertNotIn("dependencies", package) + self.assertNotIn("devDependencies", package) + self.assertNotIn("scripts", package) + + def test_generic_agent_skills_are_the_five_canonical_directories(self): + expected = [plugin["id"] for plugin in self.load_catalog()["plugins"]] + discovered = sorted(path.parent.name for path in (ROOT / "skills").glob("*/SKILL.md")) + + self.assertEqual(discovered, sorted(expected)) + for skill_id in discovered: + self.assertEqual(generator.validate_skill(ROOT / "skills" / skill_id), []) + if __name__ == "__main__": unittest.main()