Skill auto-discovery is exactly one level deep, but the docs say "all SKILL.md files in skill subdirectories" — and a nested SKILL.md is ignored silently
What the docs say
plugins/plugin-dev/skills/plugin-structure/SKILL.md states:
Auto-discovery: All SKILL.md files in skill subdirectories load automatically
Read literally, that promises recursive discovery. It isn't — discovery is
exactly one level, and the example directly beneath the sentence only ever
shows the one-level shape, so the mismatch is easy to miss.
What actually happens
Measured on CLI 2.1.238 by reading the skills array off the init
message (i.e. what the CLI actually registered — not what a model reports it
can see):
| Path under skills/ | Registered? |
|---|---|
| flatone/SKILL.md | ✅ nestprobe:flatone |
| group/SKILL.md | ✅ nestprobe:group |
| namecheck/SKILL.md | ✅ nestprobe:namecheck |
| imessage/chats/SKILL.md | ❌ |
| group/child/SKILL.md | ❌ |
| deep/er/still/SKILL.md | ❌ |
Same result for project skills in .claude/skills/ — the one-level rule holds
for both discovery paths.
Two details worth calling out:
group/registers andgroup/child/does not. An intermediate directory
is not a container: it is either a skill itself (it has its own SKILL.md)
or nothing, and either way its subdirectories are never scanned.
- The skip is completely silent. No
plugin_errors, noplugin_warnings,
nothing on stderr. A misplaced SKILL.md just never exists, which is what
makes this cost people real time.
Reproduction
# plugin with skills at four different depths
mkdir -p p/.claude-plugin
cat > p/.claude-plugin/plugin.json <<'JSON'
{"name":"nestprobe","version":"0.0.1","description":"depth probe"}
JSON
for path in flatone group imessage/chats group/child deep/er/still; do
mkdir -p "p/skills/$path"
printf -- '---\nname: %s\ndescription: probe\n---\n# probe\n' "$(basename "$path")" \
> "p/skills/$path/SKILL.md"
done
# ask the CLI what it registered
echo '{"type":"user","message":{"role":"user","content":"hi"}}' \
| claude --input-format stream-json --output-format stream-json --verbose \
--model haiku --setting-sources "" --plugin-dir ./p 2>/dev/null \
| python3 -c 'import sys,json
for l in sys.stdin:
o=json.loads(l) if l.strip() else {}
if o.get("subtype")=="init":
print([s for s in o.get("skills",[]) if "nestprobe" in str(s)]); break'
Prints ['nestprobe:flatone', 'nestprobe:group'] — the three nested skills are
absent.
(Note for anyone reproducing: asking the model to "list every skill available
to you" is not a reliable check. If it has Glob/Read it can enumerate the
plugin directory off disk and report nested skills that were never registered,
which yields a false positive. The init registry is the authoritative source.)
Suggested fixes
- Fix the sentence. Something like: "Auto-discovery: each immediate
subdirectory of skills/ containing a SKILL.md loads automatically.
Nested subdirectories are not scanned." One line, and this class of
confusion goes away.
- Warn on a skipped
SKILL.md. When discovery finds aSKILL.mdbelow
depth 1, surface it — plugin_warnings already exists on the init
message and is exactly the right channel. Silence is the expensive part:
the author's mental model is "I wrote a skill and Claude ignores it", with
nothing to grep for.
If recursive discovery is intentionally out of scope (name collisions across
nested groups being the obvious reason), then the doc fix alone resolves it —
symlinking a nested directory to depth 1 works today as a workaround and could
be mentioned.
Secondary, same doc page: directory name wins over frontmatter name:
A skill in namecheck/ declaring name: totally-different in its frontmatter
registers as namecheck — the directory name. Verified for both plugin
skills (nestprobe:namecheck) and project skills.
Every example on the page has the two agreeing, so which one is authoritative
is never stated. Worth one sentence, since a mismatch silently resolves to the
directory and the name: field appears to have no effect on the registered
identifier.