cli/internal/skills/discovery/collisions.go
Copilot 2e93afc272 Install skills flat by Name, not namespaced InstallName
Most agent clients (Claude Code, Copilot, etc.) only discover immediate
subdirectories of their skills folder. When a skill repository used
namespaced paths like skills/author/my-skill/, the installer created
nested directories (e.g. .claude/skills/author/my-skill/) that clients
could not find.

This separates the skill's identity (InstallName, used for lockfile keys,
search, filtering, display) from the filesystem path (Name, used for the
install directory). Skills are now always installed flat:

  .claude/skills/my-skill/SKILL.md  (not .claude/skills/author/my-skill/)

Changes:
- installer: use skill.Name for directory paths instead of InstallName
- install.go: use skill.Name for overwrite checks and prompts
- collisions: detect conflicts by Name since flat install means two
  skills with the same Name but different Namespace values will collide
- update: clean up old namespaced directories when migrating to flat

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 01:26:31 +02:00

55 lines
1.8 KiB
Go

package discovery
import (
"fmt"
"sort"
"strings"
)
// NameCollision represents a group of skills that share the same install
// directory name and would overwrite each other when installed.
type NameCollision struct {
Name string // the conflicting skill name (directory name)
DisplayNames []string // display names of each conflicting skill
}
// FindNameCollisions detects skills whose Name fields collide (meaning they
// would be installed to the same directory) and returns a sorted slice of
// collisions. Skills are installed flat by Name, so two skills with the same
// Name but different Namespace values still conflict. Callers decide how to
// present the conflict to the user.
func FindNameCollisions(skills []Skill) []NameCollision {
byName := make(map[string][]Skill)
for _, s := range skills {
byName[s.Name] = append(byName[s.Name], s)
}
var collisions []NameCollision
for name, group := range byName {
if len(group) <= 1 {
continue
}
names := make([]string, len(group))
for i, s := range group {
names[i] = s.DisplayName()
}
collisions = append(collisions, NameCollision{Name: name, DisplayNames: names})
}
sort.Slice(collisions, func(i, j int) bool {
return collisions[i].Name < collisions[j].Name
})
return collisions
}
// FormatCollisions builds a human-readable string listing each collision,
// suitable for embedding in an error message. Each collision is formatted as
// "name: display1, display2" and collisions are separated by newlines with
// leading indentation.
func FormatCollisions(collisions []NameCollision) string {
lines := make([]string, len(collisions))
for i, c := range collisions {
lines[i] = fmt.Sprintf("%s: %s", c.Name, strings.Join(c.DisplayNames, ", "))
}
return strings.Join(lines, "\n ")
}