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
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 55 additions & 0 deletions tests/claude_plugin_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import json
import re
import subprocess
import unittest
from pathlib import Path

Expand Down Expand Up @@ -117,12 +118,66 @@ 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]


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_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):
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()
Loading