From 17e21a36f817c3e68871cf52daead19f91d9ab68 Mon Sep 17 00:00:00 2001 From: Christopher Date: Fri, 13 Mar 2026 11:50:34 +0000 Subject: [PATCH] feat: auto-wrap flat SKILL.md repos as skills (#232) When a plugin has no skills/ subdirectory, scan the plugin root for directories containing SKILL.md and treat each as a skill. This gives compatibility with the npx skills ecosystem (e.g. vercel-labs/agent-skills) without any schema changes. The enabledSkills/disabledSkills schema continues to work unchanged since the plugin is still tracked as a workspace plugin entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/core/skills.ts | 35 ++++++++++++++++++++++++++--------- src/core/transform.ts | 39 +++++++++++++++++++++++++++------------ 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/src/core/skills.ts b/src/core/skills.ts index ffc01119..8524ba55 100644 --- a/src/core/skills.ts +++ b/src/core/skills.ts @@ -98,26 +98,43 @@ export async function getAllSkillsFromPlugins( const pluginName = resolved.pluginName ?? getPluginName(pluginPath); const skillsDir = join(pluginPath, 'skills'); - if (!existsSync(skillsDir)) continue; - - const entries = await readdir(skillsDir, { withFileTypes: true }); - const skillDirs = entries.filter((e) => e.isDirectory()); - // Only apply enabledSkills to plugins that actually have entries in the set const hasEnabledEntries = enabledSkills && [...enabledSkills].some((s) => s.startsWith(`${pluginName}:`)); - for (const entry of skillDirs) { - const skillKey = `${pluginName}:${entry.name}`; + let skillEntries: { name: string; skillPath: string }[]; + + if (existsSync(skillsDir)) { + // Standard layout: plugin/skills// + const entries = await readdir(skillsDir, { withFileTypes: true }); + skillEntries = entries + .filter((e) => e.isDirectory()) + .map((e) => ({ name: e.name, skillPath: join(skillsDir, e.name) })); + } else { + // Flat layout: plugin//SKILL.md + const entries = await readdir(pluginPath, { withFileTypes: true }); + const flatSkills: { name: string; skillPath: string }[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillMdPath = join(pluginPath, entry.name, 'SKILL.md'); + if (existsSync(skillMdPath)) { + flatSkills.push({ name: entry.name, skillPath: join(pluginPath, entry.name) }); + } + } + skillEntries = flatSkills; + } + + for (const { name, skillPath } of skillEntries) { + const skillKey = `${pluginName}:${name}`; const isDisabled = hasEnabledEntries ? !enabledSkills?.has(skillKey) : disabledSkills.has(skillKey); skills.push({ - name: entry.name, + name, pluginName, pluginSource, - path: join(skillsDir, entry.name), + path: skillPath, disabled: isDisabled, }); } diff --git a/src/core/transform.ts b/src/core/transform.ts index 5f53b94e..fcb54802 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -367,29 +367,44 @@ export async function collectPluginSkills( ): Promise { const skillsDir = join(pluginPath, 'skills'); - if (!existsSync(skillsDir)) { - return []; - } - - const entries = await readdir(skillsDir, { withFileTypes: true }); - const skillDirs = entries.filter((e) => e.isDirectory()); - // Filter skills: enabledSkills (allowlist) takes priority over disabledSkills (blocklist) // Only apply enabledSkills to plugins that actually have entries in the set const hasEnabledEntries = enabledSkills && pluginName && [...enabledSkills].some((s) => s.startsWith(`${pluginName}:`)); + let candidateDirs: { name: string; path: string }[]; + + if (existsSync(skillsDir)) { + // Standard layout: plugin/skills// + const entries = await readdir(skillsDir, { withFileTypes: true }); + candidateDirs = entries + .filter((e) => e.isDirectory()) + .map((e) => ({ name: e.name, path: join(skillsDir, e.name) })); + } else { + // Flat layout: plugin//SKILL.md + const entries = await readdir(pluginPath, { withFileTypes: true }); + const flatDirs: { name: string; path: string }[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillMdPath = join(pluginPath, entry.name, 'SKILL.md'); + if (existsSync(skillMdPath)) { + flatDirs.push({ name: entry.name, path: join(pluginPath, entry.name) }); + } + } + candidateDirs = flatDirs; + } + const filteredDirs = pluginName ? hasEnabledEntries - ? skillDirs.filter((e) => enabledSkills?.has(`${pluginName}:${e.name}`)) + ? candidateDirs.filter((e) => enabledSkills?.has(`${pluginName}:${e.name}`)) : disabledSkills - ? skillDirs.filter((e) => !disabledSkills.has(`${pluginName}:${e.name}`)) - : skillDirs - : skillDirs; + ? candidateDirs.filter((e) => !disabledSkills.has(`${pluginName}:${e.name}`)) + : candidateDirs + : candidateDirs; return filteredDirs.map((entry) => ({ folderName: entry.name, - skillPath: join(skillsDir, entry.name), + skillPath: entry.path, pluginPath, pluginSource, }));