Skip to content

Commit ae49d36

Browse files
aether-agent[bot]CodebuffAIjahoomagreptile-apps[bot]
authored
Add dedicated load-skills test coverage (#502)
Co-authored-by: CodebuffAI <189203002+CodebuffAI@users.noreply.github.com> Co-authored-by: James Grugett <jahooma@gmail.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
1 parent b48b13b commit ae49d36

File tree

1 file changed

+271
-0
lines changed

1 file changed

+271
-0
lines changed
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import { afterEach, beforeEach, 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('loads skills from an explicit skillsPath only', async () => {
102+
const explicitSkillsDir = path.join(tempRoot, 'custom-skills')
103+
104+
writeSkill({
105+
skillsRoot: explicitSkillsDir,
106+
skillDirName: 'custom-skill',
107+
description: 'Loaded from explicit skillsPath',
108+
})
109+
writeSkill({
110+
skillsRoot: path.join(projectDir, '.agents', 'skills'),
111+
skillDirName: 'project-skill',
112+
description: 'Should be ignored when skillsPath is set',
113+
})
114+
115+
const skills = await loadSkills({
116+
cwd: projectDir,
117+
skillsPath: explicitSkillsDir,
118+
})
119+
120+
expect(Object.keys(skills)).toEqual(['custom-skill'])
121+
expect(skills['custom-skill']?.description).toBe(
122+
'Loaded from explicit skillsPath',
123+
)
124+
})
125+
126+
test('applies override precedence as project over global and .agents over .claude', async () => {
127+
writeSkill({
128+
skillsRoot: path.join(homeDir, '.claude', 'skills'),
129+
skillDirName: 'shared-skill',
130+
description: 'global claude',
131+
})
132+
writeSkill({
133+
skillsRoot: path.join(homeDir, '.agents', 'skills'),
134+
skillDirName: 'shared-skill',
135+
description: 'global agents',
136+
})
137+
writeSkill({
138+
skillsRoot: path.join(projectDir, '.claude', 'skills'),
139+
skillDirName: 'shared-skill',
140+
description: 'project claude',
141+
})
142+
writeSkill({
143+
skillsRoot: path.join(projectDir, '.agents', 'skills'),
144+
skillDirName: 'shared-skill',
145+
description: 'project agents',
146+
})
147+
148+
const skills = await loadSkills({ cwd: projectDir })
149+
150+
expect(skills['shared-skill']?.description).toBe('project agents')
151+
expect(skills['shared-skill']?.filePath).toBe(
152+
path.join(projectDir, '.agents', 'skills', 'shared-skill', 'SKILL.md'),
153+
)
154+
})
155+
156+
test('prefers project .claude skills over global .agents skills', async () => {
157+
writeSkill({
158+
skillsRoot: path.join(homeDir, '.agents', 'skills'),
159+
skillDirName: 'priority-skill',
160+
description: 'global agents',
161+
})
162+
writeSkill({
163+
skillsRoot: path.join(projectDir, '.claude', 'skills'),
164+
skillDirName: 'priority-skill',
165+
description: 'project claude',
166+
})
167+
168+
const skills = await loadSkills({ cwd: projectDir })
169+
170+
expect(skills['priority-skill']?.description).toBe('project claude')
171+
})
172+
173+
test('skips invalid skill directories and malformed skill definitions', async () => {
174+
const skillsRoot = path.join(projectDir, '.agents', 'skills')
175+
const consoleError = spyOn(console, 'error').mockImplementation(() => { })
176+
const consoleWarn = spyOn(console, 'warn').mockImplementation(() => { })
177+
178+
mkdirSync(path.join(skillsRoot, 'missing-skill-file'), { recursive: true })
179+
180+
const malformedDir = path.join(skillsRoot, 'malformed-frontmatter')
181+
mkdirSync(malformedDir, { recursive: true })
182+
writeFileSync(
183+
path.join(malformedDir, 'SKILL.md'),
184+
['---', '{invalid yaml: [unclosed', '---'].join('\n'),
185+
'utf8',
186+
)
187+
188+
writeSkill({
189+
skillsRoot,
190+
skillDirName: 'mismatch-dir',
191+
frontmatterName: 'different-name',
192+
description: 'Mismatched name',
193+
})
194+
195+
const tooLongName = 'a'.repeat(SKILL_NAME_MAX_LENGTH + 1)
196+
writeSkill({
197+
skillsRoot,
198+
skillDirName: tooLongName,
199+
description: 'Too long',
200+
})
201+
202+
writeSkill({
203+
skillsRoot,
204+
skillDirName: 'Uppercase-Skill',
205+
description: 'Uppercase invalid',
206+
})
207+
writeSkill({
208+
skillsRoot,
209+
skillDirName: 'special_skill',
210+
description: 'Special char invalid',
211+
})
212+
writeSkill({
213+
skillsRoot,
214+
skillDirName: 'valid-skill',
215+
description: 'Valid skill',
216+
})
217+
218+
const skills = await loadSkills({ cwd: projectDir, verbose: true })
219+
220+
expect(Object.keys(skills)).toEqual(['valid-skill'])
221+
expect(skills['valid-skill']?.description).toBe('Valid skill')
222+
223+
expect(consoleError).toHaveBeenCalledWith(
224+
expect.stringContaining('Invalid frontmatter in skill file'),
225+
)
226+
expect(consoleError).toHaveBeenCalledWith(
227+
expect.stringContaining(
228+
"Skill name 'different-name' does not match directory name 'mismatch-dir'",
229+
),
230+
)
231+
expect(consoleWarn).toHaveBeenCalledWith(
232+
`Skipping invalid skill directory name: ${tooLongName}`,
233+
)
234+
expect(consoleWarn).toHaveBeenCalledWith(
235+
'Skipping invalid skill directory name: Uppercase-Skill',
236+
)
237+
expect(consoleWarn).toHaveBeenCalledWith(
238+
'Skipping invalid skill directory name: special_skill',
239+
)
240+
})
241+
242+
test('loads skills from skillsPath and bypasses default search roots', async () => {
243+
const customSkillsDir = path.join(tempRoot, 'custom-skills')
244+
mkdirSync(customSkillsDir, { recursive: true })
245+
246+
// Put a skill in a default root that should NOT be found
247+
writeSkill({
248+
skillsRoot: path.join(projectDir, '.agents', 'skills'),
249+
skillDirName: 'default-skill',
250+
description: 'Should not be found',
251+
})
252+
253+
// Put a skill in the custom directory that SHOULD be found
254+
writeSkill({
255+
skillsRoot: customSkillsDir,
256+
skillDirName: 'custom-skill',
257+
description: 'Found via skillsPath',
258+
})
259+
260+
const skills = await loadSkills({
261+
cwd: projectDir,
262+
skillsPath: customSkillsDir,
263+
})
264+
265+
expect(Object.keys(skills).sort()).toEqual(['custom-skill'])
266+
expect(skills['custom-skill']?.description).toBe('Found via skillsPath')
267+
expect(skills['custom-skill']?.filePath).toBe(
268+
path.join(customSkillsDir, 'custom-skill', 'SKILL.md'),
269+
)
270+
})
271+
})

0 commit comments

Comments
 (0)