Skip to content

Commit ad56d3c

Browse files
committed
refactor(plugin-agentskills-compact): 重构技能输出插件为直接写入项目目录
将全局技能目录结构改为直接写入项目目录下的 .agents/skills/ 路径 移除全局技能目录和符号链接逻辑,改为直接写入文件 同时保留对旧版 .skills/ 目录的清理支持
1 parent 79d06e9 commit ad56d3c

2 files changed

Lines changed: 172 additions & 231 deletions

File tree

cli/src/plugins/plugin-agentskills-compact/GenericSkillsOutputPlugin.test.ts

Lines changed: 89 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ describe('genericSkillsOutputPlugin', () => {
192192
})
193193

194194
describe('registerProjectOutputDirs', () => {
195-
it('should register .skills directory for each project', async () => {
195+
it('should register both .agents/skills and legacy .skills directories for each project', async () => {
196196
const ctx = createMockOutputPluginContext({
197197
workspace: {
198198
directory: createMockRelativePath('.', mockWorkspaceDir),
@@ -206,9 +206,11 @@ describe('genericSkillsOutputPlugin', () => {
206206

207207
const results = await plugin.registerProjectOutputDirs(ctx)
208208

209-
expect(results).toHaveLength(2)
210-
expect(results[0]?.path).toBe(path.join('project1', '.skills'))
211-
expect(results[1]?.path).toBe(path.join('project2', '.skills'))
209+
expect(results).toHaveLength(4) // Each project should register 2 directories: .agents/skills and .skills
210+
expect(results[0]?.path).toBe(path.join('project1', '.agents', 'skills'))
211+
expect(results[1]?.path).toBe(path.join('project1', '.skills'))
212+
expect(results[2]?.path).toBe(path.join('project2', '.agents', 'skills'))
213+
expect(results[3]?.path).toBe(path.join('project2', '.skills'))
212214
})
213215

214216
it('should return empty array when no skills exist', async () => {
@@ -225,7 +227,7 @@ describe('genericSkillsOutputPlugin', () => {
225227
})
226228

227229
describe('registerGlobalOutputDirs', () => {
228-
it('should register ~/.skills/ directory when skills exist', async () => {
230+
it('should return empty array (no global output dirs)', async () => {
229231
const ctx = createMockOutputPluginContext({
230232
workspace: {
231233
directory: createMockRelativePath('.', mockWorkspaceDir),
@@ -236,28 +238,28 @@ describe('genericSkillsOutputPlugin', () => {
236238

237239
const results = await plugin.registerGlobalOutputDirs(ctx)
238240

239-
expect(results).toHaveLength(1)
240-
const pathValue = results[0]?.path.replaceAll('\\', '/')
241-
const expected = path.join('.aindex', '.skills').replaceAll('\\', '/')
242-
expect(pathValue).toBe(expected)
243-
expect(results[0]?.basePath).toBe(mockHomeDir)
241+
expect(results).toHaveLength(0)
244242
})
243+
})
245244

246-
it('should return empty array when no skills exist', async () => {
245+
describe('registerGlobalOutputFiles', () => {
246+
it('should return empty array (no global output files)', async () => {
247247
const ctx = createMockOutputPluginContext({
248248
workspace: {
249249
directory: createMockRelativePath('.', mockWorkspaceDir),
250250
projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}]
251-
}
251+
},
252+
skills: [createMockSkillPrompt('test-skill', 'content')]
252253
})
253254

254-
const results = await plugin.registerGlobalOutputDirs(ctx)
255+
const results = await plugin.registerGlobalOutputFiles(ctx)
256+
255257
expect(results).toHaveLength(0)
256258
})
257259
})
258260

259-
describe('registerGlobalOutputFiles', () => {
260-
it('should register SKILL.md in ~/.skills/ for each skill', async () => {
261+
describe('registerProjectOutputFiles', () => {
262+
it('should register skill files for each skill in each project', async () => {
261263
const ctx = createMockOutputPluginContext({
262264
workspace: {
263265
directory: createMockRelativePath('.', mockWorkspaceDir),
@@ -269,12 +271,11 @@ describe('genericSkillsOutputPlugin', () => {
269271
]
270272
})
271273

272-
const results = await plugin.registerGlobalOutputFiles(ctx)
274+
const results = await plugin.registerProjectOutputFiles(ctx)
273275

274-
expect(results).toHaveLength(2)
275-
expect(results[0]?.path).toBe(path.join('.aindex', '.skills', 'skill-a', 'SKILL.md'))
276-
expect(results[1]?.path).toBe(path.join('.aindex', '.skills', 'skill-b', 'SKILL.md'))
277-
expect(results[0]?.basePath).toBe(mockHomeDir)
276+
expect(results).toHaveLength(2) // 2 skills * 1 file each = 2 files
277+
expect(results[0]?.path).toBe(path.join('.agents', 'skills', 'skill-a', 'SKILL.md'))
278+
expect(results[1]?.path).toBe(path.join('.agents', 'skills', 'skill-b', 'SKILL.md'))
278279
})
279280

280281
it('should register mcp.json when skill has MCP config', async () => {
@@ -286,66 +287,52 @@ describe('genericSkillsOutputPlugin', () => {
286287
skills: [createMockSkillPrompt('test-skill', 'content', {mcpConfig: {rawContent: '{}'}})]
287288
})
288289

289-
const results = await plugin.registerGlobalOutputFiles(ctx)
290+
const results = await plugin.registerProjectOutputFiles(ctx)
290291

291292
expect(results).toHaveLength(2)
292-
expect(results[0]?.path).toBe(path.join('.aindex', '.skills', 'test-skill', 'SKILL.md'))
293-
expect(results[1]?.path).toBe(path.join('.aindex', '.skills', 'test-skill', 'mcp.json'))
293+
expect(results[0]?.path).toBe(path.join('.agents', 'skills', 'test-skill', 'SKILL.md'))
294+
expect(results[1]?.path).toBe(path.join('.agents', 'skills', 'test-skill', 'mcp.json'))
294295
})
295-
})
296296

297-
describe('writeGlobalOutputs', () => {
298-
it('should write SKILL.md to ~/.skills/ with front matter', async () => {
299-
const ctx = createMockOutputWriteContext({
297+
it('should register child docs', async () => {
298+
const ctx = createMockOutputPluginContext({
300299
workspace: {
301300
directory: createMockRelativePath('.', mockWorkspaceDir),
302301
projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}]
303302
},
304-
skills: [createMockSkillPrompt('test-skill', '# Skill Content', {
305-
description: 'A test skill',
306-
keywords: ['test', 'demo']
303+
skills: [createMockSkillPrompt('test-skill', 'content', {
304+
childDocs: [{relativePath: 'doc1.mdx', content: 'doc content'}]
307305
})]
308306
})
309307

310-
const results = await plugin.writeGlobalOutputs(ctx)
311-
312-
expect(results.files).toHaveLength(1)
313-
expect(results.files[0]?.success).toBe(true)
314-
315-
const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]
316-
expect(writeCall).toBeDefined()
317-
expect(writeCall?.[0]).toContain(path.join(mockHomeDir, '.aindex', '.skills', 'test-skill', 'SKILL.md'))
308+
const results = await plugin.registerProjectOutputFiles(ctx)
318309

319-
const writtenContent = writeCall?.[1] as string
320-
expect(writtenContent).toContain('name: test-skill')
321-
expect(writtenContent).toContain('description: A test skill')
322-
expect(writtenContent).toContain('# Skill Content')
310+
expect(results).toHaveLength(2)
311+
expect(results[0]?.path).toBe(path.join('.agents', 'skills', 'test-skill', 'SKILL.md'))
312+
expect(results[1]?.path).toBe(path.join('.agents', 'skills', 'test-skill', 'doc1.md'))
323313
})
324314

325-
it('should support dry-run mode', async () => {
326-
const ctx = createMockOutputWriteContext(
327-
{
328-
workspace: {
329-
directory: createMockRelativePath('.', mockWorkspaceDir),
330-
projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}]
331-
},
332-
skills: [createMockSkillPrompt('test-skill', 'content')]
315+
it('should register resources', async () => {
316+
const ctx = createMockOutputPluginContext({
317+
workspace: {
318+
directory: createMockRelativePath('.', mockWorkspaceDir),
319+
projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}]
333320
},
334-
true
335-
)
321+
skills: [createMockSkillPrompt('test-skill', 'content', {
322+
resources: [{relativePath: 'resource.json', content: '{}', encoding: 'text'}]
323+
})]
324+
})
336325

337-
const results = await plugin.writeGlobalOutputs(ctx)
326+
const results = await plugin.registerProjectOutputFiles(ctx)
338327

339-
expect(results.files).toHaveLength(1)
340-
expect(results.files[0]?.success).toBe(true)
341-
expect(results.files[0]?.skipped).toBe(false)
342-
expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled()
328+
expect(results).toHaveLength(2)
329+
expect(results[0]?.path).toBe(path.join('.agents', 'skills', 'test-skill', 'SKILL.md'))
330+
expect(results[1]?.path).toBe(path.join('.agents', 'skills', 'test-skill', 'resource.json'))
343331
})
344332
})
345333

346334
describe('writeProjectOutputs', () => {
347-
it('should create symlinks for each skill in each project', async () => {
348-
vi.mocked(fs.existsSync).mockReturnValue(false) // Symlink doesn't exist yet
335+
it('should write skill files directly to project directory', async () => {
349336
const ctx = createMockOutputWriteContext({
350337
workspace: {
351338
directory: createMockRelativePath('.', mockWorkspaceDir),
@@ -359,22 +346,19 @@ describe('genericSkillsOutputPlugin', () => {
359346

360347
const results = await plugin.writeProjectOutputs(ctx)
361348

362-
expect(results.files).toHaveLength(2)
363-
expect(vi.mocked(fs.symlinkSync)).toHaveBeenCalledTimes(2)
349+
expect(results.files).toHaveLength(2) // 2 projects * 1 skill = 2 files
350+
expect(results.files[0]?.success).toBe(true)
351+
expect(results.files[1]?.success).toBe(true)
364352

365-
expect(vi.mocked(fs.symlinkSync)).toHaveBeenCalledWith( // Verify symlinks point from project to global
366-
expect.stringContaining(path.join(mockHomeDir, '.aindex', '.skills', 'test-skill')),
367-
expect.stringContaining(path.join('project1', '.skills', 'test-skill')),
368-
expect.anything()
369-
)
370-
expect(vi.mocked(fs.symlinkSync)).toHaveBeenCalledWith(
371-
expect.stringContaining(path.join(mockHomeDir, '.aindex', '.skills', 'test-skill')),
372-
expect.stringContaining(path.join('project2', '.skills', 'test-skill')),
373-
expect.anything()
374-
)
353+
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalled() // Verify files are written (not symlinks created)
354+
expect(vi.mocked(fs.symlinkSync)).not.toHaveBeenCalled()
355+
356+
const writeCalls = vi.mocked(fs.writeFileSync).mock.calls // Verify correct paths
357+
expect(writeCalls[0]?.[0]).toContain(path.join('project1', '.agents', 'skills', 'test-skill', 'SKILL.md'))
358+
expect(writeCalls[1]?.[0]).toContain(path.join('project2', '.agents', 'skills', 'test-skill', 'SKILL.md'))
375359
})
376360

377-
it('should support dry-run mode for symlinks', async () => {
361+
it('should support dry-run mode', async () => {
378362
const ctx = createMockOutputWriteContext(
379363
{
380364
workspace: {
@@ -390,8 +374,7 @@ describe('genericSkillsOutputPlugin', () => {
390374

391375
expect(results.files).toHaveLength(1)
392376
expect(results.files[0]?.success).toBe(true)
393-
expect(results.files[0]?.skipped).toBe(false)
394-
expect(vi.mocked(fs.symlinkSync)).not.toHaveBeenCalled()
377+
expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled()
395378
})
396379

397380
it('should skip project without dirFromWorkspacePath', async () => {
@@ -406,7 +389,6 @@ describe('genericSkillsOutputPlugin', () => {
406389
const results = await plugin.writeProjectOutputs(ctx)
407390

408391
expect(results.files).toHaveLength(0)
409-
expect(vi.mocked(fs.symlinkSync)).not.toHaveBeenCalled()
410392
})
411393

412394
it('should return empty results when no skills exist', async () => {
@@ -422,26 +404,47 @@ describe('genericSkillsOutputPlugin', () => {
422404
expect(results.files).toHaveLength(0)
423405
expect(results.dirs).toHaveLength(0)
424406
})
407+
408+
it('should write skill with front matter', async () => {
409+
const ctx = createMockOutputWriteContext({
410+
workspace: {
411+
directory: createMockRelativePath('.', mockWorkspaceDir),
412+
projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}]
413+
},
414+
skills: [createMockSkillPrompt('test-skill', '# Skill Content', {
415+
description: 'A test skill',
416+
keywords: ['test', 'demo']
417+
})]
418+
})
419+
420+
await plugin.writeProjectOutputs(ctx)
421+
422+
const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]
423+
expect(writeCall).toBeDefined()
424+
expect(writeCall?.[0]).toContain(path.join('project1', '.agents', 'skills', 'test-skill', 'SKILL.md'))
425+
426+
const writtenContent = writeCall?.[1] as string
427+
expect(writtenContent).toContain('name: test-skill')
428+
expect(writtenContent).toContain('description: A test skill')
429+
expect(writtenContent).toContain('# Skill Content')
430+
})
425431
})
426432

427-
describe('registerProjectOutputFiles', () => {
428-
it('should register symlink paths for each skill in each project', async () => {
429-
const ctx = createMockOutputPluginContext({
433+
describe('writeGlobalOutputs', () => {
434+
it('should return empty results (no global output)', async () => {
435+
const ctx = createMockOutputWriteContext({
430436
workspace: {
431437
directory: createMockRelativePath('.', mockWorkspaceDir),
432438
projects: [{name: 'project1', dirFromWorkspacePath: createMockRelativePath('project1', mockWorkspaceDir)}]
433439
},
434-
skills: [
435-
createMockSkillPrompt('skill-a', 'content a'),
436-
createMockSkillPrompt('skill-b', 'content b')
437-
]
440+
skills: [createMockSkillPrompt('test-skill', 'content')]
438441
})
439442

440-
const results = await plugin.registerProjectOutputFiles(ctx)
443+
const results = await plugin.writeGlobalOutputs(ctx)
441444

442-
expect(results).toHaveLength(2)
443-
expect(results[0]?.path).toBe(path.join('.skills', 'skill-a'))
444-
expect(results[1]?.path).toBe(path.join('.skills', 'skill-b'))
445+
expect(results.files).toHaveLength(0)
446+
expect(results.dirs).toHaveLength(0)
447+
expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled()
445448
})
446449
})
447450
})

0 commit comments

Comments
 (0)