From 55b952d2f9bd5b092d2f4b87fdbcf205a1a5ccc5 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Thu, 13 Aug 2026 11:24:06 +1000 Subject: [PATCH] fix(plugins): namespace Copilot materialized content (#2643) Place Copilot-specific content in com.github.copilot and remove unsupported command handling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 04c14c3f-d248-4a7f-93ab-93fd8b2b119e --- AGENTS.md | 6 +-- CONTRIBUTING.md | 9 ++-- eng/clean-materialized-plugins.mjs | 84 ++++++++++++++++++++---------- eng/generate-website-data.mjs | 3 +- eng/materialize-plugins.mjs | 22 ++++---- eng/update-readme.mjs | 2 - eng/validate-plugins.mjs | 7 ++- 7 files changed, 78 insertions(+), 55 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fc10edd7..e3f6eebc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ The Awesome GitHub Copilot repository is a community-driven collection of custom - **Skills** - Self-contained folders with instructions and bundled resources for specialized tasks - **Hooks** - Automated workflows triggered by specific events during development - **Workflows** - [Agentic Workflows](https://github.github.com/gh-aw) for AI-powered repository automation in GitHub Actions -- **Plugins** - Installable packages that group related agents, commands, and skills around specific themes +- **Plugins** - Installable packages that group related agents, hooks, and skills around specific themes ## Repository Structure @@ -120,7 +120,7 @@ All agent files (`*.agent.md`) and instruction files (`*.instructions.md`) must - plugin.json must have `name` field (matching the folder name) - plugin.json must have `description` field (describing the plugin's purpose) - plugin.json must have `version` field (semantic version, e.g., "1.0.0") -- Plugin content is defined declaratively in plugin.json under `extensions.com.github.awesome-copilot` using source-only composition fields (`agents`, `commands`, `hooks`, `skills`, and `extensions`). Source files live in top-level directories and are materialized into plugins by CI. This namespace is stripped from the served manifest — conventional directory discovery handles the materialized content in spec mode. +- Plugin content is defined declaratively in plugin.json under `extensions.com.github.awesome-copilot` using source-only composition fields (`agents`, `hooks`, `skills`, and `extensions`). Source files live in top-level directories and are materialized into plugins by CI. This namespace is stripped from the served manifest — skills use the standard `skills/` directory and Copilot-specific content uses `com.github.copilot/`. - The `marketplace.json` file is automatically generated from all plugins during build - Plugins are discoverable and installable via GitHub Copilot CLI @@ -165,7 +165,7 @@ When adding a new agent, instruction, skill, hook, workflow, or plugin: **For Plugins:** 1. Run `npm run plugin:create -- --name ` to scaffold a new plugin -2. Define agents, commands, hooks, skills, and reusable extensions under `extensions.com.github.awesome-copilot` in `plugin.json` +2. Define agents, hooks, skills, and reusable extensions under `extensions.com.github.awesome-copilot` in `plugin.json` 3. Edit the generated `plugin.json` with your metadata 4. Run `npm run plugin:validate` to validate the plugin structure 5. Run `npm run build` to update README.md and marketplace.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 866b33ef..9dbbb079 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -153,11 +153,11 @@ Canvas extensions live in `extensions//` as reusable source compon ### Adding Plugins -Plugins group related agents, commands, and skills around specific themes or workflows, making it easy for users to install comprehensive toolkits via GitHub Copilot CLI. +Plugins group related agents, hooks, and skills around specific themes or workflows, making it easy for users to install comprehensive toolkits via GitHub Copilot CLI. 1. **Create your plugin**: Run `npm run plugin:create` to scaffold a new plugin 2. **Follow the naming convention**: Use descriptive, lowercase folder names with hyphens (e.g., `python-web-development`) -3. **Define your content**: List agents, commands, hooks, skills, and reusable extensions under `extensions.com.github.awesome-copilot` in `plugin.json` +3. **Define your content**: List agents, hooks, skills, and reusable extensions under `extensions.com.github.awesome-copilot` in `plugin.json` 4. **Test your plugin**: Run `npm run plugin:validate` to verify your plugin structure #### Creating a plugin @@ -174,7 +174,7 @@ plugins/my-plugin-id/ └── README.md # Plugin documentation ``` -> **Note:** Plugin content is defined declaratively in plugin.json under `extensions.com.github.awesome-copilot`. Source files live in top-level directories and are materialized into plugins by CI. This repository namespace is removed from the served manifest. +> **Note:** Plugin content is defined declaratively in plugin.json under `extensions.com.github.awesome-copilot`. Source files live in top-level directories and are materialized into plugins by CI. Skills are emitted under `skills/`; Copilot-specific agents, hooks, and extensions are emitted under `com.github.copilot/`. This repository namespace is removed from the served manifest. #### plugin.json example @@ -191,7 +191,6 @@ plugins/my-plugin-id/ "extensions": { "com.github.awesome-copilot": { "agents": ["./agents/my-agent.md"], - "commands": ["./commands/my-command.md"], "skills": ["./skills/my-skill/"] } } @@ -200,7 +199,7 @@ plugins/my-plugin-id/ #### Plugin Guidelines -- **Declarative content**: Plugin content is specified under `extensions.com.github.awesome-copilot` — source files live in top-level directories and are materialized into plugins by CI +- **Declarative content**: Plugin content is specified under `extensions.com.github.awesome-copilot` — source files live in top-level directories and are materialized into plugins by CI. Skills use the standard `skills/` directory; Copilot-specific content uses `com.github.copilot/`. - **Valid references**: All paths referenced in plugin.json must point to existing source files in the repository - **Reusable extensions**: Curated plugins can bundle extensions by adding `./extensions/` paths under `extensions.com.github.awesome-copilot.extensions`; the same extension can be listed by multiple plugins - **Instructions excluded**: Instructions are standalone resources and are not part of plugins diff --git a/eng/clean-materialized-plugins.mjs b/eng/clean-materialized-plugins.mjs index f6584a41..655b2e18 100644 --- a/eng/clean-materialized-plugins.mjs +++ b/eng/clean-materialized-plugins.mjs @@ -7,25 +7,35 @@ import { ROOT_FOLDER } from "./constants.mjs"; const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins"); const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions"); +const COPILOT_CONTENT_DIR = "com.github.copilot"; +const AWESOME_COPILOT_NAMESPACE = "com.github.awesome-copilot"; const MATERIALIZED_SPECS = { agents: { - path: "agents", + paths: [path.join(COPILOT_CONTENT_DIR, "agents"), "agents"], restore(dirPath) { return collectFiles(dirPath).map((relativePath) => `./agents/${relativePath}`); }, }, - commands: { - path: "commands", + hooks: { + paths: [path.join(COPILOT_CONTENT_DIR, "hooks"), "hooks"], restore(dirPath) { - return collectFiles(dirPath).map((relativePath) => `./commands/${relativePath}`); + return collectDirectoriesContainingFile(dirPath, "hooks.json") + .map((relativePath) => `./hooks/${relativePath}/`); }, }, skills: { - path: "skills", + paths: ["skills"], restore(dirPath) { return collectSkillDirectories(dirPath).map((relativePath) => `./skills/${relativePath}/`); }, }, + extensions: { + paths: [path.join(COPILOT_CONTENT_DIR, "extensions"), "extensions"], + restore(dirPath) { + return collectDirectoriesContainingFile(dirPath, "extension.mjs") + .map((relativePath) => `./extensions/${relativePath}`); + }, + }, }; function copyDirRecursive(src, dest) { @@ -78,22 +88,19 @@ export function restoreManifestFromMaterializedFiles(pluginPath) { let changed = false; for (const [field, spec] of Object.entries(MATERIALIZED_SPECS)) { - if (Array.isArray(plugin[field])) { - const sortedEntries = sortPluginEntries(plugin[field]); - if (!arraysEqual(plugin[field], sortedEntries)) { - plugin[field] = sortedEntries; - changed = true; - } - } - - const materializedPath = path.join(pluginPath, spec.path); - if (!fs.existsSync(materializedPath) || !fs.statSync(materializedPath).isDirectory()) { + const materializedPath = spec.paths + .map((subdir) => path.join(pluginPath, subdir)) + .find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()); + if (!materializedPath) { continue; } const restored = spec.restore(materializedPath); - if (!arraysEqual(plugin[field], restored)) { - plugin[field] = restored; + const composition = plugin.extensions?.[AWESOME_COPILOT_NAMESPACE]; + if (!arraysEqual(composition?.[field], restored)) { + plugin.extensions ??= {}; + plugin.extensions[AWESOME_COPILOT_NAMESPACE] ??= {}; + plugin.extensions[AWESOME_COPILOT_NAMESPACE][field] = restored; changed = true; } } @@ -112,16 +119,23 @@ function cleanPlugin(pluginPath) { } let removed = 0; - for (const { path: subdir } of Object.values(MATERIALIZED_SPECS)) { - const target = path.join(pluginPath, subdir); - if (fs.existsSync(target) && fs.statSync(target).isDirectory()) { - const count = countFiles(target); - fs.rmSync(target, { recursive: true, force: true }); - removed += count; - console.log(` Removed ${path.basename(pluginPath)}/${subdir}/ (${count} files)`); + for (const { paths } of Object.values(MATERIALIZED_SPECS)) { + for (const subdir of paths) { + const target = path.join(pluginPath, subdir); + if (fs.existsSync(target) && fs.statSync(target).isDirectory()) { + const count = countFiles(target); + fs.rmSync(target, { recursive: true, force: true }); + removed += count; + console.log(` Removed ${path.basename(pluginPath)}/${subdir}/ (${count} files)`); + } } } + const copilotContentPath = path.join(pluginPath, COPILOT_CONTENT_DIR); + if (fs.existsSync(copilotContentPath) && fs.readdirSync(copilotContentPath).length === 0) { + fs.rmdirSync(copilotContentPath); + } + return { removed, manifestUpdated }; } @@ -230,6 +244,24 @@ function collectSkillDirectories(dir, rootDir = dir) { return skillDirs.sort(); } +function collectDirectoriesContainingFile(dir, fileName, rootDir = dir) { + const directories = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + const entryPath = path.join(dir, entry.name); + if (fs.existsSync(path.join(entryPath, fileName))) { + directories.push(toPosixPath(path.relative(rootDir, entryPath))); + continue; + } + + directories.push(...collectDirectoriesContainingFile(entryPath, fileName, rootDir)); + } + return directories.sort(); +} + function arraysEqual(left, right) { if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { return false; @@ -238,10 +270,6 @@ function arraysEqual(left, right) { return left.every((value, index) => value === right[index]); } -function sortPluginEntries(entries) { - return [...entries].sort((left, right) => left.localeCompare(right)); -} - function toPosixPath(filePath) { return filePath.split(path.sep).join("/"); } diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs index 21aeddd4..57f058ec 100755 --- a/eng/generate-website-data.mjs +++ b/eng/generate-website-data.mjs @@ -618,10 +618,9 @@ function generatePluginsData(gitDates, resourceIndex = {}) { } } - // Build items list from spec fields (agents, commands, skills, mcpServers) + // Build items list from supported composition fields. const items = [ ...agentItems, - ...(composition.commands || []).map((p) => ({ kind: "prompt", path: p })), ...(composition.skills || []).map((p) => ({ kind: "skill", path: p })), ...extensionItems, ...mcpItems, diff --git a/eng/materialize-plugins.mjs b/eng/materialize-plugins.mjs index 8d9b6ef7..512d4f79 100644 --- a/eng/materialize-plugins.mjs +++ b/eng/materialize-plugins.mjs @@ -9,6 +9,7 @@ const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins"); const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions"); const COPILOT_NAMESPACE = "com.github.copilot"; const AWESOME_COPILOT_NAMESPACE = "com.github.awesome-copilot"; +const COPILOT_CONTENT_DIR = COPILOT_NAMESPACE; /** * Recursively copy a directory. @@ -49,9 +50,6 @@ function resolveSource(relPath) { if (relPath.startsWith("./hooks/")) { return path.join(ROOT_FOLDER, "hooks", relPath.replace(/^\.\/hooks\//, "")); } - if (relPath.startsWith("./commands/")) { - return path.join(ROOT_FOLDER, "commands", relPath.replace(/^\.\/commands\//, "")); - } return null; } @@ -114,7 +112,7 @@ export function materializePlugins() { const composition = metadata.extensions?.[AWESOME_COPILOT_NAMESPACE] ?? {}; // Process repository composition fields. - for (const field of ["agents", "commands", "hooks", "skills"]) { + for (const field of ["agents", "hooks", "skills"]) { const entries = composition[field]; if (!Array.isArray(entries)) continue; for (const relPath of entries) { @@ -129,7 +127,10 @@ export function materializePlugins() { warnings++; continue; } - const dest = path.join(pluginPath, relPath.replace(/^\.\//, "").replace(/\/$/, "")); + const relativeDestination = relPath.replace(/^\.\//, "").replace(/\/$/, ""); + const dest = field === "skills" + ? path.join(pluginPath, relativeDestination) + : path.join(pluginPath, COPILOT_CONTENT_DIR, relativeDestination); fs.mkdirSync(path.dirname(dest), { recursive: true }); if (fs.statSync(src).isDirectory()) copyDirRecursive(src, dest); else fs.copyFileSync(src, dest); @@ -153,17 +154,16 @@ export function materializePlugins() { warnings++; continue; } - // Extensions are conventional plugin content and belong under the - // plugin's top-level extensions directory, not the client namespace. - const dest = path.join(pluginPath, "extensions", extensionName); + const dest = path.join(pluginPath, COPILOT_CONTENT_DIR, "extensions", extensionName); copyDirRecursive(src, dest); totalExtensions++; } // Emit a spec-compliant served manifest for the marketplace branch. - // Source manifests keep composition fields (agents and skills) - // for build tooling. The served manifest retains only Agent Plugins v1.0.0 fields - // so the runtime uses conventional directory discovery for all content. + // Source manifests keep repository composition fields for build tooling. + // The served manifest retains only Agent Plugins v1.0.0 fields; standard + // skills are discovered from skills/, while Copilot-specific content is + // discovered from com.github.copilot/. const SPEC_FIELDS = new Set(["$schema", "name", "version", "description", "author", "homepage", "repository", "license", "keywords", "extensions"]); const AGENT_PLUGINS_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; diff --git a/eng/update-readme.mjs b/eng/update-readme.mjs index b6cfbd67..821364c5 100644 --- a/eng/update-readme.mjs +++ b/eng/update-readme.mjs @@ -800,7 +800,6 @@ function generatePluginsSection(pluginsDir) { : 0; const itemCount = (composition.agents || []).length + - (composition.commands || []).length + (composition.skills || []).length + extensionReferences + implicitExtension; @@ -869,7 +868,6 @@ function generateFeaturedPluginsSection(pluginsDir) { : 0; const itemCount = (composition.agents || []).length + - (composition.commands || []).length + (composition.skills || []).length + extensionReferences + implicitExtension; diff --git a/eng/validate-plugins.mjs b/eng/validate-plugins.mjs index 1c01eaff..258facb4 100755 --- a/eng/validate-plugins.mjs +++ b/eng/validate-plugins.mjs @@ -120,7 +120,6 @@ function validateSpecPaths(plugin) { const errors = []; const specs = { agents: { prefix: "./agents/", suffix: ".md", repoDir: "agents", repoSuffix: ".agent.md" }, - commands: { prefix: "./commands/", suffix: ".md", repoDir: "commands", repoSuffix: ".md" }, hooks: { prefix: "./hooks/", suffix: "/", repoDir: "hooks", repoFile: "README.md" }, skills: { prefix: "./skills/", suffix: "/", repoDir: "skills", repoFile: "SKILL.md" }, }; @@ -215,7 +214,7 @@ function validateExtensionReferences(plugin, pluginDir) { function validateCompositionNamespace(plugin) { const errors = []; - const compositionFields = ["agents", "commands", "hooks", "mcpServers", "skills"]; + const compositionFields = ["agents", "hooks", "mcpServers", "skills"]; const extensions = plugin.extensions; const composition = extensions?.[AWESOME_COPILOT_NAMESPACE]; @@ -291,7 +290,7 @@ function validatePlugin(folderName) { // Rule 5b: license (shared with external plugins). Non-SPDX is a warning, not an error. const warnings = []; - for (const field of ["agents", "commands", "hooks", "mcpServers", "skills"]) { + for (const field of ["agents", "hooks", "mcpServers", "skills"]) { if (plugin[field] !== undefined) { errors.push(`${field} must be moved to extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}`); } @@ -301,7 +300,7 @@ function validatePlugin(folderName) { errors.push(...licenseResult.errors); warnings.push(...licenseResult.warnings); - // Rule 6: agents, commands, skills paths + // Rule 6: agents, hooks, and skills paths const specErrors = validateSpecPaths(plugin); errors.push(...specErrors);