From 2200acf276e800c41fc23e1e905512bc03f99db0 Mon Sep 17 00:00:00 2001 From: Conor Bronsdon <120674402+conorbronsdon@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:42:01 -0700 Subject: [PATCH 1/2] Recognize .claude-plugin/plugin.json for external plugin submissions External plugin intake and quality gates resolve a submission's manifest from a fixed list of three locations: .github/plugin/plugin.json, .plugin/plugin.json, and plugin.json. A plugin whose manifest sits at .claude-plugin/plugin.json is reported as "No plugin.json found" and fails the version match gate before a maintainer sees it. The Copilot CLI already searches that location. The manifest resolver in the shipped native runtime (@github/copilot-linux-x64) carries the candidate directory set root, .plugin, .github/plugin, .claude-plugin, and the equivalent set for marketplace.json. So the intake list is a subset of what the CLI itself installs from, and the gap rejects plugins that would install correctly. Fifteen of the 37 entries already in plugins/external.json ship a .claude-plugin/plugin.json. For microsoft/skills-for-copilot-studio it is the only manifest in the repository, so its manifest is unresolvable under the current list. Add the location as a fourth candidate in all three copies of the list, ordered last so a repository shipping any of the original three keeps resolving to that one. netlify/context-and-tools is the case that makes ordering matter: it ships name=netlify version=1.1.0 at .github/plugin/plugin.json and name=netlify-skills version=1.2.0 at .claude-plugin/plugin.json, and external.json pins 1.1.0. Two secondary effects, both widening to match how the other three locations are already treated. Intake now resolves canvas metadata from the new location, and buildVallyLintArgs now lints the directories named in a .claude-plugin manifest's skills array rather than falling back to the whole plugin root. Tests cover the new location, the unchanged failure when no manifest exists in any location, and the precedence of .github/plugin/plugin.json when both are present. Co-Authored-By: Claude Opus 5 (1M context) --- eng/external-plugin-intake.mjs | 3 + eng/external-plugin-quality-gates.mjs | 12 +++- eng/external-plugin-quality-gates.test.mjs | 78 ++++++++++++++++++++++ eng/external-plugin-validation.mjs | 6 +- 4 files changed, 97 insertions(+), 2 deletions(-) diff --git a/eng/external-plugin-intake.mjs b/eng/external-plugin-intake.mjs index 372c73397..1a945b41a 100644 --- a/eng/external-plugin-intake.mjs +++ b/eng/external-plugin-intake.mjs @@ -57,10 +57,13 @@ const LEGACY_FIELD_TITLES = Object.freeze({ }); const EXTERNAL_CANVAS_KEYWORD = "canvas"; const EXTERNAL_CANVAS_PREVIEW_PATH = "assets/preview.png"; +// NOTE: Keep in sync with PLUGIN_JSON_CANDIDATES in external-plugin-quality-gates.mjs +// and EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS in external-plugin-validation.mjs. const EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS = Object.freeze([ ".github/plugin/plugin.json", ".plugin/plugin.json", "plugin.json", + ".claude-plugin/plugin.json", ]); function normalizeMultilineText(value) { diff --git a/eng/external-plugin-quality-gates.mjs b/eng/external-plugin-quality-gates.mjs index 014f9b83f..25cedd826 100644 --- a/eng/external-plugin-quality-gates.mjs +++ b/eng/external-plugin-quality-gates.mjs @@ -148,11 +148,21 @@ function cloneSubmissionRepository(workDir, plugin) { // Both the Copilot CLI and many external repos use nested conventions. We read the // manifest ourselves so skill paths can be resolved from the plugin root consistently, // regardless of where the manifest lives. -// NOTE: Keep in sync with EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS in external-plugin-validation.mjs +// NOTE: Keep this SET in sync with EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS in +// external-plugin-validation.mjs (used only to format the expected-location error +// message) and in external-plugin-intake.mjs (used to resolve canvas plugin +// metadata). Those two orderings already differ from this one; ordering is +// precedence only where a list actually resolves a manifest. +// +// These four directories are the same set the Copilot CLI searches for a plugin +// manifest, so a plugin the CLI can install resolves here too. `.claude-plugin` +// is last: a repo shipping any of the three original locations keeps resolving +// to that one. const PLUGIN_JSON_CANDIDATES = [ [".github", "plugin", "plugin.json"], [".plugin", "plugin.json"], ["plugin.json"], + [".claude-plugin", "plugin.json"], ]; function toPosixPath(...segments) { diff --git a/eng/external-plugin-quality-gates.test.mjs b/eng/external-plugin-quality-gates.test.mjs index b24d3037c..7eb24c73e 100644 --- a/eng/external-plugin-quality-gates.test.mjs +++ b/eng/external-plugin-quality-gates.test.mjs @@ -333,3 +333,81 @@ test("runRefShaConsistencyGate passes when ref and sha point to the same commit" const result = runRefShaConsistencyGate(repoDir, plugin, sha); assert.equal(result.status, "pass", result.output); }); + +// Manifest-location coverage: `.claude-plugin/plugin.json` is the Claude Code plugin +// spec location that CONTRIBUTING.md points external submitters at. It resolves last, +// so a repo shipping one of the three original locations keeps resolving to that one. + +function writePluginManifestAt(repoDir, pluginPath, manifestRelativePath, manifest) { + const manifestPath = path.join(repoDir, pluginPath, manifestRelativePath); + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); +} + +test("runVersionMatchGate resolves a manifest at .claude-plugin/plugin.json", () => { + const remoteDir = initRemoteRepo(); + writePluginManifestAt(remoteDir, "plugins/my-plugin", ".claude-plugin/plugin.json", { + name: "my-plugin", + version: "1.0.0", + }); + const sha = commitAll(remoteDir, "Add Claude Code spec plugin manifest"); + + const repoDir = cloneSubmissionRepo(remoteDir, sha); + const plugin = { + name: "my-plugin", + version: "1.0.0", + source: { source: "github", repo: "owner/repo", path: "plugins/my-plugin", sha }, + }; + + const result = runVersionMatchGate(repoDir, plugin, sha); + assert.equal(result.status, "pass", result.output); + assert.match(result.output, /matched version "1\.0\.0" at "plugins\/my-plugin\/\.claude-plugin\/plugin\.json"/); +}); + +test("runVersionMatchGate still fails when no manifest exists in any known location", () => { + const remoteDir = initRemoteRepo(); + fs.mkdirSync(path.join(remoteDir, "plugins", "my-plugin"), { recursive: true }); + fs.writeFileSync(path.join(remoteDir, "plugins", "my-plugin", "README.md"), "no manifest here\n"); + const sha = commitAll(remoteDir, "Add plugin folder without a manifest"); + + const repoDir = cloneSubmissionRepo(remoteDir, sha); + const plugin = { + name: "my-plugin", + version: "1.0.0", + source: { source: "github", repo: "owner/repo", path: "plugins/my-plugin", sha }, + }; + + const result = runVersionMatchGate(repoDir, plugin, sha); + assert.equal(result.status, "fail", result.output); + assert.match(result.output, /No plugin\.json found/); + // The failure message must enumerate every supported location, including the new one. + assert.match(result.output, /plugins\/my-plugin\/\.github\/plugin\/plugin\.json/); + assert.match(result.output, /plugins\/my-plugin\/\.plugin\/plugin\.json/); + assert.match(result.output, /plugins\/my-plugin\/plugin\.json/); + assert.match(result.output, /plugins\/my-plugin\/\.claude-plugin\/plugin\.json/); +}); + +test("runVersionMatchGate prefers .github/plugin/plugin.json over .claude-plugin/plugin.json", () => { + const remoteDir = initRemoteRepo(); + writePluginManifestAt(remoteDir, "plugins/my-plugin", ".github/plugin/plugin.json", { + name: "my-plugin", + version: "1.0.0", + }); + writePluginManifestAt(remoteDir, "plugins/my-plugin", ".claude-plugin/plugin.json", { + name: "my-plugin", + version: "9.9.9", + }); + const sha = commitAll(remoteDir, "Add both manifest locations"); + + const repoDir = cloneSubmissionRepo(remoteDir, sha); + const plugin = { + name: "my-plugin", + version: "1.0.0", + source: { source: "github", repo: "owner/repo", path: "plugins/my-plugin", sha }, + }; + + const result = runVersionMatchGate(repoDir, plugin, sha); + assert.equal(result.status, "pass", result.output); + assert.match(result.output, /at "plugins\/my-plugin\/\.github\/plugin\/plugin\.json"/); + assert.doesNotMatch(result.output, /\.claude-plugin/); +}); diff --git a/eng/external-plugin-validation.mjs b/eng/external-plugin-validation.mjs index 07f14c256..931365770 100644 --- a/eng/external-plugin-validation.mjs +++ b/eng/external-plugin-validation.mjs @@ -51,11 +51,15 @@ const ALLOWED_SOURCE_KEYS = Object.freeze(["source", "repo", "path", "ref", "sha const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; -// NOTE: Keep in sync with PLUGIN_JSON_CANDIDATES in external-plugin-quality-gates.mjs +// NOTE: Keep this SET in sync with PLUGIN_JSON_CANDIDATES in +// external-plugin-quality-gates.mjs and EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS in +// external-plugin-intake.mjs. This list only formats the expected-location error +// message, so its order carries no precedence meaning. const EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS = Object.freeze([ "plugin.json", ".github/plugin/plugin.json", ".plugin/plugin.json", + ".claude-plugin/plugin.json", ]); function resolvePolicy(policy) { From 0fe223b89cb71601072ddb01eb8b2e4787a964cd Mon Sep 17 00:00:00 2001 From: Conor Bronsdon Date: Mon, 3 Aug 2026 10:49:50 -0700 Subject: [PATCH 2/2] Correct the manifest-location comment in the new tests The comment said `.claude-plugin/plugin.json` is "the Claude Code plugin spec location that CONTRIBUTING.md points external submitters at." CONTRIBUTING.md does not mention `.claude-plugin` at all -- `grep -c` returns 0. It points submitters at `.github/plugin/plugin.json` (lines 145 and 175); what it borrows from the Claude Code spec is the field vocabulary (`agents`, `commands`, `skills`), not the directory. The actual justification is the one in the PR description and it did not travel into the code: the Copilot CLI's own manifest resolver searches this directory, so a plugin the CLI can install should resolve here too. Stating that instead, since it is the claim the change rests on and it is the one that survives a maintainer checking it. Co-Authored-By: Claude Opus 5 (1M context) --- eng/external-plugin-quality-gates.test.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/eng/external-plugin-quality-gates.test.mjs b/eng/external-plugin-quality-gates.test.mjs index 7eb24c73e..065e0fd1b 100644 --- a/eng/external-plugin-quality-gates.test.mjs +++ b/eng/external-plugin-quality-gates.test.mjs @@ -334,9 +334,10 @@ test("runRefShaConsistencyGate passes when ref and sha point to the same commit" assert.equal(result.status, "pass", result.output); }); -// Manifest-location coverage: `.claude-plugin/plugin.json` is the Claude Code plugin -// spec location that CONTRIBUTING.md points external submitters at. It resolves last, -// so a repo shipping one of the three original locations keeps resolving to that one. +// Manifest-location coverage: `.claude-plugin/plugin.json` is one of the four +// directories the Copilot CLI itself searches for a plugin manifest, so a plugin the +// CLI can install resolves here too. It resolves last, so a repo shipping one of the +// three original locations keeps resolving to that one. function writePluginManifestAt(repoDir, pluginPath, manifestRelativePath, manifest) { const manifestPath = path.join(repoDir, pluginPath, manifestRelativePath);