Migrate plugins and canvas extensions to Agent Plugins spec (#2546)

* feat: migrate plugins and extensions to Agent Plugins v1.0.0 spec

- Add \ to all 69 curated plugin manifests
- Migrate all 18 extension manifests: add \, move logo into
  xtensions.com.github.copilot.logo namespace, remove top-level
  logo and string xtensions: '.'
- Update eng/validate-plugins.mjs: require \, validate
  namespace-keyed extensions object for canvas extensions, widen
  name pattern to allow dots (spec §5.5, max 64 chars)
- Update eng/materialize-plugins.mjs: emit spec-clean served manifests
  (only spec fields: \, name, version, description, author,
  homepage, repository, license, keywords, extensions)
- Update eng/generate-website-data.mjs: read logo from namespace
  with fallback to top-level logo for compatibility
- Update eng/create-plugin.mjs: scaffold emits \
- Add .github/workflows/validate-plugins.yml: blocking CI for PRs
  touching plugins/** or extensions/**
- Add spec compliance check to external plugin quality gates:
  non-blocking warnings with /⚠️/🛑 emoji legend
- Update AGENTS.md: document new extension manifest shape,
  add \ to plugin checklist

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76

* refactor: consolidate canvas extension plugins

- Move all extension plugin manifests from extensions/<name> to plugins/<name>
- Keep extensions/<name> as reusable source only
- Remove standalone extension discovery from marketplace and website plugin catalogs
- Auto-bundle same-name extension sources during materialization
- Add build-only extensions.json references for sharing extensions across plugins
- Remove x-awesome-copilot extension metadata support
- Update validation and contributor documentation

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76

* feat: add canvas extension scaffolding skill

- Add repo-local skill for creating canvas extension sources
- Generate spec-compliant plugin manifests under plugins/
- Support registering reusable extensions with multiple plugins
- Remove guidance for extension-local plugin manifests and custom fields

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76

* fix: align extension namespaces with current guidance

- Use each extension ID as its manifest namespace key
- Update validation and website generation to resolve extension-specific namespaces
- Upsert plugin validation PR comments using the existing repository pattern

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76

* fix: use Copilot extension namespace

- Adopt com.github.copilot for all canvas extension manifests
- Require the namespace during validation and website generation
- Update extension scaffolding guidance

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76

* docs: regenerate plugin catalog after merge

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76

* refactor(plugins): move manifests to plugin roots

Use root plugin.json manifests and namespaced extension directories throughout local tooling, validation, generation, and contributor documentation. Restore materialize-plugins.mjs line breaks so the source remains readable in GitHub.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76

* feat(plugins): migrate manifests to namespaced composition

Move repository composition metadata under com.github.awesome-copilot, materialize reusable extensions into the plugin extensions directory, and improve contributor and PR validation guidance.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76

* fix(validation): address plugin review findings

Restore executable build scripts, validate namespaced manifests and hook directories, improve README item counts, and manage validation comments across reruns.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76
This commit is contained in:
Aaron Powell
2026-08-06 22:29:31 +10:00
committed by GitHub
parent 6c7fe1b15f
commit a7fdcd5006
147 changed files with 2454 additions and 1320 deletions
+26 -90
View File
@@ -540,8 +540,6 @@ function resolvePluginItem(item, resourceIndex) {
*/
function generatePluginsData(gitDates, resourceIndex = {}) {
const plugins = [];
const extensionEntriesByName = new Map();
if (!fs.existsSync(PLUGINS_DIR)) {
return { items: [], filters: { tags: [] } };
}
@@ -550,85 +548,30 @@ function generatePluginsData(gitDates, resourceIndex = {}) {
.readdirSync(PLUGINS_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory());
if (fs.existsSync(EXTENSIONS_DIR)) {
const extensionDirs = fs.readdirSync(EXTENSIONS_DIR, { withFileTypes: true })
.filter((entry) => {
if (!entry.isDirectory()) return false;
return hasExtensionEntryPoint(path.join(EXTENSIONS_DIR, entry.name), entry.name);
})
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b));
for (const extensionDirName of extensionDirs) {
const extensionDir = path.join(EXTENSIONS_DIR, extensionDirName);
const pluginJsonPath = path.join(extensionDir, ".github", "plugin", "plugin.json");
if (!fs.existsSync(pluginJsonPath)) {
continue;
}
try {
const extensionPlugin = JSON.parse(fs.readFileSync(pluginJsonPath, "utf-8"));
const pluginName = normalizeText(extensionPlugin.name, extensionDirName);
const pluginDescription = normalizeText(extensionPlugin.description, "Canvas extension");
const extensionKeywords = Array.isArray(extensionPlugin.keywords)
? [...new Set(extensionPlugin.keywords.filter((keyword) => typeof keyword === "string").map((keyword) => keyword.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b))
: [];
const relPath = `extensions/${extensionDirName}`;
const extensionItem = resolvePluginItem(
{
kind: "extension",
path: relPath,
},
resourceIndex
);
const extReadmePath = path.join(extensionDir, "README.md");
const extReadmeFile = fs.existsSync(extReadmePath)
? `${relPath}/README.md`
: null;
extensionEntriesByName.set(pluginName, {
id: pluginName,
name: pluginName,
description: pluginDescription,
path: relPath,
readmeFile: extReadmeFile,
version: normalizeText(extensionPlugin.version, null),
tags: extensionKeywords,
itemCount: 1,
items: [extensionItem],
generatedFromExtension: true,
lastUpdated: getDirectoryLastUpdated(gitDates, relPath),
searchText: `${pluginName} ${pluginDescription} ${extensionKeywords.join(" ")} canvas extension`.toLowerCase(),
});
} catch (e) {
console.warn(`Failed to parse extension plugin manifest for ${extensionDirName}: ${e.message}`);
}
}
}
for (const dir of pluginDirs) {
const pluginDir = path.join(PLUGINS_DIR, dir.name);
const jsonPath = path.join(pluginDir, ".github/plugin", "plugin.json");
const jsonPath = path.join(pluginDir, "plugin.json");
if (!fs.existsSync(jsonPath)) continue;
try {
const data = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
const relPath = `plugins/${dir.name}`;
const extensionRefs = Array.isArray(data?.["x-awesome-copilot"]?.extensions)
? data["x-awesome-copilot"].extensions
: [];
const composition = data.extensions?.["com.github.awesome-copilot"] ?? {};
const extensionRefs = composition.extensions
?.map((entry) => entry.replace(/^\.\/extensions\//, "").replace(/\/$/, ""))
.filter(Boolean) ?? [];
if (fs.existsSync(path.join(EXTENSIONS_DIR, dir.name, "extension.mjs")) && !extensionRefs.includes(dir.name)) {
extensionRefs.push(dir.name);
}
const extensionItems = extensionRefs
.map((entry) => normalizeText(entry))
.filter(Boolean)
.map((entry) => entry.replace(/^\.\/+/, "").replace(/\/$/, ""))
.filter((entry) => entry.startsWith("extensions/"))
.filter((entry) => typeof entry === "string")
.map((entry) => ({
kind: "extension",
path: entry,
path: `extensions/${entry}`,
}));
const agentItems = (data.agents || []).flatMap((agent) => {
const agentItems = (composition.agents || []).flatMap((agent) => {
const agentPath = agent.replace("./", "");
const fullPath = path.join(pluginDir, agentPath);
@@ -646,11 +589,11 @@ function generatePluginsData(gitDates, resourceIndex = {}) {
// Parse mcpServers: supports a path to a .mcp.json file or an inline object
const mcpItems = [];
if (data.mcpServers) {
if (composition.mcpServers) {
let mcpServersObj = null;
let mcpConfigPath = relPath;
if (typeof data.mcpServers === "string") {
const manifestMcpPath = data.mcpServers.replace(/^\.\//, "");
if (typeof composition.mcpServers === "string") {
const manifestMcpPath = composition.mcpServers.replace(/^\.\//, "");
mcpConfigPath = manifestMcpPath ? `${relPath}/${manifestMcpPath}` : relPath;
const mcpJsonPath = path.join(pluginDir, manifestMcpPath);
if (fs.existsSync(mcpJsonPath)) {
@@ -661,8 +604,8 @@ function generatePluginsData(gitDates, resourceIndex = {}) {
// ignore parse errors
}
}
} else if (typeof data.mcpServers === "object") {
mcpServersObj = data.mcpServers;
} else if (typeof composition.mcpServers === "object") {
mcpServersObj = composition.mcpServers;
}
if (mcpServersObj) {
for (const serverName of Object.keys(mcpServersObj)) {
@@ -674,8 +617,8 @@ function generatePluginsData(gitDates, resourceIndex = {}) {
// Build items list from spec fields (agents, commands, skills, mcpServers)
const items = [
...agentItems,
...(data.commands || []).map((p) => ({ kind: "prompt", path: p })),
...(data.skills || []).map((p) => ({ kind: "skill", path: p })),
...(composition.commands || []).map((p) => ({ kind: "prompt", path: p })),
...(composition.skills || []).map((p) => ({ kind: "skill", path: p })),
...extensionItems,
...mcpItems,
].map((item) => resolvePluginItem(item, resourceIndex));
@@ -702,16 +645,11 @@ function generatePluginsData(gitDates, resourceIndex = {}) {
searchText: `${pluginName} ${data.description || ""
} ${tags.join(" ")}`.toLowerCase(),
});
extensionEntriesByName.delete(pluginName);
} catch (e) {
console.warn(`Failed to parse plugin: ${dir.name}`, e.message);
}
}
for (const extensionPlugin of extensionEntriesByName.values()) {
plugins.push(extensionPlugin);
}
// Load external plugins from plugins/external.json
const externalJsonPath = path.join(PLUGINS_DIR, "external.json");
if (fs.existsSync(externalJsonPath)) {
@@ -1204,15 +1142,13 @@ function resolveExtensionScreenshots(pluginJson, extensionDir, relPath, ref) {
}
: null;
const logoEntry = normalizeExtensionScreenshotRole(pluginJson?.logo, relPath, ref);
const screenshotConfig = pluginJson?.["x-awesome-copilot"]?.screenshots || {};
const iconEntry = normalizeExtensionScreenshotRole(screenshotConfig.icon, relPath, ref);
const galleryRaw = screenshotConfig.gallery;
const firstGalleryEntry = Array.isArray(galleryRaw) ? galleryRaw[0] : galleryRaw;
const galleryEntry = normalizeExtensionScreenshotRole(firstGalleryEntry, relPath, ref);
const finalIcon = iconEntry || logoEntry || inferredIcon;
const finalGallery = galleryEntry || logoEntry || inferredGallery || finalIcon;
const copilotNs = pluginJson?.extensions?.["com.github.copilot"];
const logoEntry = normalizeExtensionScreenshotRole(
copilotNs?.logo ?? pluginJson?.logo,
relPath, ref
);
const finalIcon = logoEntry || inferredIcon;
const finalGallery = logoEntry || inferredGallery || finalIcon;
return {
screenshots: {
@@ -1256,7 +1192,7 @@ function generateCanvasManifest(gitDates, commitSha) {
const packageJson = fs.existsSync(packageJsonPath)
? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"))
: {};
const pluginJsonPath = path.join(extensionDir, ".github", "plugin", "plugin.json");
const pluginJsonPath = path.join(PLUGINS_DIR, dir.name, "plugin.json");
const pluginJson = fs.existsSync(pluginJsonPath)
? JSON.parse(fs.readFileSync(pluginJsonPath, "utf-8"))
: {};