Claude Code 2.1.323, in the changelog dated 12 November 2026, adds engines["claude-code"] to plugin.json and says a plugin outside that range is not installed, updated, or loaded. The binary in this lab is 2.1.308. On that binary the same key is a warning, the warning says the field is ignored, and a plugin whose range sits below 2.0.0 still seats its skill.
That is a version boundary, not a disagreement inside one release. Copying the new field onto a fleet that has not upgraded does not do the job the changelog describes. The validator on 2.1.308 will tell you it is safe to keep the field. That sentence matches what this binary did. It is not a description of 2.1.323, and it is not a reason to skip the list after you upgrade.
Two pages, one key
The changelog sentence is specific. Plugins outside their stated Claude Code version range are not installed, updated, or loaded, and install and update pick the newest version that fits. This run did not install 2.1.323. Nothing below is a measurement of that binary. Re-list after an upgrade before you treat a range measured here as still ignored.
The plugin manifest reference, read the same evening, does not list engines in its fields table. It says an unrecognized top-level key is stripped and the plugin loads, and that claude plugin validate reports that key as a warning. The page and the changelog are not describing one release. A reader who stops at the manifest page will treat engines as another ignored key. A reader who stops at the 2.1.323 notes will treat it as a gate. The binary decides which sentence is operational. claude --version printed 2.1.308 (Claude Code).
The file on PATH had SHA-256 356733f185fa988e6bf3aa9a555269fdd167982a2e2a80d3c8a78037bb92ad58. The host was Linux x86_64. The runs were the evening of 12 November 2026, US Eastern. CLAUDE_CONFIG_DIR pointed at a new directory, so the operator config was not the directory under test. No login was run. The print runs set ANTHROPIC_API_KEY to a probe value the service rejected. Both exited 1 with authentication_failed, HTTP 401, total_cost_usd of 0, and the text Invalid API key ยท Fix external API key. The init object was already on stdout. Skill seating does not require a completed model turn.
This run also did not exercise marketplace install, marketplace update, or the “pick the newest version that fits” clause. Those are install-time choices. The path under test is --plugin-dir, which loads a directory for one session. If your pin is supposed to stop an install from a marketplace, this article does not show that pin working or failing. It shows that a range in the manifest does not stop this binary from loading the directory you pointed at.
The warning does not print the range
Three directories used the Claude layout: .claude-plugin/plugin.json and skills/greet/SKILL.md. The skill frontmatter set name to greet. The manifests differed in engines. The excluding manifest was:
{
"name": "exclude-plugin",
"version": "0.2.0",
"description": "Range excludes this binary",
"engines": {
"claude-code": "<2.0.0"
}
}
The including manifest used the same shape with name include-plugin and a lower bound of 2.1.300. The third manifest set engines to a string, >=2.1.323, instead of an object, and named the plugin string-engines at version 0.1.0. The warning did not repeat any of those values.
claude plugin validate on each directory exited 0. Each human report ended with Validation passed with warnings. The engines line was the same in all three:
engines: Unknown field 'engines' (commonly seen in a VS Code/Cursor extension manifest). Claude Code ignores unrecognized fields at load time, so it's safe to keep.
None of the three range strings appeared in that line. A job that diffs the warning cannot tell which range was in the file, and it cannot tell an object from a string. The warning names the key. It does not parse the value. The parenthetical about VS Code and Cursor is what this binary prints for the key. It is not a comparison against 2.1.308, and it is not a comparison against 2.1.323.
--json on the excluding directory exited 0. success was true. strict was false. The engines warning object had path set to engines, code null, and the same message. The range was not a separate field. A script that reads success and stops will accept the excluding manifest. A script that reads warnings[].path can see that engines was present. It still cannot see whether the range would have excluded this binary, because the message does not contain the range.
A second warning, missing author, was on the same reports. It is not the version result. --strict turned both warnings into a failure and exited 1. The last line was Validation failed (--strict treats warnings as errors). That exit means a warning was present. It does not mean the running version is outside the range. A missing author produces the same exit. If the job’s purpose is “do not load a plugin outside the range,” --strict is the wrong tool on this binary. It fails the file for a field the runtime says it ignores, and it fails a missing author the same way. The file that --strict rejects is the file the next section loads.
--profile directory on the excluding directory also exited 1. The failure line said the plugin directory would refuse a submission. The problems it named were a missing README and a missing LICENSE. The engines line was still a warning above that refusal. The directory profile does not read the range. It fails a catalog submission that has no README. The human report, without that flag, says those directory checks do not change the result. A pass from the default command is not a catalog acceptance, and a directory-profile failure is not a version refusal.
The skill was seated
claude --plugin-dir pointed at the excluding directory, followed by plugin list, exited 0. The row was exclude-plugin@inline, version 0.2.0, status loaded. The including directory printed include-plugin@inline at the same version, also loaded. The string directory printed string-engines@inline, version 0.1.0, also loaded. The status line does not mention engines. A loaded row for a range below 2.0.0 and a loaded row for a range that includes 2.1.308 look the same.
The 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 excluding row and on the including row. It does not mean the range was checked. Read the name in front of it, then read the status. Both said loaded.
List is the short check. The print run is how this session confirmed the skill, not only the row. It used --bare, --verbose, --debug-file, -p, and --output-format stream-json. The prompt was the word ping. The key was rejected. The init object still named the plugin and the skill.
For the excluding directory, plugins included exclude-plugin at version 0.2.0 with source exclude-plugin@inline. skills and slash_commands both included exclude-plugin:greet. That name is the plugin name, a colon, and the name from SKILL.md. The including directory’s init object included include-plugin:greet in the same two arrays. The debug log for the excluding directory said Loaded inline plugin from path: exclude-plugin, then Loaded 1 skills from plugin exclude-plugin default directory, then Total plugin skills loaded: 1. It did not contain the word engines. The including directory’s debug log said Loaded inline plugin from path: include-plugin and Total plugin skills loaded: 1. The strip is silent. Validate is the only command in this run that named the field.
--bare skips hooks defined in settings and by installed plugins, along with a list of other startup work. The skill still resolved. A list row can say loaded while a child skill is not seated. That is not what happened here. The debug count and the slash-command name agree. The range did not hide the skill, and it did not hide the plugin.
Built-in plugins were also in the init object, with source names ending in @builtin. They are not part of the fixture. The fixture’s contribution is the one inline plugin and the one skill whose name starts with that plugin’s name. Count those. Do not treat the length of slash_commands as the plugin’s skill count. The debug line that says Total plugin skills loaded: 1 is the count for the directory you passed.
What actually refuses the load
Two other manifests failed, and they failed for reasons the engines warning does not cover.
A truncated plugin.json failed validate with exit 1: Invalid JSON syntax: JSON Parse error: Property name must be a string literal. plugin list against that directory exited 0 and printed Failed to load plugin with the same parse error. The exit code of list is not the load result. The status text is. A wrapper that only checks the exit code of plugin list will accept a corrupt manifest and an out-of-range manifest as the same success. Both were exit 0 in this run. One said loaded. One said failed to load.
A manifest with version, description, and engines, and no name, failed validate with exit 1: name: Invalid input: expected string, received undefined. The engines warning was still printed, including the sentence that it is safe to keep. The failure line was Validation failed, from the missing name, not from the range. plugin list exited 0 and printed Failed to load plugin with that name error. The warning and the refusal were both on the page. Only the name error stopped the load.
That split is the useful one. On this binary the warning is true: the field is ignored, and a missing name is what stops the load. On the 2.1.323 changelog the same key is a gate. Keeping the field because 2.1.308 said it was safe does not keep the plugin loading after the upgrade. If the range excludes the new binary, the changelog says the plugin will not load. The warning does not say that. The changelog does. A file you leave in place because the warning called it harmless can become the file a newer binary refuses.
The missing-name report is also a reason not to treat every engines line as benign noise you can strip from a log and forget. On a failed validation the line is still printed. If you grep for safe to keep and ignore the Validation failed line under it, you will miss the name error. Read the last result line, then read the status from plugin list. The engines sentence does not replace either of those.
Verify the load before you trust the range
On 2.1.308, do not use engines as a compatibility pin. Read claude --version first. If it is not 2.1.323 or newer, the range is not a gate this binary will enforce. The checks that mean something here are the ones this run actually separated.
Validate without --strict. Exit 0 plus Validation passed with warnings and an engines line means the key was seen and not applied. The line will not show the range. Open the file if you need the text. Do not infer the range text from the warning, and do not infer that the value was an object.
Validate with --strict only when you want warnings to fail the job. Exit 1 means a warning was present. It does not mean the running version is outside the range. Drop --strict if the question is whether the plugin loads. The excluding directory failed --strict and still loaded.
plugin list with --plugin-dir is the load check that does not need a key. Read the status. loaded means the plugin was seated, including the range below 2.0.0 on 2.1.308. Failed to load means a manifest error, such as bad JSON or a missing name. The exit code was 0 in both cases in this run. A green exit from list is not a version acceptance.
A print run, if you need the skill name, can stop at the init object. A 401 and a cost of 0 are not a plugin refusal. Look for the plugin name, a colon, and the skill name in skills. The debug line Total plugin skills loaded: 1 is the count for a directory with one skill file. Absence of the word engines in that log is expected on this binary. It is not evidence that the field was compared and accepted.
After an upgrade, repeat the excluding-directory list before you trust the changelog sentence. If 2.1.323 or later refuses the below-2.0.0 plugin and still loads the 2.1.300-or-newer plugin, the gate described on 12 November 2026 is present on that binary. If both still say loaded, the field is still ignored, whatever the changelog says. This run cannot make that second measurement. It can tell you not to skip it, and it can tell you not to treat today’s warning as that measurement in advance.
Grok Build 1.0.67, asked to validate the same excluding directory, exited 0 and printed Plugin manifest is valid, the plugin name, the version, and 1 skill dir(s). It did not mention engines. That binary’s top-level help does not list --plugin-dir, and this run did not use Grok to load the directory. Grok’s validator is not a substitute for the Claude list, and it is not the 2.1.323 gate either. The compatibility page that says Grok reads Claude plugins does not say Grok enforces a Claude Code version range. This validate result does not add that enforcement.
The skill the excluding range was written to withhold was seated. The warning said the field would be ignored. The changelog for a release that was not installed says the field will withhold the plugin. The version string is the line that decides which of those sentences you are in.
Sources
- Claude Code changelog, 2.1.323, 12 November 2026
- Plugin manifest reference, read 12 November 2026. The fields table did not list
engines. Unrecognized top-level keys are described as stripped at load, with a validate warning. - Claude Code 2.1.308 on Linux x86_64, 12 November 2026.
claude --versionprinted2.1.308 (Claude Code). Validate,plugin list, and a rejected print run were the measurements.