Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 181 additions & 8 deletions packages/core/src/skills/skill-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockResolvedValue(validMarkdown);
Expand All @@ -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<ReturnType<typeof fs.readdir>>); // user level succeeds
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockResolvedValue(validMarkdown);
Expand All @@ -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<ReturnType<typeof fs.readdir>>);

vi.mocked(fs.access).mockResolvedValue(undefined);
Expand All @@ -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<ReturnType<typeof fs.readdir>>)
.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<ReturnType<typeof fs.readdir>>);

vi.mocked(fs.access).mockResolvedValue(undefined);
Expand Down Expand Up @@ -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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockResolvedValue(
Expand All @@ -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<ReturnType<typeof fs.readdir>>);

// Mock fs.stat to return directory stats for the symlink target
vi.mocked(fs.stat).mockResolvedValue({
isDirectory: () => true,
} as Awaited<ReturnType<typeof fs.stat>>);

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<ReturnType<typeof fs.readdir>>);

// Mock fs.stat to return file stats (not a directory)
vi.mocked(fs.stat).mockResolvedValue({
isDirectory: () => false,
} as Awaited<ReturnType<typeof fs.stat>>);

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<ReturnType<typeof fs.readdir>>);

// 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<ReturnType<typeof fs.readdir>>);

// Mock fs.stat to return directory stats for the symlink
vi.mocked(fs.stat).mockResolvedValue({
isDirectory: () => true,
} as Awaited<ReturnType<typeof fs.stat>>);

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',
]);
});
});
});
26 changes: 24 additions & 2 deletions packages/core/src/skills/skill-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down