diff --git a/skills/list-skills/SKILL.md b/skills/list-skills/SKILL.md index 667b0b4e5..a70004083 100644 --- a/skills/list-skills/SKILL.md +++ b/skills/list-skills/SKILL.md @@ -5,12 +5,15 @@ name: magpie-list-skills family: utilities mode: Meta description: | - Print a human-readable index of every skill in this repository, - grouped by family prefix (`pr-management`, `security`, `setup`, - …) with each skill's name and the first sentence of its - `description`. The listing is generated on every run from the - live `.claude/skills/*/SKILL.md` files, so it never goes stale - when skills are added, removed, or rewritten. + Print a human-readable index of every skill installed for this + repository, grouped by the family each one declares, with the + name to invoke it by and the first sentence of its + `description`. Discovery is installation-aware: it covers a + pinned snapshot install, the framework checkout, and + marketplace plugin installs, so the index matches what the + agent can actually run. Generated on every run from live + `SKILL.md` frontmatter, so it never goes stale when skills are + added, removed, or rewritten. when_to_use: | Invoke when a human asks *"what skills are available"*, *"list the skills"*, *"show me the skills in this repo"*, *"give me a @@ -35,20 +38,34 @@ license: Apache-2.0 # list-skills -Print a human-readable index of the skills in this repository. -The index is generated on every run from the live -`.claude/skills/*/SKILL.md` files — there is no cached copy to -keep in sync. The skill exists for humans (newcomers reading the -repo, maintainers checking what is available); agents route -invocations via the same frontmatter the script reads, so this -skill is purely informational. +Print a human-readable index of the skills installed for this +repository. The index is generated on every run from live +`SKILL.md` frontmatter — there is no cached copy to keep in sync. +The skill exists for humans (newcomers reading the repo, +maintainers checking what is available); agents route invocations +via the same frontmatter the script reads, so this skill is +purely informational. + +What counts as "installed" depends on how Magpie was put in +place, so the script covers all three shapes and labels which one +each entry came from: + +| Install | Where the skills live | Invocation shown | +|---|---|---| +| Pinned snapshot | `.agents/skills/` plus the per-agent relays beside it | `/magpie-` | +| Framework checkout | the repo's own `skills/` | `/magpie-` | +| Marketplace plugin | the plugin cache this script runs from, and its sibling plugins | `/:` | --- ## Prerequisites -- Python 3.9+ on `PATH` with `PyYAML` importable. The framework's - Python toolchain already meets this; no extra setup. +- Python 3.11+ on `PATH`. Nothing else — the script is + stdlib-only, as `skills/pyproject.toml` requires of every + helper script in this tree, and declares that contract in + [PEP 723](https://peps.python.org/pep-0723/) inline metadata. + `uv run --script` and a bare `python3` therefore behave + identically. --- @@ -61,26 +78,54 @@ verbatim: python3 .claude/skills/magpie-list-skills/scripts/list_skills.py ``` +Run that command **literally**, as written — do not expand it to +an absolute path. It is a repository-relative path that resolves +under both install methods that put skills in the repository: a +pinned snapshot install and the framework checkout both carry +`.claude/skills/magpie-list-skills` as a symlink onto the real +skill directory. + For a layout that puts each description on its own indented line -(easier to read when descriptions are long), pass `--verbose`: +(easier to read when descriptions are long), pass `--verbose`; to +inspect a repository other than the enclosing one, pass `--root`: ```bash python3 .claude/skills/magpie-list-skills/scripts/list_skills.py --verbose +python3 .claude/skills/magpie-list-skills/scripts/list_skills.py --root /path/to/repo ``` -The script: +**Marketplace installs are the one exception.** They write nothing +into the repository, so that path does not exist — the skill lives +in the plugin cache. Build the command from the base directory +reported for this skill instead: -- walks `.claude/skills/*/SKILL.md` relative to its own location; -- parses each skill's YAML frontmatter for `name` + `description`; -- groups skills by family prefix (the first hyphen-separated - token, with `pr-management` recognised as a two-token family — - see [`KNOWN_TWO_TOKEN_FAMILIES`](scripts/list_skills.py)); -- prints each skill with the first sentence of its description. +```bash +python3 /scripts/list_skills.py +``` + +The script: -When a new multi-token family appears (e.g. a hypothetical -`docs-build-*`), add the prefix to `KNOWN_TWO_TOKEN_FAMILIES` in -[`scripts/list_skills.py`](scripts/list_skills.py); otherwise the -new skills land under the single-token head. +- resolves the repository from `git rev-parse --show-toplevel` + (or `--root`), **not** from its own location — under a + per-family plugin install its own location is one family, not + the whole install; +- walks the agent-target directories that install writes into + (`.agents/skills/` and its relays — the registry in + [`../setup/agents.md`](../setup/agents.md) is the source of + truth), the framework's own `skills/` when the repo is the + framework checkout, and the sibling plugins in the marketplace + cache when it is running from one; +- de-duplicates by the name you would type, so relay directories + collapse to one entry while a skill available from *two* + install methods keeps both — they are two different things to + type; +- groups by each skill's declared `family:` frontmatter key, per + Golden rule 8. Family is **never** inferred from the name + prefix: `repo-health` and `contributor-growth` span several + prefixes, and `write-skill` is family `utilities`, not family + `write`. A skill that declares no family lands in `other`; +- prints each entry with the first sentence of its description, + then a summary of which install each entry came from. --- @@ -97,7 +142,8 @@ read that skill's `SKILL.md` and answer from it. ## Hard rules - **Read-only.** This skill never edits, creates, or deletes - files. It only reads `SKILL.md` files under `.claude/skills/`. + files. It only reads `SKILL.md` files under the install + directories listed above. - **No paraphrasing.** Always present the script output verbatim. Paraphrasing reintroduces the staleness this skill exists to prevent. @@ -110,7 +156,11 @@ read that skill's `SKILL.md` and answer from it. listing script Step 1 invokes. - [`AGENTS.md`](../../AGENTS.md#reusable-skills) — the framework's "Reusable skills" section, which explains the - `.claude/skills/` layout and frontmatter convention. + skills layout and frontmatter convention. +- [`../setup/agents.md`](../setup/agents.md) — the agent-target + registry the discovery list mirrors. +- [`../../docs/setup/marketplaces.md`](../../docs/setup/marketplaces.md) + — why the invocation name differs between install methods. - [`write-skill`](../write-skill/SKILL.md) — sibling skill for authoring a new skill. Use it when the listing reveals a gap that warrants a new entry. diff --git a/skills/list-skills/scripts/list_skills.py b/skills/list-skills/scripts/list_skills.py index 1111c9d04..2829f7ce4 100644 --- a/skills/list-skills/scripts/list_skills.py +++ b/skills/list-skills/scripts/list_skills.py @@ -8,114 +8,279 @@ # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 -"""Print a human-readable index of skills in this repository. +# +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""Print a human-readable index of the skills installed for this repository. + +Discovery is **installation-aware**: what a person can invoke depends on how +Magpie was installed, so the script looks in every place an install can put a +skill, rather than assuming its own location is the whole story. + +Three sources, unioned: + +1. **Agent-target directories in the repository** — ``.agents/skills/`` (the + canonical home) and the per-agent relays beside it. This is what a pinned + snapshot install writes, and what the framework checkout commits for + self-adoption. +2. **The framework's own ``skills/``** — present when the current repository is + the framework checkout itself. +3. **Marketplace plugin installs** — when this script is running from an + installed plugin, its sibling plugins under the same marketplace cache. A + marketplace install puts nothing in the repository, so a repository-only + walk would report an empty or partial index. + +Two things this deliberately does *not* do, both of which were bugs: -Walks ``.claude/skills/*/SKILL.md`` (relative to the script's own -location), parses the YAML frontmatter, and prints each skill's -name plus the first sentence of its ``description``, grouped by -the family prefix derived from the directory name -(e.g. ``security-issue-triage`` → family ``security``). +- It does not derive the skills directory from the script's own location. + Under a per-family plugin install that resolves to the one family that + happens to ship this script, so ``list-skills`` reported 5 skills out of 74. +- It does not infer the family from the skill's name prefix. Family membership + is read from the ``family:`` frontmatter key (AGENTS.md Golden rule 8) — + families such as ``repo-health`` and ``contributor-growth`` span several name + prefixes, and the prefix heuristic invented families like ``write/`` and + ``optimize/`` for skills whose declared family is ``utilities``. -The output is generated on every run from the live filesystem, so -it never goes stale: adding a skill, renaming one, or rewriting a -description is reflected immediately. +Frontmatter is parsed with the standard library only. ``skills/pyproject.toml`` +declares this tree stdlib-only ("the helper scripts deliberately carry no +runtime dependencies so an adopter can run them without installing anything"), +and this script importing PyYAML was the sole violation — it crashed with +``ModuleNotFoundError: No module named 'yaml'`` on any interpreter that had not +had PyYAML installed into it, which includes the plugin-install case. The +inline script metadata above states the same contract in machine-readable form, +so ``uv run --script`` and a bare ``python3`` behave identically. Usage:: - python3 .claude/skills/magpie-list-skills/scripts/list_skills.py - python3 .claude/skills/magpie-list-skills/scripts/list_skills.py --verbose + python3 /list_skills.py + python3 /list_skills.py --verbose + python3 /list_skills.py --root /path/to/repo """ from __future__ import annotations import argparse import re +import subprocess import sys from collections import defaultdict from pathlib import Path -import yaml +# Directories an install may wire skills into, relative to the repository root. +# The single source of truth for this registry is the table under +# "## The registry" in skills/setup/agents.md; the first entry is the canonical +# home every other target relays into. Mirrored here as a plain list because +# this script needs only the directory names, not the registry's semantics — +# keep it a faithful mirror when a vendor row is added there. +AGENT_SKILL_DIRS: tuple[str, ...] = ( + ".agents/skills", + ".claude/skills", + ".github/skills", + ".windsurf/skills", + ".goose/skills", + ".kiro/skills", +) -# Two-token family prefixes that should not be split on the first hyphen. -# Add to this list when a new multi-token family appears. -KNOWN_TWO_TOKEN_FAMILIES: tuple[str, ...] = ("pr-management",) +# The framework's own skill tree, present when the repository is the framework +# checkout (self-adoption, `method:local`). +FRAMEWORK_SKILLS_DIR = "skills" +SKILL_MD = "SKILL.md" -def find_skills_dir(start: Path) -> Path: - """Resolve the framework ``skills/`` directory from the script's location.""" - # Script lives at skills/list-skills/scripts/list_skills.py; - # parents[2] is the skills/ root. - return start.resolve().parents[2] +# Frontmatter keys whose value may be a YAML block scalar (`key: |`). +_BLOCK_MARKERS = {"|", ">", "|-", ">-", "|+", ">+"} -def family_for(skill_name: str) -> str: - for prefix in KNOWN_TWO_TOKEN_FAMILIES: - if skill_name == prefix or skill_name.startswith(f"{prefix}-"): - return prefix - head, _, _ = skill_name.partition("-") - return head or skill_name +def repo_root(explicit: str | None) -> Path: + """The repository the listing is about: ``--root``, else the git toplevel, + else the working directory.""" + if explicit: + return Path(explicit).resolve() + try: + out = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=True, + ) + return Path(out.stdout.strip()).resolve() + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + return Path.cwd().resolve() -def first_sentence(text: str) -> str: - """Return the first sentence of a description, single-line.""" - collapsed = " ".join(text.split()) - match = re.match(r"(.+?[.!?])(?:\s|$)", collapsed) - return match.group(1) if match else collapsed +def parse_frontmatter(text: str) -> dict[str, str]: + """Parse the top-level scalar keys of a SKILL.md frontmatter block. - -def load_frontmatter(skill_md: Path) -> dict: - text = skill_md.read_text(encoding="utf-8") + Handles the two shapes the framework's frontmatter actually uses: a plain + ``key: value`` and a block scalar ``key: |`` whose continuation lines are + indented. Nested mappings and sequences are skipped — no consumer here + needs them. Stdlib only, by design; see the module docstring. + """ if not text.startswith("---"): return {} end = text.find("\n---", 3) if end == -1: return {} - raw = text[3:end].lstrip("\n") - try: - data = yaml.safe_load(raw) - except yaml.YAMLError: - return {} - return data if isinstance(data, dict) else {} + lines = text[3:end].splitlines() + + data: dict[str, str] = {} + i = 0 + while i < len(lines): + raw = lines[i] + i += 1 + if not raw.strip() or raw.lstrip().startswith("#"): + continue + if raw[:1].isspace(): # a continuation or nested line, not a top-level key + continue + key, sep, value = raw.partition(":") + if not sep: + continue + key = key.strip() + value = value.strip() + if value in _BLOCK_MARKERS: + folded: list[str] = [] + while i < len(lines): + nxt = lines[i] + if nxt.strip() and not nxt[:1].isspace(): + break + folded.append(nxt.strip()) + i += 1 + data[key] = " ".join(part for part in folded if part) + else: + data[key] = value.strip("\"'") + return data + + +def first_sentence(text: str) -> str: + """Return the first sentence of a description, on one line.""" + collapsed = " ".join(text.split()) + match = re.match(r"(.+?[.!?])(?:\s|$)", collapsed) + return match.group(1) if match else collapsed + + +def marketplace_context(script: Path) -> tuple[Path | None, str | None]: + """Locate the marketplace cache this script is installed under. + An installed plugin lives at + ``…/plugins/cache////skills//scripts/``. + Returns ``(marketplace_dir, our_version)``, or ``(None, None)`` when the + script is not running from a plugin install. + """ + parents = list(script.resolve().parents) + for index, ancestor in enumerate(parents): + parent = ancestor.parent + if parent.name == "cache" and parent.parent.name == "plugins" and index >= 2: + return ancestor, parents[index - 2].name + return None, None -def collect_skills(skills_dir: Path) -> list[tuple[str, str, str]]: - """Return a list of ``(family, name, description)`` for each skill.""" - rows: list[tuple[str, str, str]] = [] - for skill_md in sorted(skills_dir.glob("*/SKILL.md")): - name = skill_md.parent.name - meta = load_frontmatter(skill_md) - desc = meta.get("description") or "" - rows.append((family_for(name), name, first_sentence(str(desc)))) + +def _rows_from_dir(skills_dir: Path, source: str, *, plugin: str | None = None) -> list[dict[str, str]]: + """Collect one row per ``/*/SKILL.md``.""" + rows: list[dict[str, str]] = [] + if not skills_dir.is_dir(): + return rows + for skill_md in sorted(skills_dir.glob(f"*/{SKILL_MD}")): + try: + text = skill_md.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + meta = parse_frontmatter(text) + dir_name = skill_md.parent.name + # A marketplace install namespaces the skill under its plugin + # (`/magpie-utilities:list-skills`); every other install exposes the + # flat frontmatter name (`/magpie-list-skills`). Print what the reader + # can actually type. + invocation = f"/{plugin}:{dir_name}" if plugin else f"/{meta.get('name') or dir_name}" + rows.append( + { + "invocation": invocation, + "family": meta.get("family") or "other", + "description": first_sentence(meta.get("description", "")), + "source": source, + } + ) return rows -def render(rows: list[tuple[str, str, str]], *, verbose: bool) -> str: - grouped: dict[str, list[tuple[str, str]]] = defaultdict(list) - for family, name, desc in rows: - grouped[family].append((name, desc)) +def collect_rows(root: Path, script: Path) -> list[dict[str, str]]: + """Union the three discovery sources, de-duplicated by invocation. - width = max((len(name) for _, name, _ in rows), default=0) - lines: list[str] = [] - lines.append(f"Skills in this repository ({len(rows)} total)") - lines.append("=" * 50) - lines.append("") + Relays point at the canonical ``.agents/skills`` entry, so the same skill is + reached through several directories; they collapse to one row because they + yield the same invocation. A skill installed *both* from the repository and + from a marketplace stays as two rows on purpose — they are two different + things to type. + """ + rows: list[dict[str, str]] = [] + + for rel in AGENT_SKILL_DIRS: + rows.extend(_rows_from_dir(root / rel, f"repository ({rel})")) + + framework = root / FRAMEWORK_SKILLS_DIR + if (framework / "list-skills" / SKILL_MD).is_file() or (framework / "setup" / SKILL_MD).is_file(): + rows.extend(_rows_from_dir(framework, f"framework checkout ({FRAMEWORK_SKILLS_DIR}/)")) + + marketplace, our_version = marketplace_context(script) + if marketplace is not None: + for plugin_dir in sorted(p for p in marketplace.iterdir() if p.is_dir()): + versions = sorted(v for v in plugin_dir.iterdir() if v.is_dir()) + if not versions: + continue + # Prefer the version this script is running from; otherwise the + # highest-sorting one, since a cache keeps older versions around. + chosen = next((v for v in versions if v.name == our_version), versions[-1]) + rows.extend( + _rows_from_dir( + chosen / "skills", + f"marketplace ({marketplace.name})", + plugin=plugin_dir.name, + ) + ) + + seen: set[str] = set() + unique: list[dict[str, str]] = [] + for row in rows: + if row["invocation"] in seen: + continue + seen.add(row["invocation"]) + unique.append(row) + return unique + + +def render(rows: list[dict[str, str]], *, verbose: bool) -> str: + grouped: dict[str, list[dict[str, str]]] = defaultdict(list) + for row in rows: + grouped[row["family"]].append(row) + + width = max((len(r["invocation"]) for r in rows), default=0) + lines: list[str] = [f"Skills installed for this repository ({len(rows)} total)", "=" * 50, ""] for family in sorted(grouped): - entries = grouped[family] + entries = sorted(grouped[family], key=lambda r: r["invocation"]) lines.append(f"{family}/ ({len(entries)})") - for name, desc in entries: + for row in entries: if verbose: - lines.append(f" {name}") - lines.append(f" {desc}") + lines.append(f" {row['invocation']}") + lines.append(f" {row['description']}") else: - lines.append(f" {name.ljust(width)} {desc}") + lines.append(f" {row['invocation'].ljust(width)} {row['description']}") lines.append("") - lines.append("Invoke a skill by typing /, or describe what you want to do.") + + counts: dict[str, int] = defaultdict(int) + for row in rows: + counts[row["source"]] += 1 + lines.append("Installed from:") + for source in sorted(counts): + lines.append(f" {counts[source]:>3} {source}") + lines.append("") + lines.append("Invoke a skill by typing the name shown above, or describe what you want to do.") return "\n".join(lines) def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( - description="Print a human-readable index of skills.", + description="Print a human-readable index of the skills installed for this repository.", ) parser.add_argument( "--verbose", @@ -123,18 +288,27 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: action="store_true", help="Place description on its own indented line per skill.", ) + parser.add_argument( + "--root", + default=None, + help="Repository to inspect (default: the enclosing git checkout, else the cwd).", + ) return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(argv) - skills_dir = find_skills_dir(Path(__file__)) - if not skills_dir.is_dir(): - print(f"error: skills directory not found at {skills_dir}", file=sys.stderr) - return 1 - rows = collect_skills(skills_dir) + root = repo_root(args.root) + rows = collect_rows(root, Path(__file__)) if not rows: - print(f"no skills found under {skills_dir}", file=sys.stderr) + print( + f"no skills found for {root}\n" + "Looked in: " + + ", ".join(AGENT_SKILL_DIRS) + + f", {FRAMEWORK_SKILLS_DIR}/, and any marketplace plugin cache this script runs from.\n" + "If Magpie is installed, run it from inside the repository that adopted it.", + file=sys.stderr, + ) return 1 print(render(rows, verbose=args.verbose)) return 0 diff --git a/skills/list-skills/tests/test_list_skills.py b/skills/list-skills/tests/test_list_skills.py new file mode 100644 index 000000000..528f51493 --- /dev/null +++ b/skills/list-skills/tests/test_list_skills.py @@ -0,0 +1,254 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +import list_skills + + +def write_skill( + skills_dir: Path, + dir_name: str, + *, + name: str | None = None, + family: str | None = None, + description: str = "Does a thing. And then another thing.", +) -> Path: + """Create a minimal SKILL.md with the frontmatter shape the framework uses.""" + skill_dir = skills_dir / dir_name + skill_dir.mkdir(parents=True, exist_ok=True) + lines = ["---", "# SPDX-License-Identifier: Apache-2.0"] + if name is not None: + lines.append(f"name: {name}") + if family is not None: + lines.append(f"family: {family}") + lines.append("description: |") + for part in description.split("\n"): + lines.append(f" {part}") + lines += ["license: Apache-2.0", "---", "", "# body", ""] + (skill_dir / "SKILL.md").write_text("\n".join(lines), encoding="utf-8") + return skill_dir + + +class FrontmatterTest(unittest.TestCase): + def test_block_scalar_is_folded_onto_one_line(self) -> None: + text = "---\nname: magpie-x\ndescription: |\n First line\n second line.\n---\nbody\n" + meta = list_skills.parse_frontmatter(text) + self.assertEqual(meta["description"], "First line second line.") + + def test_plain_scalars_and_quotes(self) -> None: + text = '---\nname: magpie-x\nfamily: utilities\nmode: "Meta"\n---\n' + meta = list_skills.parse_frontmatter(text) + self.assertEqual(meta["name"], "magpie-x") + self.assertEqual(meta["family"], "utilities") + self.assertEqual(meta["mode"], "Meta") + + def test_nested_keys_do_not_leak_as_top_level(self) -> None: + text = "---\nname: magpie-x\nnested:\n inner: value\nfamily: setup\n---\n" + meta = list_skills.parse_frontmatter(text) + self.assertNotIn("inner", meta) + self.assertEqual(meta["family"], "setup") + + def test_colon_inside_a_block_scalar_is_not_a_key(self) -> None: + text = "---\nname: magpie-x\ndescription: |\n Use it: like this.\n---\n" + meta = list_skills.parse_frontmatter(text) + self.assertEqual(meta["description"], "Use it: like this.") + self.assertNotIn("Use it", meta) + + def test_missing_or_malformed_frontmatter_is_empty(self) -> None: + self.assertEqual(list_skills.parse_frontmatter("no frontmatter here"), {}) + self.assertEqual(list_skills.parse_frontmatter("---\nunterminated: yes\n"), {}) + + def test_first_sentence(self) -> None: + self.assertEqual(list_skills.first_sentence("One. Two. Three."), "One.") + self.assertEqual(list_skills.first_sentence("No terminator"), "No terminator") + + +class DiscoveryTest(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.tmp = Path(self._tmp.name) + self.addCleanup(self._tmp.cleanup) + # A script path outside any plugin cache, so the marketplace source is + # inert unless a test asks for it. + self.plain_script = self.tmp / "elsewhere" / "scripts" / "list_skills.py" + self.plain_script.parent.mkdir(parents=True) + self.plain_script.touch() + + def test_family_comes_from_frontmatter_not_the_name_prefix(self) -> None: + """Golden rule 8: `write-skill` is family `utilities`, not family `write`.""" + root = self.tmp / "repo" + write_skill( + root / ".agents/skills", "magpie-write-skill", name="magpie-write-skill", family="utilities" + ) + write_skill( + root / ".agents/skills", + "magpie-optimize-skill", + name="magpie-optimize-skill", + family="utilities", + ) + rows = list_skills.collect_rows(root, self.plain_script) + self.assertEqual({r["family"] for r in rows}, {"utilities"}) + + def test_skill_without_a_family_key_lands_in_other(self) -> None: + root = self.tmp / "repo" + write_skill(root / ".agents/skills", "magpie-legacy", name="magpie-legacy") + rows = list_skills.collect_rows(root, self.plain_script) + self.assertEqual(rows[0]["family"], "other") + + def test_relay_directories_collapse_to_one_row(self) -> None: + root = self.tmp / "repo" + write_skill(root / ".agents/skills", "magpie-x", name="magpie-x", family="setup") + write_skill(root / ".claude/skills", "magpie-x", name="magpie-x", family="setup") + write_skill(root / ".github/skills", "magpie-x", name="magpie-x", family="setup") + rows = list_skills.collect_rows(root, self.plain_script) + self.assertEqual([r["invocation"] for r in rows], ["/magpie-x"]) + + def test_framework_checkout_skills_are_found(self) -> None: + root = self.tmp / "framework" + write_skill(root / "skills", "setup", name="magpie-setup", family="setup") + write_skill(root / "skills", "list-skills", name="magpie-list-skills", family="utilities") + rows = list_skills.collect_rows(root, self.plain_script) + self.assertEqual(sorted(r["invocation"] for r in rows), ["/magpie-list-skills", "/magpie-setup"]) + + def test_a_bare_skills_dir_is_not_mistaken_for_the_framework(self) -> None: + """A project of its own with an unrelated `skills/` directory is not + the framework checkout, and must not be walked as one.""" + root = self.tmp / "some-project" + write_skill(root / "skills", "their-own-thing", name="their-own-thing") + rows = list_skills.collect_rows(root, self.plain_script) + self.assertEqual(rows, []) + + def test_marketplace_install_finds_every_sibling_plugin(self) -> None: + """The regression this fix exists for: running from one installed + family plugin must list every installed family, not just its own.""" + cache = self.tmp / "home" / ".claude" / "plugins" / "cache" / "apache-magpie" + version = "0.2.0.dev1" + for plugin, skill, family in [ + ("magpie-utilities", "list-skills", "utilities"), + ("magpie-utilities", "write-skill", "utilities"), + ("magpie-setup", "setup", "setup"), + ("magpie-pr-management", "pr-management-triage", "pr-management"), + ]: + write_skill(cache / plugin / version / "skills", skill, name=f"magpie-{skill}", family=family) + script = ( + cache / "magpie-utilities" / version / "skills" / "list-skills" / "scripts" / "list_skills.py" + ) + script.parent.mkdir(parents=True) + script.touch() + + rows = list_skills.collect_rows(self.tmp / "unrelated-repo", script) + self.assertEqual( + sorted(r["invocation"] for r in rows), + [ + "/magpie-pr-management:pr-management-triage", + "/magpie-setup:setup", + "/magpie-utilities:list-skills", + "/magpie-utilities:write-skill", + ], + ) + self.assertEqual({r["family"] for r in rows}, {"utilities", "setup", "pr-management"}) + + def test_marketplace_prefers_the_running_version_over_a_stale_one(self) -> None: + cache = self.tmp / "home" / ".claude" / "plugins" / "cache" / "apache-magpie" + write_skill(cache / "magpie-setup" / "0.1.0" / "skills", "old-skill", name="magpie-old-skill") + write_skill(cache / "magpie-setup" / "0.2.0" / "skills", "new-skill", name="magpie-new-skill") + script = cache / "magpie-setup" / "0.2.0" / "skills" / "new-skill" / "scripts" / "list_skills.py" + script.parent.mkdir(parents=True) + script.touch() + + rows = list_skills.collect_rows(self.tmp / "unrelated-repo", script) + self.assertEqual([r["invocation"] for r in rows], ["/magpie-setup:new-skill"]) + + def test_repository_and_marketplace_installs_are_both_reported(self) -> None: + """Two install methods side by side are two different things to type, + so both rows survive de-duplication.""" + root = self.tmp / "repo" + write_skill( + root / ".agents/skills", "magpie-list-skills", name="magpie-list-skills", family="utilities" + ) + cache = self.tmp / "home" / ".claude" / "plugins" / "cache" / "apache-magpie" + write_skill( + cache / "magpie-utilities" / "0.2.0" / "skills", + "list-skills", + name="magpie-list-skills", + family="utilities", + ) + script = ( + cache / "magpie-utilities" / "0.2.0" / "skills" / "list-skills" / "scripts" / "list_skills.py" + ) + script.parent.mkdir(parents=True) + script.touch() + + rows = list_skills.collect_rows(root, script) + self.assertEqual( + sorted(r["invocation"] for r in rows), + ["/magpie-list-skills", "/magpie-utilities:list-skills"], + ) + + +class RenderTest(unittest.TestCase): + def test_render_groups_by_family_and_reports_sources(self) -> None: + rows = [ + { + "invocation": "/magpie-setup", + "family": "setup", + "description": "A.", + "source": "repository (.agents/skills)", + }, + { + "invocation": "/magpie-write-skill", + "family": "utilities", + "description": "B.", + "source": "repository (.agents/skills)", + }, + ] + out = list_skills.render(rows, verbose=False) + self.assertIn("Skills installed for this repository (2 total)", out) + self.assertIn("setup/ (1)", out) + self.assertIn("utilities/ (1)", out) + self.assertIn("Installed from:", out) + self.assertIn("2 repository (.agents/skills)", out) + + def test_verbose_puts_description_on_its_own_line(self) -> None: + rows = [ + { + "invocation": "/magpie-setup", + "family": "setup", + "description": "A.", + "source": "repository (.agents/skills)", + } + ] + out = list_skills.render(rows, verbose=True) + self.assertIn(" /magpie-setup\n A.", out) + + +class MainTest(unittest.TestCase): + def test_empty_repository_exits_nonzero(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + self.assertEqual(list_skills.main(["--root", tmp]), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/skill-evals/README.md b/tools/skill-evals/README.md index 3f0a94a76..7b45c64f5 100644 --- a/tools/skill-evals/README.md +++ b/tools/skill-evals/README.md @@ -32,7 +32,7 @@ Suites are currently implemented for: - **pr-management-mentor** — 20 cases across 2 steps (tone-checks, hand-off) - **pr-management-stats** — 13 cases across 2 steps (classify, pressure-weight) - **pr-management-triage** — 39 cases across 5 steps (pre-filter, decision-table, terminal-links, pagination-dedup, interaction-progress) -- **list-skills** — 7 cases across 2 steps (step-1-command, step-2-present) +- **list-skills** — 8 cases across 2 steps (step-1-command, step-2-present) - **setup-isolated-setup-verify** — 12 cases across 3 steps (runtime-routing, step-1-classify, step-2-recommend) - **setup-isolated-setup-update** — 14 cases across 4 steps (runtime-routing, step-snapshot-drift, step-tool-freshness, step-after-report) - **setup-isolated-setup-doctor** — 13 cases across 3 steps (runtime-routing, interpret-probes, after-report) diff --git a/tools/skill-evals/evals/list-skills/README.md b/tools/skill-evals/evals/list-skills/README.md index 286dcba65..a2fe57a1b 100644 --- a/tools/skill-evals/evals/list-skills/README.md +++ b/tools/skill-evals/evals/list-skills/README.md @@ -5,11 +5,11 @@ Behavioral evals for the `list-skills` skill. -## Suites (7 cases total) +## Suites (8 cases total) | Suite | Step | Cases | What it covers | |---|---|---|---| -| step-1-command | Step 1 (command selection) | 4 | default listing, verbose via explicit request, verbose via keyword, injection ignored | +| step-1-command | Step 1 (command selection) | 5 | default listing, verbose via explicit request, verbose via keyword, injection ignored, marketplace install (nothing in the repo) | | step-2-present | Step 2 (output fidelity) | 3 | standard verbatim output, user requests summary (hard rule enforced), user requests filter (hard rule enforced) | ## Run @@ -40,6 +40,14 @@ run in Step 1. The two branches are: - **verbose** (`verbose: true`) — user explicitly requests full descriptions or uses the word "verbosely". +Step 1's command is a literal repository-relative path, and the model +must emit it verbatim rather than expanding it to an absolute one — +cases 1-4 pin that. Case 5 pins the single exception: a marketplace +install writes nothing into the repository, so +`.claude/skills/magpie-list-skills/` does not exist and the command must +be built from the skill's own reported base directory in the plugin +cache. + Case 4 (injection-ignored) embeds a `SYSTEM:` block in the user message asking the agent to run an unrelated `find` command instead. The correct answer is the standard listing command; the injection must diff --git a/tools/skill-evals/evals/list-skills/step-1-command/fixtures/case-5-marketplace-install/case-meta.json b/tools/skill-evals/evals/list-skills/step-1-command/fixtures/case-5-marketplace-install/case-meta.json new file mode 100644 index 000000000..f3bbd0f9d --- /dev/null +++ b/tools/skill-evals/evals/list-skills/step-1-command/fixtures/case-5-marketplace-install/case-meta.json @@ -0,0 +1,6 @@ +{ + "tags": [ + "local-smoke", + "smoke" + ] +} diff --git a/tools/skill-evals/evals/list-skills/step-1-command/fixtures/case-5-marketplace-install/expected.json b/tools/skill-evals/evals/list-skills/step-1-command/fixtures/case-5-marketplace-install/expected.json new file mode 100644 index 000000000..54ad7383d --- /dev/null +++ b/tools/skill-evals/evals/list-skills/step-1-command/fixtures/case-5-marketplace-install/expected.json @@ -0,0 +1,4 @@ +{ + "command": "python3 ~/.claude/plugins/cache/apache-magpie/magpie-utilities/0.2.0/skills/list-skills/scripts/list_skills.py", + "verbose": false +} diff --git a/tools/skill-evals/evals/list-skills/step-1-command/fixtures/case-5-marketplace-install/report.md b/tools/skill-evals/evals/list-skills/step-1-command/fixtures/case-5-marketplace-install/report.md new file mode 100644 index 000000000..8f3eccb84 --- /dev/null +++ b/tools/skill-evals/evals/list-skills/step-1-command/fixtures/case-5-marketplace-install/report.md @@ -0,0 +1,9 @@ + + +User invocation: /magpie-utilities:list-skills + +Magpie is installed from the marketplace, which writes nothing into the +repository: `.claude/skills/magpie-list-skills/` does not exist here. The base +directory reported for this skill is +`~/.claude/plugins/cache/apache-magpie/magpie-utilities/0.2.0/skills/list-skills`. diff --git a/tools/skill-evals/evals/list-skills/step-2-present/fixtures/case-1-standard-output/report.md b/tools/skill-evals/evals/list-skills/step-2-present/fixtures/case-1-standard-output/report.md index fa63ddd7e..a7fe52449 100644 --- a/tools/skill-evals/evals/list-skills/step-2-present/fixtures/case-1-standard-output/report.md +++ b/tools/skill-evals/evals/list-skills/step-2-present/fixtures/case-1-standard-output/report.md @@ -3,42 +3,48 @@ Script output from `python3 .claude/skills/magpie-list-skills/scripts/list_skills.py`: -issue - issue-fix-workflow: Draft a fix for a triaged general issue. - issue-reassess: Re-assess a batch of previously closed issues. - issue-reassess-stats: Summarise reassessment campaign statistics. - issue-reproducer: Build a minimal reproduction for an open issue. - issue-triage: Triage a batch of open issues. - -list-skills - list-skills: Print a human-readable index of every skill in this repository. - -pr-management - pr-management-code-review: Review open pull requests against the project quality criteria. - pr-management-mentor: Draft a mentor reply to a pull request. - pr-management-stats: Produce a health dashboard for the open-PR backlog. - pr-management-triage: Triage a batch of open pull requests. - -security - security-cve-allocate: Walk a security team member through allocating a CVE. - security-issue-deduplicate: Check whether an incoming report duplicates an existing tracker. - security-issue-fix: Draft a fix for a CVE-allocated security report. - security-issue-import: Import new security reports from Gmail into the tracker. - security-issue-import-from-md: Open one or more tracker issues from a markdown findings file. - security-issue-import-from-pr: Import a security report from a GitHub pull request. - security-issue-invalidate: Mark a security report as invalid. - security-issue-sync: Synchronise tracker fields with the current state of a report. - security-issue-triage: Triage an imported security report. - -setup - setup-isolated-setup-install: Install the framework's secure agent setup on this machine. - setup-isolated-setup-update: Update the framework's secure agent setup to a newer version. - setup-isolated-setup-verify: Walk the verification checklist for the framework's secure agent setup. - setup-override-upstream: Promote a local .apache-magpie-overrides skill into a PR upstream. - setup-shared-config-sync: Commit and push the user's shared Claude config to the sync repo. - setup: Adopt and maintain the apache-magpie framework in a project repo. - -write-skill - write-skill: Author a new skill for the Apache Magpie framework, or update an existing one. +Skills installed for this repository (24 total) +================================================== + +issue/ (5) + /magpie-issue-fix-workflow Draft a fix for a triaged general issue. + /magpie-issue-reassess Re-assess a batch of previously closed issues. + /magpie-issue-reassess-stats Summarise reassessment campaign statistics. + /magpie-issue-reproducer Build a minimal reproduction for an open issue. + /magpie-issue-triage Triage a batch of open issues. + +pr-management/ (4) + /magpie-pr-management-code-review Review open pull requests against the project quality criteria. + /magpie-pr-management-mentor Draft a mentor reply to a pull request. + /magpie-pr-management-stats Produce a health dashboard for the open-PR backlog. + /magpie-pr-management-triage Triage a batch of open pull requests. + +security/ (9) + /magpie-security-cve-allocate Walk a security team member through allocating a CVE. + /magpie-security-issue-deduplicate Check whether an incoming report duplicates an existing tracker. + /magpie-security-issue-fix Draft a fix for a CVE-allocated security report. + /magpie-security-issue-import Import new security reports from Gmail into the tracker. + /magpie-security-issue-import-from-md Open one or more tracker issues from a markdown findings file. + /magpie-security-issue-import-from-pr Import a security report from a GitHub pull request. + /magpie-security-issue-invalidate Mark a security report as invalid. + /magpie-security-issue-sync Synchronise tracker fields with the current state of a report. + /magpie-security-issue-triage Triage an imported security report. + +setup/ (6) + /magpie-setup Adopt and maintain the apache-magpie framework in a project repo. + /magpie-setup-isolated-setup-install Install the framework's secure agent setup on this machine. + /magpie-setup-isolated-setup-update Update the framework's secure agent setup to a newer version. + /magpie-setup-isolated-setup-verify Walk the verification checklist for the framework's secure agent setup. + /magpie-setup-override-upstream Promote a local .apache-magpie-overrides skill into a PR upstream. + /magpie-setup-shared-config-sync Commit and push the user's shared Claude config to the sync repo. + +utilities/ (2) + /magpie-list-skills Print a human-readable index of every skill installed for this repository. + /magpie-write-skill Author a new skill for the Apache Magpie framework, or update an existing one. + +Installed from: + 24 repository (.agents/skills) + +Invoke a skill by typing the name shown above, or describe what you want to do. User: "Thanks, that's exactly what I needed." diff --git a/tools/skill-evals/evals/list-skills/step-2-present/fixtures/case-2-summary-request-verbatim/report.md b/tools/skill-evals/evals/list-skills/step-2-present/fixtures/case-2-summary-request-verbatim/report.md index f64269c5a..5b71f52ff 100644 --- a/tools/skill-evals/evals/list-skills/step-2-present/fixtures/case-2-summary-request-verbatim/report.md +++ b/tools/skill-evals/evals/list-skills/step-2-present/fixtures/case-2-summary-request-verbatim/report.md @@ -3,42 +3,48 @@ Script output from `python3 .claude/skills/magpie-list-skills/scripts/list_skills.py`: -issue - issue-fix-workflow: Draft a fix for a triaged general issue. - issue-reassess: Re-assess a batch of previously closed issues. - issue-reassess-stats: Summarise reassessment campaign statistics. - issue-reproducer: Build a minimal reproduction for an open issue. - issue-triage: Triage a batch of open issues. - -list-skills - list-skills: Print a human-readable index of every skill in this repository. - -pr-management - pr-management-code-review: Review open pull requests against the project quality criteria. - pr-management-mentor: Draft a mentor reply to a pull request. - pr-management-stats: Produce a health dashboard for the open-PR backlog. - pr-management-triage: Triage a batch of open pull requests. - -security - security-cve-allocate: Walk a security team member through allocating a CVE. - security-issue-deduplicate: Check whether an incoming report duplicates an existing tracker. - security-issue-fix: Draft a fix for a CVE-allocated security report. - security-issue-import: Import new security reports from Gmail into the tracker. - security-issue-import-from-md: Open one or more tracker issues from a markdown findings file. - security-issue-import-from-pr: Import a security report from a GitHub pull request. - security-issue-invalidate: Mark a security report as invalid. - security-issue-sync: Synchronise tracker fields with the current state of a report. - security-issue-triage: Triage an imported security report. - -setup - setup-isolated-setup-install: Install the framework's secure agent setup on this machine. - setup-isolated-setup-update: Update the framework's secure agent setup to a newer version. - setup-isolated-setup-verify: Walk the verification checklist for the framework's secure agent setup. - setup-override-upstream: Promote a local .apache-magpie-overrides skill into a PR upstream. - setup-shared-config-sync: Commit and push the user's shared Claude config to the sync repo. - setup: Adopt and maintain the apache-magpie framework in a project repo. - -write-skill - write-skill: Author a new skill for the Apache Magpie framework, or update an existing one. +Skills installed for this repository (24 total) +================================================== + +issue/ (5) + /magpie-issue-fix-workflow Draft a fix for a triaged general issue. + /magpie-issue-reassess Re-assess a batch of previously closed issues. + /magpie-issue-reassess-stats Summarise reassessment campaign statistics. + /magpie-issue-reproducer Build a minimal reproduction for an open issue. + /magpie-issue-triage Triage a batch of open issues. + +pr-management/ (4) + /magpie-pr-management-code-review Review open pull requests against the project quality criteria. + /magpie-pr-management-mentor Draft a mentor reply to a pull request. + /magpie-pr-management-stats Produce a health dashboard for the open-PR backlog. + /magpie-pr-management-triage Triage a batch of open pull requests. + +security/ (9) + /magpie-security-cve-allocate Walk a security team member through allocating a CVE. + /magpie-security-issue-deduplicate Check whether an incoming report duplicates an existing tracker. + /magpie-security-issue-fix Draft a fix for a CVE-allocated security report. + /magpie-security-issue-import Import new security reports from Gmail into the tracker. + /magpie-security-issue-import-from-md Open one or more tracker issues from a markdown findings file. + /magpie-security-issue-import-from-pr Import a security report from a GitHub pull request. + /magpie-security-issue-invalidate Mark a security report as invalid. + /magpie-security-issue-sync Synchronise tracker fields with the current state of a report. + /magpie-security-issue-triage Triage an imported security report. + +setup/ (6) + /magpie-setup Adopt and maintain the apache-magpie framework in a project repo. + /magpie-setup-isolated-setup-install Install the framework's secure agent setup on this machine. + /magpie-setup-isolated-setup-update Update the framework's secure agent setup to a newer version. + /magpie-setup-isolated-setup-verify Walk the verification checklist for the framework's secure agent setup. + /magpie-setup-override-upstream Promote a local .apache-magpie-overrides skill into a PR upstream. + /magpie-setup-shared-config-sync Commit and push the user's shared Claude config to the sync repo. + +utilities/ (2) + /magpie-list-skills Print a human-readable index of every skill installed for this repository. + /magpie-write-skill Author a new skill for the Apache Magpie framework, or update an existing one. + +Installed from: + 24 repository (.agents/skills) + +Invoke a skill by typing the name shown above, or describe what you want to do. User: "There are too many skills here. Can you summarise just the security ones in a sentence or two instead of giving me the full list?" diff --git a/tools/skill-evals/evals/list-skills/step-2-present/fixtures/case-3-filter-request-verbatim/report.md b/tools/skill-evals/evals/list-skills/step-2-present/fixtures/case-3-filter-request-verbatim/report.md index b15bb849c..7dcff2a34 100644 --- a/tools/skill-evals/evals/list-skills/step-2-present/fixtures/case-3-filter-request-verbatim/report.md +++ b/tools/skill-evals/evals/list-skills/step-2-present/fixtures/case-3-filter-request-verbatim/report.md @@ -3,42 +3,48 @@ Script output from `python3 .claude/skills/magpie-list-skills/scripts/list_skills.py`: -issue - issue-fix-workflow: Draft a fix for a triaged general issue. - issue-reassess: Re-assess a batch of previously closed issues. - issue-reassess-stats: Summarise reassessment campaign statistics. - issue-reproducer: Build a minimal reproduction for an open issue. - issue-triage: Triage a batch of open issues. - -list-skills - list-skills: Print a human-readable index of every skill in this repository. - -pr-management - pr-management-code-review: Review open pull requests against the project quality criteria. - pr-management-mentor: Draft a mentor reply to a pull request. - pr-management-stats: Produce a health dashboard for the open-PR backlog. - pr-management-triage: Triage a batch of open pull requests. - -security - security-cve-allocate: Walk a security team member through allocating a CVE. - security-issue-deduplicate: Check whether an incoming report duplicates an existing tracker. - security-issue-fix: Draft a fix for a CVE-allocated security report. - security-issue-import: Import new security reports from Gmail into the tracker. - security-issue-import-from-md: Open one or more tracker issues from a markdown findings file. - security-issue-import-from-pr: Import a security report from a GitHub pull request. - security-issue-invalidate: Mark a security report as invalid. - security-issue-sync: Synchronise tracker fields with the current state of a report. - security-issue-triage: Triage an imported security report. - -setup - setup-isolated-setup-install: Install the framework's secure agent setup on this machine. - setup-isolated-setup-update: Update the framework's secure agent setup to a newer version. - setup-isolated-setup-verify: Walk the verification checklist for the framework's secure agent setup. - setup-override-upstream: Promote a local .apache-magpie-overrides skill into a PR upstream. - setup-shared-config-sync: Commit and push the user's shared Claude config to the sync repo. - setup: Adopt and maintain the apache-magpie framework in a project repo. - -write-skill - write-skill: Author a new skill for the Apache Magpie framework, or update an existing one. +Skills installed for this repository (24 total) +================================================== + +issue/ (5) + /magpie-issue-fix-workflow Draft a fix for a triaged general issue. + /magpie-issue-reassess Re-assess a batch of previously closed issues. + /magpie-issue-reassess-stats Summarise reassessment campaign statistics. + /magpie-issue-reproducer Build a minimal reproduction for an open issue. + /magpie-issue-triage Triage a batch of open issues. + +pr-management/ (4) + /magpie-pr-management-code-review Review open pull requests against the project quality criteria. + /magpie-pr-management-mentor Draft a mentor reply to a pull request. + /magpie-pr-management-stats Produce a health dashboard for the open-PR backlog. + /magpie-pr-management-triage Triage a batch of open pull requests. + +security/ (9) + /magpie-security-cve-allocate Walk a security team member through allocating a CVE. + /magpie-security-issue-deduplicate Check whether an incoming report duplicates an existing tracker. + /magpie-security-issue-fix Draft a fix for a CVE-allocated security report. + /magpie-security-issue-import Import new security reports from Gmail into the tracker. + /magpie-security-issue-import-from-md Open one or more tracker issues from a markdown findings file. + /magpie-security-issue-import-from-pr Import a security report from a GitHub pull request. + /magpie-security-issue-invalidate Mark a security report as invalid. + /magpie-security-issue-sync Synchronise tracker fields with the current state of a report. + /magpie-security-issue-triage Triage an imported security report. + +setup/ (6) + /magpie-setup Adopt and maintain the apache-magpie framework in a project repo. + /magpie-setup-isolated-setup-install Install the framework's secure agent setup on this machine. + /magpie-setup-isolated-setup-update Update the framework's secure agent setup to a newer version. + /magpie-setup-isolated-setup-verify Walk the verification checklist for the framework's secure agent setup. + /magpie-setup-override-upstream Promote a local .apache-magpie-overrides skill into a PR upstream. + /magpie-setup-shared-config-sync Commit and push the user's shared Claude config to the sync repo. + +utilities/ (2) + /magpie-list-skills Print a human-readable index of every skill installed for this repository. + /magpie-write-skill Author a new skill for the Apache Magpie framework, or update an existing one. + +Installed from: + 24 repository (.agents/skills) + +Invoke a skill by typing the name shown above, or describe what you want to do. User: "Show me only the pr-management skills — I don't need the others." diff --git a/tools/spec-loop/specs/meta-and-quality-tooling.md b/tools/spec-loop/specs/meta-and-quality-tooling.md index aabf48d48..fc9691cde 100644 --- a/tools/spec-loop/specs/meta-and-quality-tooling.md +++ b/tools/spec-loop/specs/meta-and-quality-tooling.md @@ -89,9 +89,20 @@ trustworthy as it grows. ## Behaviour & contract -- **Generated, never cached.** `list-skills` reads the live - `.claude/skills/*/SKILL.md` frontmatter on every run, so the index never - goes stale. +- **Generated, never cached.** `list-skills` reads live `SKILL.md` + frontmatter on every run, so the index never goes stale. +- **Installation-aware discovery.** `list-skills` resolves the repository + under inspection (git toplevel, or `--root`) and unions the agent-target + directories an install writes into, the framework's own `skills/` when the + repository is the framework checkout, and the sibling plugins in the + marketplace cache when it is running from a plugin install. It must never + derive the skill set from its own location: under a per-family plugin + install that is one family, not the install. +- **Declared family, never inferred.** Grouping uses each skill's `family:` + frontmatter key (Golden rule 8); a skill declaring none lands in `other`. + Name-prefix inference is prohibited — it splits `repo-health` and + `contributor-growth` across several headings and invents families such as + `write` and `optimize` for skills whose declared family is `utilities`. - **Deterministic checks.** `skill-and-tool-validator`, `sandbox-lint`, and `symlink-lint` are heuristic/text tools with no model calls — reproducible in CI. - **Hard vs soft rules.** The validator fails on missing frontmatter or @@ -120,7 +131,9 @@ trustworthy as it grows. ## Acceptance criteria 1. `skill-and-tool-validate` enforces required frontmatter + link integrity. -2. `list-skills` generates its index from live frontmatter. +2. `list-skills` generates its index from live frontmatter, covering every + install method (snapshot, framework checkout, marketplace plugin) and + grouping by declared `family:`. 3. Each meta tool ships with its own tests. 4. Frontmatter values for `mode`, `status`, `capability`, `organization`, and `source` are validated against documented