Skip to content

Commit 9472f4c

Browse files
TMHSDigitalclaude
andauthored
fix(plugin): point skills at the files the plugin installed (#439)
The plugin ships rules/, snippets/ and templates/, but every skill linked them on GitHub main. Reading a snippet needed the network, and a pinned install read main's copy, which may no longer match its skill text. build_plugin_dist.py now rewrites, in the dist only: - SKILL.md links to snippets/, templates/, rules/, claude/ and skills/ become ${CLAUDE_PLUGIN_ROOT}/..., which Claude Code substitutes in plugin skill content. - Every other blob/main or tree/main link pins the release tag. verify() fails the build when a ${CLAUDE_PLUGIN_ROOT} target is missing from the dist or any main link survives. The fingerprint ignores the tag, so a release with unchanged content still does not republish (#350). Closes #394 Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent eb4a424 commit 9472f4c

2 files changed

Lines changed: 90 additions & 3 deletions

File tree

‎scripts/build_plugin_dist.py‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,34 @@
5252
5353
Cursor: clone this branch into `~/.cursor/plugins/local/blender-developer-tools`
5454
and reload the window (Customize then lists the 16 skills and 9 rules).
55+
56+
Skills reference bundled files as `${CLAUDE_PLUGIN_ROOT}/snippets/...`, which
57+
Claude Code expands to this plugin's install directory. In Cursor, read it as
58+
the root of this clone. Other repo links are pinned to the release tag.
5559
"""
5660

5761

58-
# The release rewrites only "version" values in the manifests, so a build whose
59-
# fingerprint matches the published branch carries no new plugin content.
62+
REPO_URL = "https://github.com/TMHSDigital/Blender-Developer-Tools"
63+
# Skills Claude Code loads get links into the installed plugin instead of main,
64+
# so a pinned install reads the snippet it shipped with, offline (#394).
65+
# Claude Code substitutes ${CLAUDE_PLUGIN_ROOT} in plugin skill content.
66+
_LOCAL_LINK = re.compile(re.escape(REPO_URL) + r"/(?:blob|tree)/main/((?:snippets|templates|rules|claude|skills)(?:/[^\s)`\"'#]*)?)")
67+
_MAIN_LINK = re.compile(re.escape(REPO_URL) + r"/(blob|tree)/main/")
68+
_LOCAL_REF = re.compile(r"\$\{CLAUDE_PLUGIN_ROOT\}/([^\s)`\"'#]+)")
69+
TEXT_SUFFIXES = {".md", ".mdc", ".py", ".toml"}
70+
71+
# The release rewrites only "version" values in the manifests and the release
72+
# tag in pinned links, so a build whose fingerprint matches the published
73+
# branch carries no new plugin content.
6074
_VERSION = re.compile(rb'("version"\s*:\s*")[^"]*(")')
75+
_TAG = re.compile(rb"(/(?:blob|tree)/)v\d+\.\d+\.\d+/")
76+
77+
78+
def rewrite_links(text: str, tag: str, local: bool) -> str:
79+
"""Point repo links at the installed plugin (`local`) or at the release tag."""
80+
if local:
81+
text = _LOCAL_LINK.sub(r"${CLAUDE_PLUGIN_ROOT}/\1", text)
82+
return _MAIN_LINK.sub(lambda m: f"{REPO_URL}/{m.group(1)}/{tag}/", text)
6183

6284

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

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

112143

@@ -124,6 +155,16 @@ def verify(out: Path, size: int) -> list[str]:
124155
for rel in cursor.get(key, []):
125156
if not (out / rel).is_file():
126157
errors.append(f"Cursor manifest {key} path {rel} missing from the dist")
158+
for p in out.rglob("*"):
159+
if not (p.is_file() and p.suffix in TEXT_SUFFIXES):
160+
continue
161+
text = p.read_text(encoding="utf-8")
162+
rel = p.relative_to(out).as_posix()
163+
for target in _LOCAL_REF.findall(text):
164+
if not (out / target.rstrip(".,;:")).exists():
165+
errors.append(f"{rel} links ${{CLAUDE_PLUGIN_ROOT}}/{target}, which is not in the dist")
166+
if _MAIN_LINK.search(text):
167+
errors.append(f"{rel} still links the repo's main branch")
127168
if size > MAX_BYTES:
128169
errors.append(f"dist is {size} bytes, over the {MAX_BYTES} budget")
129170
return errors

‎tests/test_build_plugin_dist.py‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Safety tests for scripts/build_plugin_dist.py --out. No Blender required.
1+
"""Tests for scripts/build_plugin_dist.py: --out safety and link rewriting. No Blender required.
22
33
build() replaces --out wholesale, so the script must refuse a target it
44
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):
7070
self.assertFalse((out / "stale.txt").exists())
7171

7272

73+
class Links(unittest.TestCase):
74+
# #394: skills read the files the plugin installed, everything else pins
75+
# the release tag, and the tag never makes a release look like new content.
76+
URL = b.REPO_URL
77+
78+
def test_skill_links_resolve_inside_the_plugin(self):
79+
text = f"[s]({self.URL}/blob/main/snippets/lod_chain.py) [t]({self.URL}/tree/main/templates)"
80+
self.assertEqual(b.rewrite_links(text, "v1.2.3", local=True),
81+
"[s](${CLAUDE_PLUGIN_ROOT}/snippets/lod_chain.py) [t](${CLAUDE_PLUGIN_ROOT}/templates)")
82+
83+
def test_other_links_pin_the_release_tag(self):
84+
text = f"[e]({self.URL}/tree/main/examples/bmesh-gear) [r]({self.URL}/blob/main/rules/x.mdc)"
85+
self.assertEqual(b.rewrite_links(text, "v1.2.3", local=False),
86+
f"[e]({self.URL}/tree/v1.2.3/examples/bmesh-gear) [r]({self.URL}/blob/v1.2.3/rules/x.mdc)")
87+
88+
def test_built_dist_has_no_main_links_and_every_local_link_resolves(self):
89+
with tempfile.TemporaryDirectory() as td:
90+
out = Path(td) / "dist"
91+
size = b.build(out)
92+
self.assertEqual(b.verify(out, size), [])
93+
skill = (out / "skills" / "ai-mesh-cleanup" / "SKILL.md").read_text(encoding="utf-8")
94+
self.assertIn("${CLAUDE_PLUGIN_ROOT}/snippets/", skill)
95+
96+
def test_verify_flags_a_missing_local_target_and_a_main_link(self):
97+
with tempfile.TemporaryDirectory() as td:
98+
out = Path(td) / "dist"
99+
size = b.build(out)
100+
skill = out / "skills" / "ai-mesh-cleanup" / "SKILL.md"
101+
skill.write_text(skill.read_text(encoding="utf-8")
102+
+ "\n${CLAUDE_PLUGIN_ROOT}/snippets/gone.py\n"
103+
+ f"{self.URL}/blob/main/README.md\n", encoding="utf-8")
104+
errors = "\n".join(b.verify(out, size))
105+
self.assertIn("snippets/gone.py", errors)
106+
self.assertIn("main branch", errors)
107+
108+
def test_fingerprint_ignores_the_release_tag(self):
109+
with tempfile.TemporaryDirectory() as td:
110+
a, c = Path(td) / "a", Path(td) / "c"
111+
for d, tag in ((a, "v0.1.0"), (c, "v0.2.0")):
112+
d.mkdir()
113+
(d / "x.md").write_text(f"{self.URL}/blob/{tag}/docs/y.md\n", encoding="utf-8")
114+
self.assertEqual(b.fingerprint(a), b.fingerprint(c))
115+
(c / "x.md").write_text(f"{self.URL}/blob/v0.2.0/docs/z.md\n", encoding="utf-8")
116+
self.assertNotEqual(b.fingerprint(a), b.fingerprint(c))
117+
118+
73119
if __name__ == "__main__":
74120
unittest.main()

0 commit comments

Comments
 (0)