fix: support nested skill directories

Load nested SKILL.md files from .claude/skills and namespace them with colons so category-based skill layouts work in Claude Code clients.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Charlike Mike Reagent
2026-04-01 20:20:13 +03:00
parent 5f774cfe5d
commit 63adb95e8d
2 changed files with 166 additions and 17 deletions

View File

@@ -0,0 +1,64 @@
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { getSkillDirCommands, clearSkillCaches } from './loadSkillsDir.ts'
function writeSkill(rootDir: string, skillPath: string): void {
const skillDir = join(rootDir, '.claude', 'skills', ...skillPath.split('/'))
mkdirSync(skillDir, { recursive: true })
writeFileSync(
join(skillDir, 'SKILL.md'),
`---\ndescription: ${skillPath}\n---\n# ${skillPath}\n`,
'utf8',
)
}
test('loads flat and nested skills with colon namespaces', async () => {
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-skills-'))
const cwd = join(configDir, 'workspace')
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
try {
mkdirSync(cwd, { recursive: true })
writeSkill(configDir, 'flat-skill')
writeSkill(configDir, 'git/commit')
writeSkill(configDir, 'frontend/react/form')
process.env.CLAUDE_CONFIG_DIR = configDir
clearSkillCaches()
const skills = await getSkillDirCommands(cwd)
const promptSkills = skills.filter(skill => skill.type === 'prompt')
const skillNames = promptSkills.map(skill => skill.name).sort()
assert.deepEqual(skillNames, [
'flat-skill',
'frontend:react:form',
'git:commit',
])
const nestedSkill = promptSkills.find(skill => skill.name === 'git:commit')
assert.ok(nestedSkill)
assert.equal(nestedSkill.skillRoot, join(configDir, '.claude', 'skills', 'git', 'commit'))
const deepSkill = promptSkills.find(
skill => skill.name === 'frontend:react:form',
)
assert.ok(deepSkill)
assert.equal(
deepSkill.skillRoot,
join(configDir, '.claude', 'skills', 'frontend', 'react', 'form'),
)
} finally {
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
clearSkillCaches()
rmSync(configDir, { recursive: true, force: true })
}
})

View File

@@ -401,14 +401,66 @@ export function createSkillCommand({
} }
/** /**
* Loads skills from a /skills/ directory path. * Recursively finds nested SKILL.md files under a /skills/ directory.
* Only supports directory format: skill-name/SKILL.md * Ignores markdown files placed directly in the root /skills/ directory.
*/ */
async function loadSkillsFromSkillsDir( async function findSkillMarkdownFiles(basePath: string): Promise<string[]> {
basePath: string,
source: SettingSource,
): Promise<SkillWithPath[]> {
const fs = getFsImplementation() const fs = getFsImplementation()
const visitedDirs = new Set<string>()
const skillFiles: string[] = []
async function walk(skillDirPath: string): Promise<void> {
const dirId = await getFileIdentity(skillDirPath)
if (dirId && visitedDirs.has(dirId)) {
return
}
if (dirId) {
visitedDirs.add(dirId)
}
let entries
try {
entries = await fs.readdir(skillDirPath)
} catch (e: unknown) {
if (!isFsInaccessible(e)) {
logForDebugging(`[skills] failed to read directory ${skillDirPath}: ${e}`, {
level: 'warn',
})
}
return
}
const childDirs: string[] = []
for (const entry of entries) {
const entryPath = join(skillDirPath, entry.name)
if (isSkillFile(entryPath)) {
skillFiles.push(entryPath)
continue
}
if (entry.isDirectory()) {
childDirs.push(entryPath)
continue
}
if (entry.isSymbolicLink()) {
try {
if ((await fs.stat(entryPath)).isDirectory()) {
childDirs.push(entryPath)
}
} catch (e: unknown) {
if (!isENOENT(e) && !isFsInaccessible(e)) {
logForDebugging(`[skills] failed to stat ${entryPath}: ${e}`, {
level: 'warn',
})
}
}
}
}
await Promise.all(childDirs.map(walk))
}
let entries let entries
try { try {
@@ -418,17 +470,50 @@ async function loadSkillsFromSkillsDir(
return [] return []
} }
const results = await Promise.all( const topLevelDirs: string[] = []
entries.map(async (entry): Promise<SkillWithPath | null> => { for (const entry of entries) {
try { const entryPath = join(basePath, entry.name)
// Only support directory format: skill-name/SKILL.md
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
// Single .md files are NOT supported in /skills/ directory
return null
}
const skillDirPath = join(basePath, entry.name) if (entry.isDirectory()) {
const skillFilePath = join(skillDirPath, 'SKILL.md') topLevelDirs.push(entryPath)
continue
}
if (entry.isSymbolicLink()) {
try {
if ((await fs.stat(entryPath)).isDirectory()) {
topLevelDirs.push(entryPath)
}
} catch (e: unknown) {
if (!isENOENT(e) && !isFsInaccessible(e)) {
logForDebugging(`[skills] failed to stat ${entryPath}: ${e}`, {
level: 'warn',
})
}
}
}
}
await Promise.all(topLevelDirs.map(walk))
skillFiles.sort()
return skillFiles
}
/**
* Loads skills from a /skills/ directory path.
* Supports nested directory format: category/skill/SKILL.md
*/
async function loadSkillsFromSkillsDir(
basePath: string,
source: SettingSource,
): Promise<SkillWithPath[]> {
const fs = getFsImplementation()
const skillFiles = await findSkillMarkdownFiles(basePath)
const results = await Promise.all(
skillFiles.map(async (skillFilePath): Promise<SkillWithPath | null> => {
try {
const skillDirPath = dirname(skillFilePath)
let content: string let content: string
try { try {
@@ -449,7 +534,7 @@ async function loadSkillsFromSkillsDir(
skillFilePath, skillFilePath,
) )
const skillName = entry.name const skillName = getSkillCommandName(skillFilePath, basePath)
const parsed = parseSkillFrontmatterFields( const parsed = parseSkillFrontmatterFields(
frontmatter, frontmatter,
markdownContent, markdownContent,