From baec2fc787b409aa104892636f94720d73324c2a Mon Sep 17 00:00:00 2001 From: Christopher Date: Sat, 14 Mar 2026 02:48:56 +0000 Subject: [PATCH] feat(skills): support root-level SKILL.md single-skill repos Add detection for repos where SKILL.md lives at the plugin root (the default layout created by `npx skills init`). The skill name is read from SKILL.md frontmatter, falling back to the directory name. Priority order: skills/ dir > flat subdirs with SKILL.md > root SKILL.md. Also extends copySkills() in transform.ts to handle flat and root-level layouts so these skills are actually synced to client paths. Closes #243 --- src/core/skills.ts | 19 ++++++- src/core/transform.ts | 98 +++++++++++++++++++++++++--------- tests/unit/core/skills.test.ts | 57 ++++++++++++++++++++ 3 files changed, 148 insertions(+), 26 deletions(-) diff --git a/src/core/skills.ts b/src/core/skills.ts index ab04e7fd..698c34fd 100644 --- a/src/core/skills.ts +++ b/src/core/skills.ts @@ -1,12 +1,13 @@ import { existsSync } from 'node:fs'; import { readFile, readdir } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; +import { join, basename, resolve } from 'node:path'; import { load } from 'js-yaml'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; import { getPluginSource, type WorkspaceConfig, type PluginSkillsConfig } from '../models/workspace-config.js'; import { fetchPlugin, getPluginName } from './plugin.js'; import { isGitHubUrl, parseGitHubUrl } from '../utils/plugin-path.js'; import { isPluginSpec, resolvePluginSpecWithAutoRegister } from './marketplace.js'; +import { parseSkillMetadata } from '../validators/skill.js'; /** * Information about a skill from an installed plugin @@ -134,7 +135,21 @@ export async function getAllSkillsFromPlugins( flatSkills.push({ name: entry.name, skillPath: join(pluginPath, entry.name) }); } } - skillEntries = flatSkills; + + if (flatSkills.length > 0) { + skillEntries = flatSkills; + } else { + // Root-level single-skill layout: plugin/SKILL.md + const rootSkillMd = join(pluginPath, 'SKILL.md'); + if (existsSync(rootSkillMd)) { + const skillContent = await readFile(rootSkillMd, 'utf-8'); + const metadata = parseSkillMetadata(skillContent); + const skillName = metadata?.name ?? basename(pluginPath); + skillEntries = [{ name: skillName, skillPath: pluginPath }]; + } else { + skillEntries = []; + } + } } const pluginSkillsMode: SkillInfo['pluginSkillsMode'] = diff --git a/src/core/transform.ts b/src/core/transform.ts index 297b291b..0f0ed10d 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -1,6 +1,6 @@ import { readFile, writeFile, mkdir, cp, readdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; -import { join, dirname, relative } from 'node:path'; +import { join, basename, dirname, relative } from 'node:path'; import micromatch from 'micromatch'; import { resolveGlobPatterns, isGlobPattern } from '../utils/glob-patterns.js'; import { CLIENT_MAPPINGS, isUniversalClient } from '../models/client-mapping.js'; @@ -10,6 +10,7 @@ import { generateWorkspaceRules, type WorkspaceRepository } from '../constants.j import { parseFileSource } from '../utils/plugin-path.js'; import { createSymlink } from '../utils/symlink.js'; import { adjustLinksInContent } from '../utils/link-adjuster.js'; +import { parseSkillMetadata } from '../validators/skill.js'; /** * Agent instruction files that receive WORKSPACE-RULES injection @@ -256,8 +257,51 @@ export async function copySkills( return results; } - const sourceDir = join(pluginPath, 'skills'); - if (!existsSync(sourceDir)) { + // Discover skill sources across all layouts + const skillsDir = join(pluginPath, 'skills'); + let skillSources: { name: string; sourcePath: string; isRootLevel: boolean }[]; + + if (existsSync(skillsDir)) { + // Standard layout: plugin/skills// + const entries = await readdir(skillsDir, { withFileTypes: true }); + skillSources = entries + .filter((e) => e.isDirectory()) + .filter((e) => !isExcluded(pluginPath, join(skillsDir, e.name), options.exclude)) + .map((e) => ({ name: e.name, sourcePath: join(skillsDir, e.name), isRootLevel: false })); + } else { + // Flat layout: plugin//SKILL.md + const entries = await readdir(pluginPath, { withFileTypes: true }); + const flatSkills: { name: string; sourcePath: string; isRootLevel: boolean }[] = []; + 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, sourcePath: join(pluginPath, entry.name), isRootLevel: false }); + } + } + + if (flatSkills.length > 0) { + skillSources = flatSkills; + } else { + // Root-level single-skill layout: plugin/SKILL.md + const rootSkillMd = join(pluginPath, 'SKILL.md'); + if (existsSync(rootSkillMd)) { + const content = await readFile(rootSkillMd, 'utf-8'); + const metadata = parseSkillMetadata(content); + const skillName = metadata?.name ?? basename(pluginPath); + skillSources = [{ name: skillName, sourcePath: pluginPath, isRootLevel: true }]; + } else { + return results; + } + } + } + + // When skillNameMap is provided, only copy skills that are in the map + if (skillNameMap) { + skillSources = skillSources.filter((s) => skillNameMap.has(s.name)); + } + + if (skillSources.length === 0) { return results; } @@ -266,29 +310,18 @@ export async function copySkills( await mkdir(destDir, { recursive: true }); } - const entries = await readdir(sourceDir, { withFileTypes: true }); - let skillDirs = entries.filter((e) => e.isDirectory()) - .filter((e) => !isExcluded(pluginPath, join(sourceDir, e.name), options.exclude)); - - // When skillNameMap is provided, only copy skills that are in the map - // (disabled skills are excluded from the map during collection) - if (skillNameMap) { - skillDirs = skillDirs.filter((e) => skillNameMap.has(e.name)); - } - // Determine if we should use symlinks for this client const useSymlinks = syncMode === 'symlink' && !isUniversalClient(client) && canonicalSkillsPath; // Process skill directories in parallel for better performance - const copyPromises = skillDirs.map(async (entry): Promise => { - const skillSourcePath = join(sourceDir, entry.name); + const copyPromises = skillSources.map(async (skill): Promise => { // Use resolved name from skillNameMap if available, otherwise use folder name - const resolvedName = skillNameMap?.get(entry.name) ?? entry.name; + const resolvedName = skillNameMap?.get(skill.name) ?? skill.name; const skillDestPath = join(destDir, resolvedName); if (dryRun) { return { - source: skillSourcePath, + source: skill.sourcePath, destination: skillDestPath, action: 'copied', }; @@ -307,23 +340,26 @@ export async function copySkills( }; } // Symlink failed, fall back to copy - // Log warning? For now, just fall through to copy } try { - if (options.exclude && options.exclude.length > 0) { - await copyDirectoryWithExclusions(skillSourcePath, skillDestPath, pluginPath, options.exclude); + if (skill.isRootLevel) { + // Root-level: copy only the SKILL.md into a new skill directory + await mkdir(skillDestPath, { recursive: true }); + await cp(join(skill.sourcePath, 'SKILL.md'), join(skillDestPath, 'SKILL.md')); + } else if (options.exclude && options.exclude.length > 0) { + await copyDirectoryWithExclusions(skill.sourcePath, skillDestPath, pluginPath, options.exclude); } else { - await cp(skillSourcePath, skillDestPath, { recursive: true }); + await cp(skill.sourcePath, skillDestPath, { recursive: true }); } return { - source: skillSourcePath, + source: skill.sourcePath, destination: skillDestPath, action: 'copied', }; } catch (error) { return { - source: skillSourcePath, + source: skill.sourcePath, destination: skillDestPath, action: 'failed', error: error instanceof Error ? error.message : 'Unknown error', @@ -392,7 +428,21 @@ export async function collectPluginSkills( flatDirs.push({ name: entry.name, path: join(pluginPath, entry.name) }); } } - candidateDirs = flatDirs; + + if (flatDirs.length > 0) { + candidateDirs = flatDirs; + } else { + // Root-level single-skill layout: plugin/SKILL.md + const rootSkillMd = join(pluginPath, 'SKILL.md'); + if (existsSync(rootSkillMd)) { + const content = await readFile(rootSkillMd, 'utf-8'); + const metadata = parseSkillMetadata(content); + const skillName = metadata?.name ?? basename(pluginPath); + candidateDirs = [{ name: skillName, path: pluginPath }]; + } else { + candidateDirs = []; + } + } } let filteredDirs: typeof candidateDirs; diff --git a/tests/unit/core/skills.test.ts b/tests/unit/core/skills.test.ts index 60322a09..f1d13790 100644 --- a/tests/unit/core/skills.test.ts +++ b/tests/unit/core/skills.test.ts @@ -78,4 +78,61 @@ describe('getAllSkillsFromPlugins', () => { // skill-b is NOT in the allowlist -> disabled (disabled: true) expect(skillB?.disabled).toBe(true); }); + + it('discovers root-level SKILL.md with frontmatter name', async () => { + const rootSkillPlugin = join(tmpDir, 'root-skill-plugin'); + await mkdir(rootSkillPlugin, { recursive: true }); + await writeFile( + join(rootSkillPlugin, 'SKILL.md'), + '---\nname: my-skill\ndescription: A test skill\n---\n# My Skill', + ); + + const config = { + repositories: [], + plugins: [rootSkillPlugin], + clients: ['claude'], + }; + await writeFile(join(tmpDir, '.allagents/workspace.yaml'), dump(config)); + + const skills = await getAllSkillsFromPlugins(tmpDir); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe('my-skill'); + expect(skills[0]!.pluginName).toBe('root-skill-plugin'); + expect(skills[0]!.path).toBe(rootSkillPlugin); + }); + + it('falls back to directory name when root SKILL.md has no frontmatter name', async () => { + const rootSkillPlugin = join(tmpDir, 'fallback-name-plugin'); + await mkdir(rootSkillPlugin, { recursive: true }); + await writeFile(join(rootSkillPlugin, 'SKILL.md'), '# Just content, no frontmatter'); + + const config = { + repositories: [], + plugins: [rootSkillPlugin], + clients: ['claude'], + }; + await writeFile(join(tmpDir, '.allagents/workspace.yaml'), dump(config)); + + const skills = await getAllSkillsFromPlugins(tmpDir); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe('fallback-name-plugin'); + }); + + it('prefers flat layout over root-level SKILL.md', async () => { + const mixedPlugin = join(tmpDir, 'mixed-plugin'); + await mkdir(join(mixedPlugin, 'sub-skill'), { recursive: true }); + await writeFile(join(mixedPlugin, 'SKILL.md'), '---\nname: root\ndescription: root\n---\n'); + await writeFile(join(mixedPlugin, 'sub-skill/SKILL.md'), '# Sub skill'); + + const config = { + repositories: [], + plugins: [mixedPlugin], + clients: ['claude'], + }; + await writeFile(join(tmpDir, '.allagents/workspace.yaml'), dump(config)); + + const skills = await getAllSkillsFromPlugins(tmpDir); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe('sub-skill'); + }); });