diff --git a/AGENTS.md b/AGENTS.md index 5cb4ca0..eefcb08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,17 @@ Requires Python 3.14+. Run `make install` before first use to set up the virtual The LLM tests read two environment variables: `OLLAMA_MODEL_NAME` (the DeepEval judge model, defaults to `gemma4`) and `SLACK_MCP_TOKEN` (a Slack MCP bearer token; the MCP tool-selection test is skipped when it's unset). Copy `.env.example` to `.env` and fill in values — the `Makefile` auto-loads `.env` — or pass them inline, e.g. `OLLAMA_MODEL_NAME= make test-eval`. +## Cross-Skill References + +When one `SKILL.md` references another skill (e.g., to delegate a step instead of duplicating content), follow these rules: + +- Use the backticked `plugin:skill` form, e.g. `` `slack:slack-cli` ``. +- When pointing at a specific step, include the step's heading text, not just the number — references survive future reordering. +- Add a sentence of prose explaining what the referenced section does and why you're delegating to it. +- Don't use markdown anchor links (`[text](#step-1)`), `@`-include syntax (`@path/to/SKILL.md`), or bare file paths — none are idiomatic in installed skills, and `@`-includes force-load context. + +See `skills/create-slack-app/SKILL.md` Step 1a for an example. + ## Testing Two test layers validate skills: diff --git a/tests/config.py b/tests/config.py index 7b4a015..376fc66 100644 --- a/tests/config.py +++ b/tests/config.py @@ -1,9 +1,14 @@ +import json import os from pathlib import Path # Filesystem SKILLS_ROOT = Path(__file__).parent.parent / "skills" +# Plugin namespace (single source of truth: the plugin manifest) +PLUGIN_MANIFEST = Path(__file__).parent.parent / ".claude-plugin" / "plugin.json" +PLUGIN_NAME = json.loads(PLUGIN_MANIFEST.read_text())["name"] + # Skill inventory (single source of truth) EXPECTED_SKILLS = ("create-slack-app", "block-kit", "slack-api", "slack-cli") diff --git a/tests/unit/test_cross_skill_references.py b/tests/unit/test_cross_skill_references.py new file mode 100644 index 0000000..9bc45a9 --- /dev/null +++ b/tests/unit/test_cross_skill_references.py @@ -0,0 +1,45 @@ +import re + +from tests.config import PLUGIN_NAME +from tests.skill import discover_skills + + +class TestCrossSkillReferences: + def setup_method(self): + self.skills = discover_skills() + self.skill_names = {skill.metadata.name for skill in self.skills} + + def test_plugin_skill_references_target_real_skills(self): + pattern = re.compile(rf"`{re.escape(PLUGIN_NAME)}:([a-z0-9-]+)`") + for skill in self.skills: + for target in pattern.findall(skill.body): + assert ( + target in self.skill_names + ), f"{skill.path} references unknown skill `{PLUGIN_NAME}:{target}`" + + def test_no_markdown_anchor_links(self): + for skill in self.skills: + # .find() returns -1 if the substring is not found + anchor_index = skill.body.find("](#") + + snippet = skill.body[anchor_index : anchor_index + 30] + assert anchor_index == -1, ( + f"'{skill.path}' uses a markdown anchor link near " + f'"{snippet}"; ' + "cross-skill references must not use `[text](#anchor)` links" + ) + + def test_no_bare_skill_file_paths(self): + for skill in self.skills: + # .find() returns -1 if the substring is not found + path_index = skill.body.find("SKILL.md") + + # A bare path precedes "SKILL.md" (e.g. "skills/foo/SKILL.md"), so + # the error window reaches back to show that leading context. + start = max(0, path_index - 30) + snippet = skill.body[start : path_index + 8] + assert path_index == -1, ( + f"'{skill.path}' references a SKILL.md path near " + f'"{snippet}"; ' + "reference skills by the backticked `plugin:skill` form instead" + )