The same --plugin-dir flag, pointed at the same directory, produced two inventories. In the first, claude plugin list named alpha@inline at version 0.1.0 and beta@inline at version 0.1.0. Both rows said loaded. In the second, the same command named one row, plugins@inline, version unknown, and said that row was loaded too. Both commands exited 0. The child directories had not moved. Each still held a .claude-plugin/plugin.json and a skill file. The change between the two lists was a file at the top of the parent.
That file was a zero-byte .mcp.json. List did not say the children had been skipped. It reported the parent directory as a plugin, and it put a check mark on that report. A wrapper that searches the output for loaded will accept the collapsed inventory and the open one. The fields that separate them are the name and the version. A folder that opened its children has one row per manifest, the version from that child’s plugin.json, and a path that ends in the child directory. A folder that collapsed has one row, the parent directory’s name, version unknown, and a path that is the parent.
This was Claude Code 2.1.308 on Linux x86_64, on 10 November 2026. claude --version printed 2.1.308 (Claude Code). The directory under test was new. HOME and CLAUDE_CONFIG_DIR pointed at that directory’s neighbors, not at an operator config. No marketplace was added. No plugin was installed. plugin list did not ask for a key.
What the session inventory showed
List is the short check. The first object from a bare print run is the same split, with the skill names attached.
With no .mcp.json at the top, that object named plugins alpha and beta. Its skills included alpha:greet and beta:farewell. Those names are the plugin name, a colon, and the name from each SKILL.md. The debug log said the path was a folder of plugins because nothing at its top marked it as one plugin, and that it was loading alpha and beta. It then recorded two plugin skills.
With the zero-byte file put back, the object named one directory-loaded plugin, plugins, source plugins@inline. Neither alpha:greet nor beta:farewell was in the skills list. The debug log said the path was one plugin because .mcp.json at its top marks it, and that zero plugin skills loaded. The skill files were still on disk under the children. They were not seated.
The print run used --bare, --verbose, and --output-format stream-json. The API key in that environment was rejected. The process exited 1, and the result object recorded total_cost_usd of 0. The init object was already on stdout before that rejection. You do not need a completed model turn to see which plugins the flag seated. You also do not need that print run for the daily check. plugin list exits 0 without a key and shows the same names. The print run is how this session confirmed that the missing rows were missing skills, not only missing list lines.
The source suffix @inline is the label this binary puts on a plugin loaded from --plugin-dir rather than from an installed marketplace entry. It appeared on the collapsed row and on the two child rows. It does not mean the children were included. Read the name in front of it.
Which file at the top flips the walk
The create guide says a folder of plugins requires Claude Code v2.1.265 or later. It treats the directory as a collection only when that directory has no .claude-plugin/ directory and no plugin components at its top level. Each immediate subfolder that has .claude-plugin/plugin.json then loads as its own plugin. Anything else in the folder is skipped without an error, including a subfolder that has no manifest. .mcp.json is listed in the same guide as the MCP server definitions for a plugin root. A file by that name at the collection root is a component at the top, so the collection rule says not to walk the children.
On 2.1.308 the marker is the name of the file, not a successful parse of an MCP server list. Each of these contents, written in turn to .mcp.json at the top of the same parent, produced the collapsed list and the same debug sentence:
zero-byte file
a single newline
{}
{"mcpServers":{}}
not json
In every one of those runs the log said the path was one plugin because .mcp.json at its top marks it. One directory-loaded plugin. Zero plugin skills. {"mcpServers":{}} is well-formed JSON and still an empty server map. It did not get a different walk from the zero-byte file. not json did not get a parse error that aborted startup. The file’s presence was enough, and list still exited 0 with Status: ✔ loaded.
A renamed copy did not count. While .mcp.json.bak sat next to alpha/ and beta/, and nothing was named exactly .mcp.json, list printed both children at version 0.1.0. The loader is not scanning for a file that merely looks like MCP config. It is testing for that name at the path you passed.
Two other top-level objects collapsed the folder in the same list shape, which is what the guide’s component rule predicts, and which list still reports as a successful load. A skills/stray/SKILL.md tree at the parent, with no .mcp.json, produced plugins@inline and Version: unknown. An empty .claude-plugin/ directory did the same. The guide says the absence of that directory is part of the collection test. An empty directory is still that directory, so the collapse matches the sentence. The sentence does not say that list will call the empty directory a loaded plugin, or that the version cell will be unknown because the manifest that exists is one level down.
The exception in the same section held on this binary. After the empty .claude-plugin/ directory had collapsed the folder, writing only marketplace.json into it, and not writing plugin.json, brought alpha@inline and beta@inline back. The guide says a folder may keep .claude-plugin/marketplace.json beside its plugin folders, and that the child folders still load as long as that directory holds no plugin.json. It also says this path requires v2.1.281 or later. This host is 2.1.308, so the requirement is met. The marketplace file was not installed and was not applied as a catalog. List did not gain a marketplace section. The file only stopped the empty .claude-plugin/ directory from marking the parent as one plugin.
Point the flag at the child
The marker is evaluated on the path you pass. It is not inherited from a parent you did not pass. With the zero-byte .mcp.json left at the top of plugins/, naming each child still seated both plugins:
claude --plugin-dir ./plugins/alpha --plugin-dir ./plugins/beta plugin list
The debug lines for that run said each path was one plugin because .claude-plugin at its top marks it. Two directory-loaded plugins. Two plugin skills. The init skills were again alpha:greet and beta:farewell. The parent file was still there. It did not matter, because the flag never named the parent.
Use that form when the collection directory is shared with another tool that drops .mcp.json at the root, or when you cannot move the file yet. Repeat --plugin-dir once per child that has a manifest. Do not pass the parent and expect the children to be discovered underneath a marker.
The help text says you can repeat the flag, and that a folder of plugins loads each child. Both sentences are true on this binary, and they describe different paths. The repeated flag names plugin roots. The single flag names a directory that is a collection only when nothing at its top marks it as a plugin. Passing the parent is the path that the zero-byte file changes.
Validate does not choose the walk
claude plugin validate on the parent exited 1 in the run where list loaded both children. The error was one line: no manifest found in the directory, expected .claude-plugin/marketplace.json or .claude-plugin/plugin.json. It did not mention .mcp.json. It did not name alpha or beta. It did not say whether --plugin-dir on that path would walk the children. The same command on alpha exited 0. It warned that author was missing, and it listed directory-submission problems for a missing README, a missing LICENSE, and a missing icon. The command said those directory checks do not change the result unless you pass --profile directory. The child manifest was acceptable to the runtime. The parent error was not a prediction about the children.
The sequence that fails quietly is the one a setup script is likely to use. Validate each child and require exit 0. Then start Claude Code with --plugin-dir pointed at the parent. Then accept plugin list if it contains loaded. On this binary the first step can pass, the second step can collapse because of a file the validator never named, and the third step can still print a check mark. The version cell is unknown in that third step. That is the field to require, not the check mark.
Validate remains the right check for a single plugin root. Run it on alpha and on beta before you hand those directories to someone else. Do not run it on the parent and treat the missing-manifest error as either success or failure of the collection walk. The collection walk is a different command.
Verify the rows before the session starts
Build the folder the way this run did, so the check has something to find. Two child directories are enough. Each needs .claude-plugin/plugin.json with a name and a version, and one skills/name/SKILL.md whose front matter name you can look for later. This run used alpha version 0.1.0 with skill greet, and beta version 0.1.0 with skill farewell. The descriptions were one line each. No author field was set. No README was added. Those omissions produced warnings on validate and did not stop the load.
From the parent, list the names at the top before you pass the directory to Claude Code:
find . -mindepth 1 -maxdepth 1 -printf '%f\n'
On 2.1.308, do not pass that parent to --plugin-dir if the names include .mcp.json, skills, commands, agents, hooks, or .claude-plugin, unless .claude-plugin contains marketplace.json and does not contain plugin.json. A zero-byte .mcp.json is enough to refuse the parent. A file that is not JSON is enough. Move the marker aside, or pass each child directory instead.
Then require the child rows:
claude --plugin-dir ./plugins plugin list
Accept the output only when each expected plugin appears as its manifest name followed by @inline, the version matches the version in that child’s plugin.json, and the path ends in that child’s directory. Reject a single row whose name is the parent directory and whose version is unknown, including when the status says loaded and the exit code is 0. Both of those were true for the collapsed run.
If a headless job already prints stream JSON, read the first object the same way. Require the child plugin names in plugins. Require the prefixed skill names in skills. On this run those strings were alpha:greet and beta:farewell when the folder was open, and they were absent when the marker file was present. An authentication error after the init object does not remove the object. Do not wait for a model reply to decide whether the plugins loaded.
The 2.1.311 changelog entry, dated 26 October 2026, says a --plugin-dir folder of plugins no longer loads as one plugin when an empty .mcp.json is at its top. This host was not upgraded. The installed binary is the 2.1.308 release, dated 22 October 2026 in that changelog. The later note names an empty file. It does not say what {}, a newline, or not json do after the fix, and this run did not install 2.1.311. On 2.1.308 those contents collapse the folder the same way a zero-byte file does. A version string newer than 2.1.308 is not the check. Run list on the folder you will actually pass, and read the names.
References
- Claude Code 2.1.308 on Linux x86_64, 10 November 2026.
claude --versionprinted2.1.308 (Claude Code). The checks wereclaude plugin validateandclaude --plugin-dir ./plugins plugin list, plus one bare print run with--verboseand--output-format stream-json.HOMEandCLAUDE_CONFIG_DIRpointed at a new directory. No marketplace was added. The print run’s key was rejected, the process exited 1, and the result recordedtotal_cost_usdof 0. The init object was on stdout before that rejection. - Claude Code docs, Create a plugin, section “Load a folder of plugins”: https://code.claude.com/docs/en/plugins/create. Read 10 November 2026. Folder loading requires v2.1.265 or later. The directory is a collection only when it has no
.claude-plugin/directory and no plugin components at the top. A.claude-plugin/marketplace.jsonwith noplugin.jsonstill allows the child folders to load, and that exception requires v2.1.281 or later. - Claude Code docs, plugin layout table on the same page.
.mcp.jsonis the MCP server definitions at a plugin root. A plugin loaded with--plugin-dirand no manifest is named after its directory. - Claude Code changelog, https://code.claude.com/docs/en/changelog, read 10 November 2026. Version 2.1.308 is dated 22 October 2026. Version 2.1.311, dated 26 October 2026, says it fixed a
--plugin-dirfolder of plugins loading as one plugin when an empty.mcp.jsonwas at its top. This host was not upgraded to that release.