From ca9add249a2a4b5f598dc8ba8045b52861c519ff Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:27:55 -0400 Subject: [PATCH] fix(distribution): make the Cursor plugin installable and document the install Following the README, a Cursor user got the 9 rules and none of the 16 skills: Cursor loads skills only from a skills folder or an installed plugin, and "reference skills by name in chat" does nothing. The repo's .cursor-plugin/plugin.json could not serve as that plugin: Cursor's schema (cursor/plugins schemas/plugin.schema.json) sets additionalProperties: false, and the manifest carried four inventory keys (snippets, templates, examples, showcase) that only mirrored the filesystem for this repo's own CI. jsonschema rejects it with exactly those four keys. - plugin.json drops the inventory keys (skills and rules stay) and adds homepage/repository; .cursor-plugin/marketplace.json is new. Both validate against Cursor's schemas. - The plugin-dist build ships the Cursor manifests, so a Cursor user clones the slim branch into ~/.cursor/plugins/local/ (README, landing install steps and the dist README say how; per-project copying of skills/ and rules/ is the fallback). - The landing page counts snippets, templates and examples from disk; Validate checks the manifest against the schema's allowed keys and the marketplace entry, instead of the inventory. CLAUDE.md, AGENTS.md and CONTRIBUTING.md drop the "list it in plugin.json" authoring step. Closes #347 Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .cursor-plugin/marketplace.json | 16 +++ .cursor-plugin/plugin.json | 180 +------------------------------- .github/workflows/validate.yml | 47 ++++++--- AGENTS.md | 7 +- CLAUDE.md | 6 +- CONTRIBUTING.md | 2 +- README.md | 11 +- scripts/build_plugin_dist.py | 15 ++- scripts/site/build_site.py | 29 +++-- site.json | 2 +- 10 files changed, 101 insertions(+), 214 deletions(-) create mode 100644 .cursor-plugin/marketplace.json diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 00000000..ef5c0901 --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "blender-developer-tools", + "owner": { + "name": "TMHSDigital" + }, + "metadata": { + "description": "Blender Python skills and rules for Cursor: correct bpy for Blender 5.2 LTS and 4.5 LTS." + }, + "plugins": [ + { + "name": "blender-developer-tools", + "source": "./", + "description": "Cursor and Claude Code skills, rules, snippets, and templates for Blender Python add-on and scripting development" + } + ] +} diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 46afb4f0..d8e4b76b 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -6,6 +6,8 @@ "author": { "name": "TMHSDigital" }, + "homepage": "https://tmhsdigital.github.io/Blender-Developer-Tools/", + "repository": "https://github.com/TMHSDigital/Blender-Developer-Tools", "license": "CC-BY-NC-ND-4.0 AND MIT", "keywords": [ "cursor-plugin", @@ -44,183 +46,5 @@ "rules/validate-imported-mesh-scale.mdc", "rules/no-unapplied-modifiers-on-export.mdc", "rules/use-correct-axis-rna-per-exporter.mdc" - ], - "snippets": [ - "snippets/action-ensure-channelbag-for-slot.py", - "snippets/app-handler-registration.py", - "snippets/bake_normal_high_to_low.py", - "snippets/bmesh-load-edit-free.py", - "snippets/canonical-object-creation.py", - "snippets/canonical-object-deletion.py", - "snippets/convex_hull_collider.py", - "snippets/cross-version-property-delete.py", - "snippets/decimate_to_budget.py", - "snippets/depsgraph-evaluated-mesh.py", - "snippets/driver-with-custom-function.py", - "snippets/export_preset_godot.py", - "snippets/export_preset_unity.py", - "snippets/export_preset_unreal.py", - "snippets/foreach-get-vertices.py", - "snippets/foreach-set-vertices.py", - "snippets/gltf_draco_export.py", - "snippets/lod_chain.py", - "snippets/pointerproperty-binding.py", - "snippets/principled-bsdf-material.py", - "snippets/register-classes-factory.py", - "snippets/save_baked_image.py", - "snippets/shader-node-group.py", - "snippets/setup_bake_target_image.py", - "snippets/temp-override-context.py", - "snippets/usd-export-evaluation-mode.py", - "snippets/version-branch-skeleton.py" - ], - "templates": [ - "templates/ai-asset-pipeline-template", - "templates/extension-addon-template", - "templates/headless-batch-script-template" - ], - "examples": [ - "examples/armature-bend", - "examples/attribute-domain-shear", - "examples/bake-normal-high-to-low", - "examples/bmesh-gear", - "examples/car-mirror-symmetry", - "examples/coincident-vert-weld", - "examples/collision-hull-proxy", - "examples/color-attribute-wheel", - "examples/compositor-glare", - "examples/cross-version-property-delete", - "examples/curve-bevel-arc", - "examples/custom-normals-shade", - "examples/damped-track-aim", - "examples/degenerate-bevel-weld", - "examples/depsgraph-export", - "examples/driver-wave", - "examples/eval-mesh-datablock-name", - "examples/exit-pre-sidecar", - "examples/export-preset-axis", - "examples/gltf-export-roundtrip", - "examples/gltf-skin-roundtrip", - "examples/gn-bundle-roundtrip", - "examples/gn-instance-grid", - "examples/gn-modifier-inputs", - "examples/gn-sdf-remesh", - "examples/gn-sim-fountain", - "examples/gn-socket-rename", - "examples/gn-zone-iterate", - "examples/gp-lineart-contour", - "examples/grease-pencil-rosette", - "examples/image-pixels-testcard", - "examples/light-link-studio", - "examples/lightmap-uv-channel", - "examples/lod-decimate-chain", - "examples/mesh-automasking-settings", - "examples/mesh-hygiene-audit", - "examples/modular-kit-snap", - "examples/ngon-triangulate", - "examples/parent-inverse-orrery", - "examples/png-exr-alpha", - "examples/prop-origin-transform", - "examples/shader-node-group", - "examples/shape-key-blend", - "examples/sky-texture-sun-elevation", - "examples/soccer-ball-goldberg", - "examples/socket-attach-points", - "examples/swatch-grid", - "examples/temp-override-join", - "examples/text-version-stamp", - "examples/triangulate-tangents", - "examples/turntable", - "examples/unapplied-scale-gltf", - "examples/usd-export-evaluation-mode", - "examples/uv-layer-grid", - "examples/vertex-color-ao", - "examples/vertex-weight-limit", - "examples/vse-cut-list", - "examples/vse-gamma-cross", - "examples/vse-linear-modifiers", - "examples/wave-displace", - "examples/ray-cast-space", - "examples/lattice-deform", - "examples/solidify-even-thickness", - "examples/boolean-exact-volume" - ], - "showcase": [ - "showcase/shipping-crate", - "showcase/stone-well", - "showcase/wooden-barrel", - "showcase/campfire", - "showcase/market-stall", - "showcase/street-lantern", - "showcase/treasure-chest", - "showcase/terrain-scatter", - "showcase/fence-kit", - "showcase/watchtower", - "showcase/cart", - "showcase/park-bench", - "showcase/wheelbarrow", - "showcase/anvil", - "showcase/water-trough", - "showcase/hitching-post", - "showcase/grindstone", - "showcase/hand-pump", - "showcase/signpost", - "showcase/chopping-block", - "showcase/wooden-bucket", - "showcase/wall-torch", - "showcase/tavern-stool", - "showcase/iron-cauldron", - "showcase/wooden-ladder", - "showcase/hay-bale", - "showcase/crate-stack", - "showcase/stone-archway", - "showcase/rope-bridge", - "showcase/grain-sacks", - "showcase/brazier", - "showcase/apothecary-shelf", - "showcase/wooden-yoke", - "showcase/butter-churn", - "showcase/bookshelf", - "showcase/book-trolley", - "showcase/basketball-hoop", - "showcase/pine-tree", - "showcase/desk-lamp", - "showcase/quad-drone", - "showcase/soccer-goal", - "showcase/fallen-log", - "showcase/office-chair", - "showcase/hover-bike", - "showcase/weight-rack", - "showcase/boulder-cluster", - "showcase/floor-fan", - "showcase/motor-scooter", - "showcase/bowling-pins", - "showcase/pond-edge", - "showcase/stand-mixer", - "showcase/cargo-loader", - "showcase/archery-target", - "showcase/cactus-garden", - "showcase/espresso-machine", - "showcase/road-bicycle", - "showcase/skate-ramp", - "showcase/palm-tree", - "showcase/wingback-armchair", - "showcase/planet-rover", - "showcase/traffic-cones", - "showcase/mushroom-stump", - "showcase/go-kart", - "showcase/broadleaf-oak", - "showcase/farm-tractor", - "showcase/fern-mossy-rock", - "showcase/sea-stack-arch", - "showcase/moka-pot", - "showcase/cricket-wicket", - "showcase/bamboo-clump", - "showcase/toboggan", - "showcase/sundial", - "showcase/mine-cart", - "showcase/garden-gate", - "showcase/ships-wheel", - "showcase/sawhorse-plank" ] } diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d3a8b991..f483588f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -163,6 +163,7 @@ jobs: import glob import json import os + import re import sys errors = [] @@ -174,33 +175,45 @@ jobs: f"plugin.json version '{manifest.get('version')}' != VERSION '{version}'" ) - # Every manifest path must exist on disk. - for key in ('skills', 'rules', 'snippets', 'templates', 'examples', 'showcase'): - for path in manifest.get(key, []): - if not os.path.exists(path): - errors.append(f'{key}: manifest lists missing path {path}') - - # Every content file/dir on disk must be listed in the manifest. + # Cursor's plugin schema (github.com/cursor/plugins schemas/ + # plugin.schema.json) sets additionalProperties: false, so any other + # key makes the manifest invalid for Cursor. The old snippets / + # templates / examples / showcase inventory keys did exactly that (#347). + allowed = {'name', 'displayName', 'description', 'version', + 'minClientVersions', 'author', 'publisher', 'homepage', + 'repository', 'license', 'logo', 'keywords', 'category', + 'tags', 'commands', 'agents', 'skills', 'rules', 'hooks', + 'variables', 'mcpServers'} + for key in sorted(set(manifest) - allowed): + errors.append(f'plugin.json: {key!r} is not in the Cursor plugin schema') + if not re.fullmatch(r'[a-z0-9]([a-z0-9.-]*[a-z0-9])?', manifest.get('name', '')): + errors.append('plugin.json: name must be kebab-case') + + # skills / rules: every listed path exists and every one on disk is listed. expected = { 'skills': sorted(glob.glob('skills/*/SKILL.md')), 'rules': sorted(glob.glob('rules/*.mdc')), - 'snippets': sorted(glob.glob('snippets/*.py')), - 'templates': sorted( - d for d in glob.glob('templates/*') if os.path.isdir(d) - ), - 'examples': sorted( - d for d in glob.glob('examples/*') if os.path.isdir(d) - ), - 'showcase': sorted( - d for d in glob.glob('showcase/*') if os.path.isdir(d) - ), } for key, paths in expected.items(): listed = {p.replace('\\', '/') for p in manifest.get(key, [])} + for path in listed: + if not os.path.exists(path): + errors.append(f'{key}: manifest lists missing path {path}') for path in paths: if path.replace('\\', '/') not in listed: errors.append(f'{key}: {path} on disk but not in manifest') + # The marketplace that lets Cursor import this repo as a plugin. + market = json.load(open('.cursor-plugin/marketplace.json')) + for key in sorted(set(market) - {'name', 'owner', 'metadata', 'plugins'}): + errors.append(f'marketplace.json: {key!r} is not in the Cursor marketplace schema') + entries = market.get('plugins') or [] + if [e.get('name') for e in entries] != [manifest.get('name')]: + errors.append('marketplace.json: must list exactly this plugin by name') + for e in entries: + for key in sorted(set(e) - {'name', 'source', 'description', 'minClientVersions'}): + errors.append(f'marketplace.json plugin entry: {key!r} not allowed') + # Every showcase piece names a category the gallery build knows. sys.path.insert(0, 'scripts') from build_gallery import CATEGORIES diff --git a/AGENTS.md b/AGENTS.md index 5ff2e4eb..ddc38f0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,9 +164,10 @@ way, and a one-paragraph rationale. 30 to 80 lines is the right size. examples, and showcase pieces) match filesystem reality. The counts language in `README.md` is load-bearing: the job greps for it. - `validate.yml` also runs a `validate-manifest` job that checks - `.cursor-plugin/plugin.json` against reality: every listed path must exist, - every skill, rule, snippet, template, example, and showcase piece on disk must be listed, - and the manifest `version` must equal `VERSION`. The release pipeline owns + `.cursor-plugin/plugin.json` against Cursor's plugin schema (no keys outside + it; Cursor rejects unknown ones) and against reality: every listed skill + and rule exists and every one on disk is listed, `marketplace.json` lists + this plugin, and the manifest `version` must equal `VERSION`. The release pipeline owns the manifest `version` line (see `release.yml` below) — never hand-edit it. - `blender-smoke.yml` executes every shipped example (check-only, no render) plus snippet/template smoke tests inside REAL headless Blender, on diff --git a/CLAUDE.md b/CLAUDE.md index 00121c1b..de47b06e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,11 +123,11 @@ example still carries a `tests/smoke/catalog.json` row and a falsifier. `examples/gallery.json` is the gallery's source of truth. When authoring a new gallery example, copy the anatomy of `examples/bmesh-gear/` (script structure, README shape, dark-studio render recipe) and -wire all of: gallery.json entry (with an `alt` sentence describing the still; the build fails without one), `.cursor-plugin/plugin.json` examples array (CI-gated), +wire all of: gallery.json entry (with an `alt` sentence describing the still; the build fails without one), a `tests/smoke/catalog.json` row, hero webp (1280×720) in `docs/gallery/assets/` + its 640×360 card variant `-hero-640.webp` (`python scripts/make_thumbs.py`; the gallery build exits 5 without it) + preview webp (1200×675), then run `python scripts/build_gallery.py`. -A check-only example wires only the `.cursor-plugin/plugin.json` entry, the -`tests/smoke/catalog.json` row, and its README — it is deliberately absent from +A check-only example wires only the +`tests/smoke/catalog.json` row and its README — it is deliberately absent from `examples/gallery.json` and `docs/gallery/`. Renders must conform to the gallery look spec at `docs/VISUAL-STYLE.md`. Render paths gate framing through the shared helper `examples/gallery_framing.py` — diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6206c965..bd6ae1ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,7 +113,7 @@ budget conformance, never an API contract. 1. Add `showcase//` with a script, a README that includes an exit-code table, and a falsifier that breaks one pipeline stage so a **named** budget fails. -2. List the directory in `.cursor-plugin/plugin.json` `"showcase"` and add a +2. Add a `tests/smoke/catalog.json` row with at least one entry in `falsifiers` (`{"args": ["--your-flag"], "expect_exit": N}`, matching the README falsifier table). The runner takes opaque script paths. diff --git a/README.md b/README.md index e0745c5b..9a296cd7 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,13 @@ The content is consumed by AI coding agents reading these files directly from a git clone https://github.com/TMHSDigital/Blender-Developer-Tools.git ``` -- **Cursor** — open your project with this checkout available and copy (or symlink) `rules/*.mdc` into your project's `.cursor/rules/`. The rules apply automatically by glob scope; skills are referenced by name in chat. +- **Cursor** — install it as a local plugin from the slim `plugin-dist` branch, then run **Developer: Reload Window**. **Customize** lists the 16 skills and 9 rules; the rules apply by glob scope and the agent loads a skill when its description matches the task. + + ```bash + git clone --branch plugin-dist --single-branch https://github.com/TMHSDigital/Blender-Developer-Tools.git ~/.cursor/plugins/local/blender-developer-tools + ``` + + (On Windows the folder is `%USERPROFILE%\.cursor\plugins\local\`. Cursor skips symlinks that point outside that folder, so clone or copy rather than link.) Teams can import the repository as a team marketplace instead; it carries `.cursor-plugin/marketplace.json`. For one project only, copy `skills/*` into the project's `.cursor/skills/` and `rules/*.mdc` into `.cursor/rules/` from a checkout. Copying only the rules gives you no skills. - **Claude Code** — install as a plugin, then run `/skills` to see all 16 skills plus `blender-rules`: ```text @@ -83,7 +89,8 @@ blender --background --python examples/bmesh-gear/bmesh_gear.py -- Releases ship often; [CHANGELOG.md](CHANGELOG.md) lists what each one changed. - **Claude Code plugin** — `/plugin marketplace update blender-developer-tools` refreshes the marketplace and its plugins (from a shell: `claude plugin update blender-developer-tools@blender-developer-tools`). Remove it with `/plugin uninstall blender-developer-tools`, then `/plugin marketplace remove blender-developer-tools`. -- **Checkout** (Cursor rules, the optional always-on Claude rules import, examples) — `git pull` in the clone. Copied `.mdc` files do not update themselves: symlink `rules/*.mdc` into `.cursor/rules/` instead of copying, or re-copy after each pull. To uninstall, delete the copied or linked rules and the `@.../blender-rules.md` line from your `CLAUDE.md`. +- **Cursor local plugin** — `git pull` in `~/.cursor/plugins/local/blender-developer-tools`, then reload the window. Remove that folder to uninstall. +- **Checkout** (per-project Cursor copies, the optional always-on Claude rules import, examples) — `git pull` in the clone. Copied `.mdc` files do not update themselves: symlink `rules/*.mdc` into `.cursor/rules/` instead of copying, or re-copy after each pull. To uninstall, delete the copied or linked rules and the `@.../blender-rules.md` line from your `CLAUDE.md`. ## Supported Blender versions diff --git a/scripts/build_plugin_dist.py b/scripts/build_plugin_dist.py index 419061f1..c291b439 100644 --- a/scripts/build_plugin_dist.py +++ b/scripts/build_plugin_dist.py @@ -6,7 +6,7 @@ as a self-contained marketplace (its marketplace.json points at "./"): skills/ claude/ rules/ snippets/ templates/ LICENSE files - .claude-plugin/plugin.json + marketplace.json, and a short README + .claude-plugin/ and .cursor-plugin/ manifests, and a short README python scripts/build_plugin_dist.py --out DIR # build (DIR must be absent or empty) python scripts/build_plugin_dist.py --out DIR --force # replace a previous build in DIR @@ -27,7 +27,10 @@ ROOT = Path(__file__).resolve().parent.parent DIRS = ("skills", "claude", "rules", "snippets", "templates") -FILES = ("LICENSE", ".claude-plugin/plugin.json") +# The Cursor manifest ships too, so Cursor users can install the same slim +# tree as a local plugin (~/.cursor/plugins/local/) instead of the full repo. +FILES = ("LICENSE", ".claude-plugin/plugin.json", + ".cursor-plugin/plugin.json", ".cursor-plugin/marketplace.json") MAX_BYTES = 2_000_000 IGNORE = shutil.ignore_patterns("__pycache__", "*.pyc") @@ -43,6 +46,9 @@ /plugin marketplace add TMHSDigital/Blender-Developer-Tools@plugin-dist /plugin install blender-developer-tools@blender-developer-tools ``` + +Cursor: clone this branch into `~/.cursor/plugins/local/blender-developer-tools` +and reload the window (Customize then lists the 16 skills and 9 rules). """ @@ -92,6 +98,11 @@ def verify(out: Path, size: int) -> list[str]: errors.append(f"{d.relative_to(out)} has no SKILL.md") if not (out / "claude" / "skills" / "blender-rules" / "SKILL.md").is_file(): errors.append("blender-rules skill missing") + cursor = json.loads((out / ".cursor-plugin" / "plugin.json").read_text(encoding="utf-8")) + for key in ("skills", "rules"): + for rel in cursor.get(key, []): + if not (out / rel).is_file(): + errors.append(f"Cursor manifest {key} path {rel} missing from the dist") if size > MAX_BYTES: errors.append(f"dist is {size} bytes, over the {MAX_BYTES} budget") return errors diff --git a/scripts/site/build_site.py b/scripts/site/build_site.py index 1cf96f82..f70dbc9b 100644 --- a/scripts/site/build_site.py +++ b/scripts/site/build_site.py @@ -407,6 +407,16 @@ def _clean_repo_url(url: str) -> str: return url +def content_dirs(repo_root: Path, top: str) -> list[str]: + """Shipped content directories under ``top`` (``examples/``, ...), + ignoring caches and private helpers.""" + base = repo_root / top + if not base.is_dir(): + return [] + return sorted(f"{top}/{d.name}" for d in base.iterdir() + if d.is_dir() and not d.name.startswith((".", "_"))) + + def load_plugin_meta(repo_root: Path, site: dict) -> dict: """Return the plugin metadata the template needs. @@ -533,6 +543,11 @@ def main(): for ex in examples: ex.setdefault("kind", "example") recent = recent_additions(repo_root, examples + showcase) + # Read from disk, not the Cursor manifest: Cursor's plugin schema rejects + # inventory keys such as "snippets" and "templates" (#347). + snippet_paths = sorted(p.relative_to(repo_root).as_posix() + for p in (repo_root / "snippets").glob("*.py")) + template_paths = content_dirs(repo_root, "templates") context = { "plugin": plugin, @@ -543,24 +558,24 @@ def main(): "rule_count": len(rules), "examples": examples, "example_count": len(examples), - # gallery.json lists rendered examples only; plugin.json lists every - # shipped example, check-only ones included (the README's count). - "example_total": len(plugin.get("examples", [])) or len(examples), + # gallery.json lists rendered examples only; the directory count + # includes check-only ones (the README's count). + "example_total": len(content_dirs(repo_root, "examples")) or len(examples), "featured_examples": featured, "featured_count": len(featured), "showcase": showcase, "showcase_count": len(showcase), "featured_showcase": pick_featured(showcase), - "snippet_count": len(plugin.get("snippets", [])), - "template_count": len(plugin.get("templates", [])), + "snippet_count": len(snippet_paths), + "template_count": len(template_paths), # basenames for display: snippets/foo-bar.py -> foo-bar "snippets": [ {"name": p.split("/")[-1].removesuffix(".py"), "path": p} - for p in plugin.get("snippets", []) + for p in snippet_paths ], "templates": [ {"name": p.split("/")[-1], "path": p} - for p in plugin.get("templates", []) + for p in template_paths ], "mcp_tools": mcp_tools, "mcp_tool_count": len(mcp_tools), diff --git a/site.json b/site.json index edff3c1c..4fd80a79 100644 --- a/site.json +++ b/site.json @@ -19,7 +19,7 @@ }, "installSteps": [ "Clone the repo: git clone https://github.com/TMHSDigital/Blender-Developer-Tools", - "Cursor: copy rules/*.mdc into your project's .cursor/rules/ — they auto-apply via scope globs; reference skills by name in chat", + "Cursor: git clone --branch plugin-dist --single-branch https://github.com/TMHSDigital/Blender-Developer-Tools.git ~/.cursor/plugins/local/blender-developer-tools, then reload the window; Customize lists the 16 skills and 9 rules", "Claude Code: run /plugin marketplace add TMHSDigital/Blender-Developer-Tools@plugin-dist then /plugin install blender-developer-tools@blender-developer-tools; the rules come with it as the blender-rules skill", "Grab snippets/ and templates/ as starting points for add-ons and headless batch jobs" ]