From f022252264e6abb4d69fd539b127f949f7773f20 Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Mon, 2 Feb 2026 14:30:12 +0800 Subject: [PATCH] feat(core): add symlink support for skill manager Add support for loading skills from symlinked directories in the skill manager. This allows users to organize and share skills more flexibly by using symbolic links. Changes: - Modified skill discovery logic to detect and follow symlinks - Added validation to ensure symlink targets point to valid directories - Skip broken or invalid symlinks with appropriate warnings - Added comprehensive test coverage for symlink scenarios --- .../core/src/skills/skill-manager.test.ts | 189 +++++++++++++++++- packages/core/src/skills/skill-manager.ts | 26 ++- 2 files changed, 205 insertions(+), 10 deletions(-) diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts index 3e5125a4d18..d2191614358 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -61,6 +61,18 @@ describe('SkillManager', () => { if (yamlString.includes('name: skill3')) { return { name: 'skill3', description: 'Third skill' }; } + if (yamlString.includes('name: symlink-skill')) { + return { + name: 'symlink-skill', + description: 'A skill loaded from symlink', + }; + } + if (yamlString.includes('A symlinked skill')) { + return { name: 'symlink-skill', description: 'A symlinked skill' }; + } + if (yamlString.includes('name: regular-skill')) { + return { name: 'regular-skill', description: 'A regular skill' }; + } if (!yamlString.includes('name:')) { return { description: 'A test skill' }; // Missing name case } @@ -303,7 +315,12 @@ You are a helpful assistant. describe('loadSkill', () => { it('should load skill from project level first', async () => { vi.mocked(fs.readdir).mockResolvedValue([ - { name: 'test-skill', isDirectory: () => true, isFile: () => false }, + { + name: 'test-skill', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, ] as unknown as Awaited>); vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readFile).mockResolvedValue(validMarkdown); @@ -318,7 +335,12 @@ You are a helpful assistant. vi.mocked(fs.readdir) .mockRejectedValueOnce(new Error('Project dir not found')) // project level fails .mockResolvedValueOnce([ - { name: 'test-skill', isDirectory: () => true, isFile: () => false }, + { + name: 'test-skill', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, ] as unknown as Awaited>); // user level succeeds vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readFile).mockResolvedValue(validMarkdown); @@ -341,7 +363,12 @@ You are a helpful assistant. describe('loadSkillForRuntime', () => { it('should load skill for runtime', async () => { vi.mocked(fs.readdir).mockResolvedValueOnce([ - { name: 'test-skill', isDirectory: () => true, isFile: () => false }, + { + name: 'test-skill', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, ] as unknown as Awaited>); vi.mocked(fs.access).mockResolvedValue(undefined); @@ -367,17 +394,38 @@ You are a helpful assistant. // Mock directory listing for skills directories (with Dirent objects) vi.mocked(fs.readdir) .mockResolvedValueOnce([ - { name: 'skill1', isDirectory: () => true, isFile: () => false }, - { name: 'skill2', isDirectory: () => true, isFile: () => false }, + { + name: 'skill1', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, + { + name: 'skill2', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, { name: 'not-a-dir.txt', isDirectory: () => false, isFile: () => true, + isSymbolicLink: () => false, }, ] as unknown as Awaited>) .mockResolvedValueOnce([ - { name: 'skill3', isDirectory: () => true, isFile: () => false }, - { name: 'skill1', isDirectory: () => true, isFile: () => false }, + { + name: 'skill3', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, + { + name: 'skill1', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, ] as unknown as Awaited>); vi.mocked(fs.access).mockResolvedValue(undefined); @@ -503,7 +551,12 @@ Skill 3 content`); describe('parse errors', () => { it('should track parse errors', async () => { vi.mocked(fs.readdir).mockResolvedValue([ - { name: 'bad-skill', isDirectory: () => true, isFile: () => false }, + { + name: 'bad-skill', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, ] as unknown as Awaited>); vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readFile).mockResolvedValue( @@ -516,4 +569,124 @@ Skill 3 content`); expect(errors.size).toBeGreaterThan(0); }); }); + + describe('symlink support', () => { + it('should load skills from symlinked directories', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { + name: 'symlink-skill', + isDirectory: () => false, + isSymbolicLink: () => true, + isFile: () => false, + }, + ] as unknown as Awaited>); + + // Mock fs.stat to return directory stats for the symlink target + vi.mocked(fs.stat).mockResolvedValue({ + isDirectory: () => true, + } as Awaited>); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(`--- +name: symlink-skill +description: A skill loaded from symlink +--- +Symlink skill content`); + + const skills = await manager.listSkills({ force: true }); + + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe('symlink-skill'); + expect(skills[0].description).toBe('A skill loaded from symlink'); + }); + + it('should skip symlinks that point to non-directory targets', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { + name: 'bad-symlink', + isDirectory: () => false, + isSymbolicLink: () => true, + isFile: () => false, + }, + ] as unknown as Awaited>); + + // Mock fs.stat to return file stats (not a directory) + vi.mocked(fs.stat).mockResolvedValue({ + isDirectory: () => false, + } as Awaited>); + + const skills = await manager.listSkills({ force: true }); + + expect(skills).toHaveLength(0); + }); + + it('should skip broken/invalid symlinks', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { + name: 'broken-symlink', + isDirectory: () => false, + isSymbolicLink: () => true, + isFile: () => false, + }, + ] as unknown as Awaited>); + + // Mock fs.stat to throw error (symlink target doesn't exist) + vi.mocked(fs.stat).mockRejectedValue( + new Error('ENOENT: no such file or directory'), + ); + + const skills = await manager.listSkills({ force: true }); + + expect(skills).toHaveLength(0); + }); + + it('should load skills from both regular directories and symlinks', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { + name: 'regular-skill', + isDirectory: () => true, + isSymbolicLink: () => false, + isFile: () => false, + }, + { + name: 'symlink-skill', + isDirectory: () => false, + isSymbolicLink: () => true, + isFile: () => false, + }, + ] as unknown as Awaited>); + + // Mock fs.stat to return directory stats for the symlink + vi.mocked(fs.stat).mockResolvedValue({ + isDirectory: () => true, + } as Awaited>); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockImplementation((filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('regular-skill')) { + return Promise.resolve(`--- +name: regular-skill +description: A regular skill +--- +Regular skill content`); + } else if (pathStr.includes('symlink-skill')) { + return Promise.resolve(`--- +name: symlink-skill +description: A symlinked skill +--- +Symlinked skill content`); + } + return Promise.reject(new Error('File not found')); + }); + + const skills = await manager.listSkills({ force: true }); + + expect(skills).toHaveLength(2); + expect(skills.map((s) => s.name).sort()).toEqual([ + 'regular-skill', + 'symlink-skill', + ]); + }); + }); }); diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index a5851fb51c1..bdfe0337d00 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -407,10 +407,32 @@ export class SkillManager { const entries = await fs.readdir(baseDir, { withFileTypes: true }); const skills: SkillConfig[] = []; for (const entry of entries) { - // Only process directories (each skill is a directory) - if (!entry.isDirectory()) continue; + // Check if it's a directory or a symlink + const isDirectory = entry.isDirectory(); + const isSymlink = entry.isSymbolicLink(); + + if (!isDirectory && !isSymlink) continue; const skillDir = path.join(baseDir, entry.name); + + // For symlinks, verify the target is a directory + if (isSymlink) { + try { + const targetStat = await fs.stat(skillDir); + if (!targetStat.isDirectory()) { + console.warn( + `Skipping symlink ${entry.name} that does not point to a directory`, + ); + continue; + } + } catch (error) { + console.warn( + `Skipping invalid symlink ${entry.name}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + continue; + } + } + const skillManifest = path.join(skillDir, SKILL_MANIFEST_FILE); try {