diff --git a/scripts/build_plugin_dist.py b/scripts/build_plugin_dist.py index ee49e510..bdcd2f34 100644 --- a/scripts/build_plugin_dist.py +++ b/scripts/build_plugin_dist.py @@ -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: @@ -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() @@ -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()) @@ -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 diff --git a/tests/test_build_plugin_dist.py b/tests/test_build_plugin_dist.py index e31d5e7a..93fb5777 100644 --- a/tests/test_build_plugin_dist.py +++ b/tests/test_build_plugin_dist.py @@ -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 @@ -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()