diff --git a/cli/src/modules/common/skills.test.ts b/cli/src/modules/common/skills.test.ts index 2efecd06..81a103de 100644 --- a/cli/src/modules/common/skills.test.ts +++ b/cli/src/modules/common/skills.test.ts @@ -66,6 +66,21 @@ describe('listSkills', () => { expect(skills.map((skill) => skill.name)).toEqual(['amis']) }) + it('lists symlinked skill directories and ignores invalid symlink targets', async () => { + const skillsRoot = join(homeDir, '.agents', 'skills') + const source = join(sandboxDir, 'shared-skills', 'linked') + await writeSkill(source, 'linked', 'Linked skill') + await mkdir(skillsRoot, { recursive: true }) + await symlink(source, join(skillsRoot, 'linked'), 'dir') + await symlink(join(sandboxDir, 'missing'), join(skillsRoot, 'broken'), 'dir') + await writeFile(join(sandboxDir, 'not-a-directory'), 'not a skill') + await symlink(join(sandboxDir, 'not-a-directory'), join(skillsRoot, 'file-link')) + + const skills = await listSkills() + + expect(skills).toEqual([{ name: 'linked', description: 'Linked skill' }]) + }) + it('lists user skills from ~/.claude/skills', async () => { await writeSkill(join(homeDir, '.claude', 'skills', 'claude-skill'), 'claude-skill', 'Claude skill') diff --git a/cli/src/modules/common/skills.ts b/cli/src/modules/common/skills.ts index 954e3c0b..9c421672 100644 --- a/cli/src/modules/common/skills.ts +++ b/cli/src/modules/common/skills.ts @@ -1,4 +1,4 @@ -import { access, open, readdir, readFile } from 'fs/promises'; +import { access, open, readdir, readFile, stat } from 'fs/promises'; import { basename, dirname, join, resolve } from 'path'; import { homedir } from 'os'; import { parse as parseYaml } from 'yaml'; @@ -106,6 +106,10 @@ async function pathExists(path: string): Promise { } } +async function isDirectory(path: string): Promise { + return await stat(path).then((info) => info.isDirectory()).catch(() => false); +} + async function listProjectSkillsRoots(workingDirectory?: string, flavor?: string): Promise { if (!workingDirectory) { return []; @@ -163,27 +167,29 @@ function extractSkillSummary(skillDir: string, fileContent: string): SkillSummar async function listTopLevelSkillDirs(skillsRoot: string, options: { includeCodexSystem?: boolean } = {}): Promise { try { - const entries = await readdir(skillsRoot, { withFileTypes: true }); + const entries = await readdir(skillsRoot); const result: string[] = []; for (const entry of entries) { - if (!entry.isDirectory()) { + const entryPath = join(skillsRoot, entry); + if (!await isDirectory(entryPath)) { continue; } - if (entry.name.startsWith('.')) { - if (options.includeCodexSystem && entry.name === '.system') { - const systemEntries = await readdir(join(skillsRoot, entry.name), { withFileTypes: true }).catch(() => []); + if (entry.startsWith('.')) { + if (options.includeCodexSystem && entry === '.system') { + const systemEntries = await readdir(entryPath).catch(() => []); for (const systemEntry of systemEntries) { - if (systemEntry.isDirectory() && !systemEntry.name.startsWith('.')) { - result.push(join(skillsRoot, entry.name, systemEntry.name)); + const systemEntryPath = join(entryPath, systemEntry); + if (!systemEntry.startsWith('.') && await isDirectory(systemEntryPath)) { + result.push(systemEntryPath); } } } continue; } - result.push(join(skillsRoot, entry.name)); + result.push(entryPath); } return result;