You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/09/09 21:53:26 UTC

(magpie) branch main updated: fix(list-skills): discover every installed skill, drop the PyYAML import (#1187)

This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/magpie.git


The following commit(s) were added to refs/heads/main by this push:
     new 826e13da fix(list-skills): discover every installed skill, drop the PyYAML import (#1187)
826e13da is described below

commit 826e13da1abdb981dcd94182ffce3e7c7eb68bd2
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Wed Sep 9 23:53:20 2026 +0200

    fix(list-skills): discover every installed skill, drop the PyYAML import (#1187)
    
    * fix(list-skills): discover every installed skill, drop PyYAML
    
    `list-skills` resolved its skill set from `Path(__file__)`, so under a
    per-family marketplace plugin install it listed only the family that
    happens to ship it — 5 skills out of 74. It also derived each skill's
    family from the name prefix, against Golden rule 8, which invented
    families like `write/` and `optimize/` for skills declaring `utilities`
    and split `repo-health` and `contributor-growth` across headings.
    
    Discovery is now installation-aware and repository-first: 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 the script runs from one. Rows de-duplicate by
    the name a person would type, so relays collapse while a skill reachable
    from two install methods keeps both. Grouping reads the `family:`
    frontmatter key; a skill declaring none lands in `other`.
    
    The script was also the only one under `skills/` importing a third-party
    module, against the stdlib-only contract in `skills/pyproject.toml`, and
    crashed with `ModuleNotFoundError: No module named 'yaml'` on any
    interpreter without PyYAML — including every plugin install. Frontmatter
    is now parsed with the standard library, as `collect_status.py` and the
    validator already do, and PEP 723 inline metadata states the contract so
    `uv run --script` and a bare `python3` behave identically.
    
    Generated-by: Claude Code (Opus 5)
    
    * fix(list-skills): keep Step 1's command literal so the evals can pin it
    
    The previous commit rewrote Step 1 to use the `<framework>` placeholder.
    Models resolve that to an absolute path, which is machine-specific and
    cannot be pinned in an eval fixture, so step-1-command cases 1-4 failed:
    expected `<framework>/skills/…`, got `/Users/…/skills/…`.
    
    Step 1 goes back to the literal repository-relative
    `.claude/skills/magpie-list-skills/scripts/list_skills.py`, with an
    explicit instruction not to expand it, and the four fixtures return to
    their upstream values — they never needed to change. The marketplace
    exception moves below the primary command and keeps its own case, which
    passed throughout.
    
    Generated-by: Claude Code (Opus 5)
---
 skills/list-skills/SKILL.md                        | 108 +++++--
 skills/list-skills/scripts/list_skills.py          | 314 ++++++++++++++++-----
 skills/list-skills/tests/test_list_skills.py       | 254 +++++++++++++++++
 tools/skill-evals/README.md                        |   2 +-
 tools/skill-evals/evals/list-skills/README.md      |  12 +-
 .../case-5-marketplace-install/case-meta.json      |   6 +
 .../case-5-marketplace-install/expected.json       |   4 +
 .../fixtures/case-5-marketplace-install/report.md  |   9 +
 .../fixtures/case-1-standard-output/report.md      |  80 +++---
 .../case-2-summary-request-verbatim/report.md      |  80 +++---
 .../case-3-filter-request-verbatim/report.md       |  80 +++---
 tools/spec-loop/specs/meta-and-quality-tooling.md  |  21 +-
 12 files changed, 753 insertions(+), 217 deletions(-)

diff --git a/skills/list-skills/SKILL.md b/skills/list-skills/SKILL.md
index 667b0b4e..a7000408 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-<skill>` |
+| Framework checkout | the repo's own `skills/` | `/magpie-<skill>` |
+| Marketplace plugin | the plugin cache this script runs from, and its sibling plugins | `/<plugin>:<skill>` |
 
 ---
 
 ## 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 <the base directory reported for this skill>/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 1111c9d0..2829f7ce 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 <path-to>/list_skills.py
+    python3 <path-to>/list_skills.py --verbose
+    python3 <path-to>/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/<marketplace>/<plugin>/<version>/skills/<skill>/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 ``<skills_dir>/*/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 /<skill-name>, 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 00000000..528f5149
--- /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 3f0a94a7..7b45c64f 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 286dcba6..a2fe57a1 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 00000000..f3bbd0f9
--- /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 00000000..54ad7383
--- /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 00000000..8f3eccb8
--- /dev/null
+++ b/tools/skill-evals/evals/list-skills/step-1-command/fixtures/case-5-marketplace-install/report.md
@@ -0,0 +1,9 @@
+<!-- SPDX-License-Identifier: Apache-2.0
+     https://www.apache.org/licenses/LICENSE-2.0 -->
+
+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 fa63ddd7..a7fe5244 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 f64269c5..5b71f52f 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 b15bb849..7dcff2a3 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 aabf48d4..fc9691cd 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