Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions scripts/build_plugin_dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,34 @@

Cursor: clone this branch into `~/.cursor/plugins/local/blender-developer-tools`
and reload the window (Customize then lists the 16 skills and 9 rules).

Skills reference bundled files as `${CLAUDE_PLUGIN_ROOT}/snippets/...`, which
Claude Code expands to this plugin's install directory. In Cursor, read it as
the root of this clone. Other repo links are pinned to the release tag.
"""


# The release rewrites only "version" values in the manifests, so a build whose
# fingerprint matches the published branch carries no new plugin content.
REPO_URL = "https://github.com/TMHSDigital/Blender-Developer-Tools"
# Skills Claude Code loads get links into the installed plugin instead of main,
# so a pinned install reads the snippet it shipped with, offline (#394).
# Claude Code substitutes ${CLAUDE_PLUGIN_ROOT} in plugin skill content.
_LOCAL_LINK = re.compile(re.escape(REPO_URL) + r"/(?:blob|tree)/main/((?:snippets|templates|rules|claude|skills)(?:/[^\s)`\"'#]*)?)")
_MAIN_LINK = re.compile(re.escape(REPO_URL) + r"/(blob|tree)/main/")
_LOCAL_REF = re.compile(r"\$\{CLAUDE_PLUGIN_ROOT\}/([^\s)`\"'#]+)")
TEXT_SUFFIXES = {".md", ".mdc", ".py", ".toml"}

# The release rewrites only "version" values in the manifests and the release
# tag in pinned links, so a build whose fingerprint matches the published
# branch carries no new plugin content.
_VERSION = re.compile(rb'("version"\s*:\s*")[^"]*(")')
_TAG = re.compile(rb"(/(?:blob|tree)/)v\d+\.\d+\.\d+/")


def rewrite_links(text: str, tag: str, local: bool) -> str:
"""Point repo links at the installed plugin (`local`) or at the release tag."""
if local:
text = _LOCAL_LINK.sub(r"${CLAUDE_PLUGIN_ROOT}/\1", text)
return _MAIN_LINK.sub(lambda m: f"{REPO_URL}/{m.group(1)}/{tag}/", text)


def fingerprint(tree: Path) -> str:
Expand All @@ -69,6 +91,8 @@ def fingerprint(tree: Path) -> str:
data = p.read_bytes().replace(b"\r\n", b"\n")
if p.suffix == ".json":
data = _VERSION.sub(rb"\1\2", data)
elif p.suffix in TEXT_SUFFIXES:
data = _TAG.sub(rb"\1", data)
h.update(p.relative_to(tree).as_posix().encode() + b"\0" + data + b"\0")
return h.hexdigest()

Expand Down Expand Up @@ -107,6 +131,13 @@ def build(out: Path) -> int:
(out / ".claude-plugin" / "marketplace.json").write_text(
json.dumps(market, indent=2) + "\n", encoding="utf-8", newline="\n")
(out / "README.md").write_text(DIST_README, encoding="utf-8", newline="\n")
tag = "v" + (ROOT / "VERSION").read_text(encoding="utf-8").strip()
for p in out.rglob("*"):
if p.is_file() and p.suffix in TEXT_SUFFIXES:
text = p.read_text(encoding="utf-8")
new = rewrite_links(text, tag, local=p.name == "SKILL.md")
if new != text:
p.write_text(new, encoding="utf-8", newline="")
return sum(p.stat().st_size for p in out.rglob("*") if p.is_file())


Expand All @@ -124,6 +155,16 @@ def verify(out: Path, size: int) -> list[str]:
for rel in cursor.get(key, []):
if not (out / rel).is_file():
errors.append(f"Cursor manifest {key} path {rel} missing from the dist")
for p in out.rglob("*"):
if not (p.is_file() and p.suffix in TEXT_SUFFIXES):
continue
text = p.read_text(encoding="utf-8")
rel = p.relative_to(out).as_posix()
for target in _LOCAL_REF.findall(text):
if not (out / target.rstrip(".,;:")).exists():
errors.append(f"{rel} links ${{CLAUDE_PLUGIN_ROOT}}/{target}, which is not in the dist")
if _MAIN_LINK.search(text):
errors.append(f"{rel} still links the repo's main branch")
if size > MAX_BYTES:
errors.append(f"dist is {size} bytes, over the {MAX_BYTES} budget")
return errors
Expand Down
48 changes: 47 additions & 1 deletion tests/test_build_plugin_dist.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Safety tests for scripts/build_plugin_dist.py --out. No Blender required.
"""Tests for scripts/build_plugin_dist.py: --out safety and link rewriting. No Blender required.

build() replaces --out wholesale, so the script must refuse a target it
would destroy (the repo, a parent of it, a git checkout, or a non-empty
Expand Down Expand Up @@ -70,5 +70,51 @@ def test_force_replaces_previous_build(self):
self.assertFalse((out / "stale.txt").exists())


class Links(unittest.TestCase):
# #394: skills read the files the plugin installed, everything else pins
# the release tag, and the tag never makes a release look like new content.
URL = b.REPO_URL

def test_skill_links_resolve_inside_the_plugin(self):
text = f"[s]({self.URL}/blob/main/snippets/lod_chain.py) [t]({self.URL}/tree/main/templates)"
self.assertEqual(b.rewrite_links(text, "v1.2.3", local=True),
"[s](${CLAUDE_PLUGIN_ROOT}/snippets/lod_chain.py) [t](${CLAUDE_PLUGIN_ROOT}/templates)")

def test_other_links_pin_the_release_tag(self):
text = f"[e]({self.URL}/tree/main/examples/bmesh-gear) [r]({self.URL}/blob/main/rules/x.mdc)"
self.assertEqual(b.rewrite_links(text, "v1.2.3", local=False),
f"[e]({self.URL}/tree/v1.2.3/examples/bmesh-gear) [r]({self.URL}/blob/v1.2.3/rules/x.mdc)")

def test_built_dist_has_no_main_links_and_every_local_link_resolves(self):
with tempfile.TemporaryDirectory() as td:
out = Path(td) / "dist"
size = b.build(out)
self.assertEqual(b.verify(out, size), [])
skill = (out / "skills" / "ai-mesh-cleanup" / "SKILL.md").read_text(encoding="utf-8")
self.assertIn("${CLAUDE_PLUGIN_ROOT}/snippets/", skill)

def test_verify_flags_a_missing_local_target_and_a_main_link(self):
with tempfile.TemporaryDirectory() as td:
out = Path(td) / "dist"
size = b.build(out)
skill = out / "skills" / "ai-mesh-cleanup" / "SKILL.md"
skill.write_text(skill.read_text(encoding="utf-8")
+ "\n${CLAUDE_PLUGIN_ROOT}/snippets/gone.py\n"
+ f"{self.URL}/blob/main/README.md\n", encoding="utf-8")
errors = "\n".join(b.verify(out, size))
self.assertIn("snippets/gone.py", errors)
self.assertIn("main branch", errors)

def test_fingerprint_ignores_the_release_tag(self):
with tempfile.TemporaryDirectory() as td:
a, c = Path(td) / "a", Path(td) / "c"
for d, tag in ((a, "v0.1.0"), (c, "v0.2.0")):
d.mkdir()
(d / "x.md").write_text(f"{self.URL}/blob/{tag}/docs/y.md\n", encoding="utf-8")
self.assertEqual(b.fingerprint(a), b.fingerprint(c))
(c / "x.md").write_text(f"{self.URL}/blob/v0.2.0/docs/z.md\n", encoding="utf-8")
self.assertNotEqual(b.fingerprint(a), b.fingerprint(c))


if __name__ == "__main__":
unittest.main()
Loading