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:
64
src/skills/loadSkillsDir.test.ts
Normal file
64
src/skills/loadSkillsDir.test.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user