mirror of
https://github.com/vintasoftware/django-ai-plugins.git
synced 2026-09-14 16:38:37 +08:00
feat: add cursor and opencode distribution
This commit is contained in:
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user