From b2cb4f2e90b200d16cc0b1dbea96eb0897014057 Mon Sep 17 00:00:00 2001 From: Zachary Lowden Date: Sat, 31 Oct 2026 23:48:44 -0500 Subject: [PATCH 1/2] docs+test: pin the skills.sh install path for the muster-setup skill `npx skills add ZacxDev/muster` already installs the plugin's own plugins/muster/skills/muster-setup/SKILL.md: the skills CLI (1.7.4, getPluginSkillPaths) searches each marketplace plugin's skills/ directory. Measured: --list finds exactly muster-setup; an install into a throwaway project wrote a byte-identical copy. So no second SKILL.md is added. The README now gives the full command (`--skill muster-setup`) and says this is muster by ZacxDev: other repositories publish unrelated skills named `muster`. tests/claude_plugin_test.py pins, for the skills CLI: - the plugin's SKILL.md is the only one in the repo (drift guard: a copy in skills/ is searched first and, measured, replaces it for skills.sh users while Claude Code keeps the original); - the marketplace entry leads the CLI to the plugin's skills/ directory; - the frontmatter's name/description stay plain YAML strings (measured: a ": " in the description makes the CLI skip the skill, 0 found); - the README's skills command names a skill the plugin ships. Red at origin/main: only the README test (the other three are invariant guards; main already satisfies them). Each new guard was mutated and failed on its own assertion. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 15 ++++-- tests/claude_plugin_test.py | 91 +++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 13955d4d0..391fe82a1 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,18 @@ repository as a plugin marketplace and install the `muster` plugin: Then ask Claude Code to "set up muster". The plugin holds one skill, `muster-setup`, which follows the same [INSTALL.md](INSTALL.md) from its URL, -so it never carries a copy of the steps. Agents that use the -[skills](https://skills.sh) CLI can install the same skill with -`npx skills add ZacxDev/muster`. +so it never carries a copy of the steps. + +Other coding agents can install the same skill with the +[skills](https://skills.sh) CLI: + +```text +npx skills add ZacxDev/muster --skill muster-setup +``` + +This is muster by ZacxDev. Other repositories publish unrelated +skills named `muster`, so install it by this repository's name, not by +searching for the word. To hack on muster, [docs/development.md](docs/development.md) runs it from source against a throwaway database. diff --git a/tests/claude_plugin_test.py b/tests/claude_plugin_test.py index 78ef4e71f..58714e6f1 100644 --- a/tests/claude_plugin_test.py +++ b/tests/claude_plugin_test.py @@ -22,6 +22,7 @@ import json import re +import subprocess import unittest from pathlib import Path @@ -117,12 +118,102 @@ def test_setup_skill_defers_to_the_guides_definition_of_done(self): self.assertEqual(guide_text().count(DONE_ANCHOR), 1) +SKILLS_ADD = "npx skills add ZacxDev/muster --skill muster-setup" +# YAML that turns a plain scalar into something other than one string: a +# mapping (": "), a comment (" #"), or a leading indicator character. +YAML_PLAIN_BREAKERS = (": ", " #") +YAML_INDICATORS = tuple("-?:,[]{}#&*!|>'\"%@`") + + +def tracked_files() -> list[str]: + # Committed files, plus untracked ones not ignored: what a push would + # publish, so a stray copy fails here before it is committed. + out = subprocess.run( + ["git", "-C", str(ROOT), "ls-files", "-z", "--cached", "--others", "--exclude-standard"], + check=True, + capture_output=True, + ).stdout + return [p for p in out.decode().split("\0") if p] + + +def skills_cli_plugin_dirs() -> list[Path]: + """The directories the `skills` CLI (vercel-labs/skills 1.7.4, + getPluginSkillPaths) adds to its search for each marketplace plugin: the + plugin's own `skills/` directory always, plus the parent of each declared + `skills` entry. It ignores a plugin whose `source` (or a `pluginRoot`) + does not start with "./".""" + m = marketplace() + root = m.get("metadata", {}).get("pluginRoot") + if root is not None and not root.startswith("./"): + return [] + dirs = [] + for p in m["plugins"]: + src = p.get("source") + if src is not None and not (isinstance(src, str) and src.startswith("./")): + continue + base = ROOT / (root or "") / (src or "") + for declared in p.get("skills") or []: + if declared.startswith("./"): + dirs.append((base / declared).parent.resolve()) + dirs.append((base / "skills").resolve()) + return dirs + + +class SkillsCli(unittest.TestCase): + """`npx skills add ZacxDev/muster` installs the plugin's own SKILL.md. + + Measured with skills 1.7.4 against this repository: it finds exactly + `muster-setup` and installs a byte-identical copy. A second SKILL.md in any + of the CLI's standard locations (`skills/`, `.claude/skills/`, the root, + ...) is searched BEFORE the plugin's directory, so a copy there with the + same name silently replaces the plugin's skill for skills.sh users while + Claude Code keeps installing the original. One file serves both tools.""" + + PLUGIN_SKILL = "plugins/muster/skills/muster-setup/SKILL.md" + + def test_the_plugins_skill_is_the_only_skill_file(self): + found = [p for p in tracked_files() if p.rsplit("/", 1)[-1] == "SKILL.md"] + self.assertEqual(found, [self.PLUGIN_SKILL]) + + def test_the_cli_searches_the_plugins_skill_directory(self): + # Through the marketplace, not by luck: when nothing turns up in the + # searched directories the CLI falls back to a recursive walk, which + # would also find this file today, but only while it is the repo's only + # SKILL.md anywhere. + skill_dir = (ROOT / self.PLUGIN_SKILL).parent.resolve() + self.assertIn(skill_dir.parent, skills_cli_plugin_dirs()) + + def test_frontmatter_parses_as_two_plain_strings(self): + # The CLI parses frontmatter as YAML and skips a skill whose `name` or + # `description` is missing or not a string; frontmatter() above reads + # lines, so it would accept a value YAML reads as something else. + fm = frontmatter(ROOT / self.PLUGIN_SKILL) + for key in ("name", "description"): + value = fm.get(key, "") + self.assertTrue(value, key) + for breaker in YAML_PLAIN_BREAKERS: + self.assertNotIn(breaker, value, key) + self.assertFalse(value.startswith(YAML_INDICATORS), key) + # A `metadata` block can mark a skill `internal: true`, which the CLI + # hides from a plain `skills add`. + self.assertNotIn("metadata", fm) + + class Readme(unittest.TestCase): def test_install_from_inside_claude_code(self): text = (ROOT / "README.md").read_text() self.assertIn("/plugin marketplace add ZacxDev/muster", text) self.assertIn("/plugin install muster@muster", text) + def test_install_with_the_skills_cli(self): + # Other repositories publish unrelated skills named `muster`, so the + # README names this repository and the skill explicitly. + text = (ROOT / "README.md").read_text() + self.assertIn(f"\n{SKILLS_ADD}\n", text) + self.assertIn("muster by ZacxDev", text) + # The skill the command names is one the plugin actually ships. + self.assertIn(SKILLS_ADD.rsplit(" ", 1)[-1], Skills().skills()) + if __name__ == "__main__": unittest.main() From a433a029f8a51b2dfcc3646264c378d4e8c81586 Mon Sep 17 00:00:00 2001 From: Zachary Lowden Date: Sun, 1 Nov 2026 00:51:47 -0500 Subject: [PATCH 2/2] test: trim the skills CLI guards to the ones round 0 kept Drop test_the_cli_searches_the_plugins_skill_directory and its helper skills_cli_plugin_dirs(): it re-implemented the skills CLI's private search logic, and the existing source/skill-set tests already pin the layout it depends on. Cut the YAML frontmatter check to the description only (the name is pinned by test_frontmatter's regex; the metadata assertion is gone). The only-one-SKILL.md drift guard already counts untracked files that are not ignored (git ls-files --others --exclude-standard); an untracked skills/muster-setup/SKILL.md fails it. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/claude_plugin_test.py | 54 +++++++------------------------------ 1 file changed, 9 insertions(+), 45 deletions(-) diff --git a/tests/claude_plugin_test.py b/tests/claude_plugin_test.py index 58714e6f1..caae56530 100644 --- a/tests/claude_plugin_test.py +++ b/tests/claude_plugin_test.py @@ -136,29 +136,6 @@ def tracked_files() -> list[str]: return [p for p in out.decode().split("\0") if p] -def skills_cli_plugin_dirs() -> list[Path]: - """The directories the `skills` CLI (vercel-labs/skills 1.7.4, - getPluginSkillPaths) adds to its search for each marketplace plugin: the - plugin's own `skills/` directory always, plus the parent of each declared - `skills` entry. It ignores a plugin whose `source` (or a `pluginRoot`) - does not start with "./".""" - m = marketplace() - root = m.get("metadata", {}).get("pluginRoot") - if root is not None and not root.startswith("./"): - return [] - dirs = [] - for p in m["plugins"]: - src = p.get("source") - if src is not None and not (isinstance(src, str) and src.startswith("./")): - continue - base = ROOT / (root or "") / (src or "") - for declared in p.get("skills") or []: - if declared.startswith("./"): - dirs.append((base / declared).parent.resolve()) - dirs.append((base / "skills").resolve()) - return dirs - - class SkillsCli(unittest.TestCase): """`npx skills add ZacxDev/muster` installs the plugin's own SKILL.md. @@ -175,28 +152,15 @@ def test_the_plugins_skill_is_the_only_skill_file(self): found = [p for p in tracked_files() if p.rsplit("/", 1)[-1] == "SKILL.md"] self.assertEqual(found, [self.PLUGIN_SKILL]) - def test_the_cli_searches_the_plugins_skill_directory(self): - # Through the marketplace, not by luck: when nothing turns up in the - # searched directories the CLI falls back to a recursive walk, which - # would also find this file today, but only while it is the repo's only - # SKILL.md anywhere. - skill_dir = (ROOT / self.PLUGIN_SKILL).parent.resolve() - self.assertIn(skill_dir.parent, skills_cli_plugin_dirs()) - - def test_frontmatter_parses_as_two_plain_strings(self): - # The CLI parses frontmatter as YAML and skips a skill whose `name` or - # `description` is missing or not a string; frontmatter() above reads - # lines, so it would accept a value YAML reads as something else. - fm = frontmatter(ROOT / self.PLUGIN_SKILL) - for key in ("name", "description"): - value = fm.get(key, "") - self.assertTrue(value, key) - for breaker in YAML_PLAIN_BREAKERS: - self.assertNotIn(breaker, value, key) - self.assertFalse(value.startswith(YAML_INDICATORS), key) - # A `metadata` block can mark a skill `internal: true`, which the CLI - # hides from a plain `skills add`. - self.assertNotIn("metadata", fm) + def test_description_parses_as_a_plain_string(self): + # The CLI parses frontmatter as YAML and skips a skill whose + # `description` is not a string; frontmatter() above reads lines, so it + # would accept a value YAML reads as something else. + value = frontmatter(ROOT / self.PLUGIN_SKILL).get("description", "") + self.assertTrue(value) + for breaker in YAML_PLAIN_BREAKERS: + self.assertNotIn(breaker, value) + self.assertFalse(value.startswith(YAML_INDICATORS)) class Readme(unittest.TestCase):