feat: add cursor and opencode distribution

This commit is contained in:
Rai Siqueira
2026-07-23 11:37:59 -03:00
parent b70822a3ce
commit 054d84d71c
17 changed files with 374 additions and 7 deletions
+37
View File
@@ -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."
}
]
}
+10
View File
@@ -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.
+17
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"name": "django-ai-skills",
"private": true,
"type": "module",
"exports": {
"./opencode": "./.opencode/plugins/django-ai-skills.js"
}
}
@@ -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/"
}
@@ -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/"
}
@@ -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/"
}
@@ -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/"
}
@@ -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/"
}
+47
View File
@@ -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
+85 -3
View File
@@ -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)
+1
View File
@@ -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",
+8
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
# Generic Agent Skills fixture
Generic consumers discover `skills/<id>/SKILL.md` directly. The structural
smoke test requires the five catalog IDs exactly once and validates every
canonical directory without host-only frontmatter.
+5
View File
@@ -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.
+18
View File
@@ -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()
+33 -4
View File
@@ -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()