Skip to content

Commit 6531468

Browse files
wesmclaude
andcommitted
Simplify ListSkills dedup and add regression tests
Replace embeddedSkillCatalog's field-by-field merge with a simpler seen-set dedup in ListSkills, making it explicit that the first agent's metadata wins when frontmatter differs across agents. Add tests covering: deduplication across agents, first-agent-wins metadata selection, and a mock-FS regression test verifying that installation detection (embeddedSkillDirNames/currentInstalledSkillFilePaths) succeeds even when embedded SKILL.md files are absent. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f420f48 commit 6531468

2 files changed

Lines changed: 90 additions & 42 deletions

File tree

internal/skills/skills.go

Lines changed: 12 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -324,57 +324,27 @@ type AgentStatus struct {
324324
Skills map[string]SkillState // keyed by dir name (e.g. "roborev-fix")
325325
}
326326

327-
func embeddedSkillCatalog() (map[string]SkillInfo, error) {
328-
catalog := make(map[string]SkillInfo)
329-
for _, spec := range supportedAgents {
330-
skills, err := embeddedSkillsForAgent(spec)
331-
if err != nil {
332-
return nil, err
333-
}
334-
for _, skill := range skills {
335-
info := SkillInfo{
336-
DirName: skill.DirName,
337-
Name: skill.Name,
338-
Description: skill.Description,
339-
}
340-
341-
existing, ok := catalog[skill.DirName]
342-
if !ok {
343-
catalog[skill.DirName] = info
344-
continue
345-
}
346-
if existing.Name == "" {
347-
existing.Name = info.Name
348-
}
349-
if existing.Description == "" {
350-
existing.Description = info.Description
351-
}
352-
catalog[skill.DirName] = existing
353-
}
354-
}
355-
return catalog, nil
356-
}
357-
358-
// ListSkills returns metadata for all embedded skills.
327+
// ListSkills returns metadata for all embedded skills, deduplicated by
328+
// directory name. When the same skill exists across multiple agents, the
329+
// first agent's metadata is used.
359330
func ListSkills() ([]SkillInfo, error) {
360-
catalog, err := embeddedSkillCatalog()
361-
if err != nil {
362-
return nil, err
363-
}
364-
365-
out := make([]SkillInfo, 0, len(catalog))
331+
seen := make(map[string]bool)
332+
var out []SkillInfo
366333
for _, spec := range supportedAgents {
367334
skills, err := embeddedSkillsForAgent(spec)
368335
if err != nil {
369336
return nil, err
370337
}
371338
for _, skill := range skills {
372-
info, ok := catalog[skill.DirName]
373-
if !ok {
339+
if seen[skill.DirName] {
374340
continue
375341
}
376-
out = append(out, info)
377-
delete(catalog, skill.DirName)
342+
seen[skill.DirName] = true
343+
out = append(out, SkillInfo{
344+
DirName: skill.DirName,
345+
Name: skill.Name,
346+
Description: skill.Description,
347+
})
378348
}
379349
}
380350
return out, nil

internal/skills/skills_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package skills
33
import (
44
"os"
55
"path/filepath"
6+
"strings"
67
"testing"
8+
"testing/fstest"
79

810
"github.com/stretchr/testify/assert"
911
"github.com/stretchr/testify/require"
@@ -390,3 +392,79 @@ func TestUpdateOnlyUpdatesInstalled(t *testing.T) {
390392
})
391393
}
392394
}
395+
396+
func TestListSkillsDeduplicatesAcrossAgents(t *testing.T) {
397+
skills, err := ListSkills()
398+
require.NoError(t, err)
399+
400+
seen := make(map[string]bool)
401+
for _, skill := range skills {
402+
assert.False(t, seen[skill.DirName], "duplicate skill in ListSkills output: %s", skill.DirName)
403+
seen[skill.DirName] = true
404+
}
405+
}
406+
407+
func TestListSkillsUsesFirstAgentMetadata(t *testing.T) {
408+
// When frontmatter differs across agents for the same skill,
409+
// ListSkills should return the first agent's (Claude's) metadata.
410+
skills, err := ListSkills()
411+
require.NoError(t, err)
412+
413+
claudeSkillsByDir := make(map[string]embeddedSkill)
414+
claudeSpec := supportedAgents[0]
415+
require.Equal(t, AgentClaude, claudeSpec.agent, "first agent must be Claude for this test")
416+
417+
embedded, err := embeddedSkillsForAgent(claudeSpec)
418+
require.NoError(t, err)
419+
for _, s := range embedded {
420+
claudeSkillsByDir[s.DirName] = s
421+
}
422+
423+
for _, skill := range skills {
424+
cs, ok := claudeSkillsByDir[skill.DirName]
425+
if !ok {
426+
continue
427+
}
428+
assert.Equal(t, cs.Name, skill.Name,
429+
"skill %s: name should match first agent (Claude)", skill.DirName)
430+
assert.Equal(t, cs.Description, skill.Description,
431+
"skill %s: description should match first agent (Claude)", skill.DirName)
432+
}
433+
}
434+
435+
func TestDirNameEnumerationDoesNotReadContent(t *testing.T) {
436+
// embeddedSkillDirNames only enumerates directories, so it must
437+
// succeed even when SKILL.md files are absent. This guards against
438+
// regressions that would make IsInstalled/Update depend on file reads.
439+
mockFS := fstest.MapFS{
440+
"agent/skill-a/.keep": &fstest.MapFile{Data: []byte("")},
441+
"agent/skill-b/.keep": &fstest.MapFile{Data: []byte("")},
442+
}
443+
spec := agentSpec{
444+
agent: "mock",
445+
configDirName: ".mock",
446+
embedFS: mockFS,
447+
embedDir: "agent",
448+
}
449+
450+
// embeddedSkillDirNames should succeed (only reads directory entries)
451+
names, err := embeddedSkillDirNames(spec)
452+
require.NoError(t, err)
453+
assert.ElementsMatch(t, []string{"skill-a", "skill-b"}, names)
454+
455+
// embeddedSkillsForAgent should fail (reads SKILL.md content)
456+
_, err = embeddedSkillsForAgent(spec)
457+
require.Error(t, err, "embeddedSkillsForAgent should fail when SKILL.md is missing")
458+
459+
// currentInstalledSkillFilePaths should succeed via embeddedSkillDirNames
460+
home := t.TempDir()
461+
paths, err := currentInstalledSkillFilePaths(home, spec)
462+
require.NoError(t, err)
463+
require.Len(t, paths, 2)
464+
for _, p := range paths {
465+
assert.True(t, strings.Contains(p, filepath.Join(home, ".mock", "skills")),
466+
"path should be under agent skills dir: %s", p)
467+
assert.True(t, strings.HasSuffix(p, "SKILL.md"),
468+
"path should end with SKILL.md: %s", p)
469+
}
470+
}

0 commit comments

Comments
 (0)