Skip to content

Commit 8561fa6

Browse files
CodebuffAICodebuffAI
authored andcommitted
Add load skills test suite
1 parent bd97765 commit 8561fa6

File tree

1 file changed

+216
-0
lines changed

1 file changed

+216
-0
lines changed
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { beforeEach, afterEach, describe, expect, mock, spyOn, test } from 'bun:test'
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
3+
import os from 'os'
4+
import path from 'path'
5+
6+
import {
7+
SKILL_FILE_NAME,
8+
SKILL_NAME_MAX_LENGTH,
9+
} from '@codebuff/common/constants/skills'
10+
11+
import { loadSkills } from '../skills/load-skills'
12+
13+
const writeSkill = ({
14+
skillsRoot,
15+
skillDirName,
16+
frontmatterName = skillDirName,
17+
description = `Description for ${skillDirName}`,
18+
body = `# ${skillDirName}\n`,
19+
}: {
20+
skillsRoot: string
21+
skillDirName: string
22+
frontmatterName?: string
23+
description?: string
24+
body?: string
25+
}): string => {
26+
const skillDir = path.join(skillsRoot, skillDirName)
27+
const skillFile = path.join(skillDir, SKILL_FILE_NAME)
28+
29+
mkdirSync(skillDir, { recursive: true })
30+
writeFileSync(
31+
skillFile,
32+
[
33+
'---',
34+
`name: ${frontmatterName}`,
35+
`description: ${description}`,
36+
'---',
37+
'',
38+
body,
39+
].join('\n'),
40+
'utf8',
41+
)
42+
43+
return skillFile
44+
}
45+
46+
describe('loadSkills', () => {
47+
let tempRoot: string
48+
let homeDir: string
49+
let projectDir: string
50+
51+
beforeEach(() => {
52+
tempRoot = mkdtempSync(path.join(os.tmpdir(), 'codebuff-sdk-load-skills-'))
53+
homeDir = path.join(tempRoot, 'home')
54+
projectDir = path.join(tempRoot, 'project')
55+
56+
mkdirSync(homeDir, { recursive: true })
57+
mkdirSync(projectDir, { recursive: true })
58+
59+
spyOn(os, 'homedir').mockReturnValue(homeDir)
60+
})
61+
62+
afterEach(() => {
63+
mock.restore()
64+
rmSync(tempRoot, { recursive: true, force: true })
65+
})
66+
67+
test('discovers valid skills from all default search roots', async () => {
68+
writeSkill({
69+
skillsRoot: path.join(homeDir, '.claude', 'skills'),
70+
skillDirName: 'global-claude-skill',
71+
})
72+
writeSkill({
73+
skillsRoot: path.join(homeDir, '.agents', 'skills'),
74+
skillDirName: 'global-agents-skill',
75+
})
76+
writeSkill({
77+
skillsRoot: path.join(projectDir, '.claude', 'skills'),
78+
skillDirName: 'project-claude-skill',
79+
})
80+
writeSkill({
81+
skillsRoot: path.join(projectDir, '.agents', 'skills'),
82+
skillDirName: 'project-agents-skill',
83+
})
84+
85+
const skills = await loadSkills({ cwd: projectDir })
86+
87+
expect(Object.keys(skills).sort()).toEqual([
88+
'global-agents-skill',
89+
'global-claude-skill',
90+
'project-agents-skill',
91+
'project-claude-skill',
92+
])
93+
expect(skills['global-claude-skill']?.filePath).toBe(
94+
path.join(homeDir, '.claude', 'skills', 'global-claude-skill', 'SKILL.md'),
95+
)
96+
expect(skills['project-agents-skill']?.description).toBe(
97+
'Description for project-agents-skill',
98+
)
99+
})
100+
101+
test('applies override precedence as project over global and .agents over .claude', async () => {
102+
writeSkill({
103+
skillsRoot: path.join(homeDir, '.claude', 'skills'),
104+
skillDirName: 'shared-skill',
105+
description: 'global claude',
106+
})
107+
writeSkill({
108+
skillsRoot: path.join(homeDir, '.agents', 'skills'),
109+
skillDirName: 'shared-skill',
110+
description: 'global agents',
111+
})
112+
writeSkill({
113+
skillsRoot: path.join(projectDir, '.claude', 'skills'),
114+
skillDirName: 'shared-skill',
115+
description: 'project claude',
116+
})
117+
writeSkill({
118+
skillsRoot: path.join(projectDir, '.agents', 'skills'),
119+
skillDirName: 'shared-skill',
120+
description: 'project agents',
121+
})
122+
123+
const skills = await loadSkills({ cwd: projectDir })
124+
125+
expect(skills['shared-skill']?.description).toBe('project agents')
126+
expect(skills['shared-skill']?.filePath).toBe(
127+
path.join(projectDir, '.agents', 'skills', 'shared-skill', 'SKILL.md'),
128+
)
129+
})
130+
131+
test('prefers project .claude skills over global .agents skills', async () => {
132+
writeSkill({
133+
skillsRoot: path.join(homeDir, '.agents', 'skills'),
134+
skillDirName: 'priority-skill',
135+
description: 'global agents',
136+
})
137+
writeSkill({
138+
skillsRoot: path.join(projectDir, '.claude', 'skills'),
139+
skillDirName: 'priority-skill',
140+
description: 'project claude',
141+
})
142+
143+
const skills = await loadSkills({ cwd: projectDir })
144+
145+
expect(skills['priority-skill']?.description).toBe('project claude')
146+
})
147+
148+
test('skips invalid skill directories and malformed skill definitions', async () => {
149+
const skillsRoot = path.join(projectDir, '.agents', 'skills')
150+
const consoleError = spyOn(console, 'error').mockImplementation(() => {})
151+
const consoleWarn = spyOn(console, 'warn').mockImplementation(() => {})
152+
153+
mkdirSync(path.join(skillsRoot, 'missing-skill-file'), { recursive: true })
154+
155+
const malformedDir = path.join(skillsRoot, 'malformed-frontmatter')
156+
mkdirSync(malformedDir, { recursive: true })
157+
writeFileSync(
158+
path.join(malformedDir, 'SKILL.md'),
159+
['---', 'name malformed-frontmatter', 'description: missing colon', '---'].join(
160+
'\n',
161+
),
162+
'utf8',
163+
)
164+
165+
writeSkill({
166+
skillsRoot,
167+
skillDirName: 'mismatch-dir',
168+
frontmatterName: 'different-name',
169+
description: 'Mismatched name',
170+
})
171+
172+
const tooLongName = 'a'.repeat(SKILL_NAME_MAX_LENGTH + 1)
173+
writeSkill({
174+
skillsRoot,
175+
skillDirName: tooLongName,
176+
description: 'Too long',
177+
})
178+
179+
writeSkill({
180+
skillsRoot,
181+
skillDirName: 'Uppercase-Skill',
182+
description: 'Uppercase invalid',
183+
})
184+
writeSkill({
185+
skillsRoot,
186+
skillDirName: 'special_skill',
187+
description: 'Special char invalid',
188+
})
189+
writeSkill({
190+
skillsRoot,
191+
skillDirName: 'valid-skill',
192+
description: 'Valid skill',
193+
})
194+
195+
const skills = await loadSkills({ cwd: projectDir, verbose: true })
196+
197+
expect(Object.keys(skills)).toEqual(['valid-skill'])
198+
expect(skills['valid-skill']?.description).toBe('Valid skill')
199+
200+
expect(consoleError).toHaveBeenCalledWith(
201+
expect.stringContaining('Invalid frontmatter in skill file'),
202+
)
203+
expect(consoleError).toHaveBeenCalledWith(
204+
expect.stringContaining("Skill name 'different-name' does not match directory name 'mismatch-dir'"),
205+
)
206+
expect(consoleWarn).toHaveBeenCalledWith(
207+
`Skipping invalid skill directory name: ${tooLongName}`,
208+
)
209+
expect(consoleWarn).toHaveBeenCalledWith(
210+
'Skipping invalid skill directory name: Uppercase-Skill',
211+
)
212+
expect(consoleWarn).toHaveBeenCalledWith(
213+
'Skipping invalid skill directory name: special_skill',
214+
)
215+
})
216+
})

0 commit comments

Comments
 (0)