@@ -11,7 +11,6 @@ import (
11 "path/filepath"
11 "path/filepath"
@@ -30,6 +29,9 @@ const (
31 namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
30 namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
32 promptReplacer = strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
31 promptReplacer = strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
33+ latestStates []*SkillState
34+ latestStatesMu sync.RWMutex
35// Skill represents a parsed SKILL.md file.
37// Skill represents a parsed SKILL.md file.
@@ -75,6 +77,41 @@ func SubscribeEvents(ctx context.Context) <-chan pubsub.Event[Event] {
75 return broker.Subscribe(ctx)
77 return broker.Subscribe(ctx)
80+// PublishStates publishes a skill discovery event with the given states.
81+func PublishStates(states []*SkillState) {
82+ broker.Publish(pubsub.UpdatedEvent, Event{States: cloneStates(states)})
85+// cloneStates returns a deep copy of the given state slice so callers cannot
86+// accidentally mutate the source.
87+func cloneStates(states []*SkillState) []*SkillState {
91+ result := make([]*SkillState, len(states))
92+ for i, s := range states {
99+// GetLatestStates returns the latest discovery states.
100+func GetLatestStates() []*SkillState {
101+ latestStatesMu.RLock()
102+ defer latestStatesMu.RUnlock()
103+ return cloneStates(latestStates)
106+// SetLatestStates stores the given states in the package-level cache so that
107+// GetLatestStates can return them synchronously before the first pubsub event
109+func SetLatestStates(states []*SkillState) {
110+ latestStatesMu.Lock()
111+ latestStates = cloneStates(states)
112+ latestStatesMu.Unlock()
78// Validate checks if the skill meets spec requirements.
115// Validate checks if the skill meets spec requirements.
79func (s *Skill) Validate() error {
116func (s *Skill) Validate() error {
80 var errs []error
117 var errs []error
@@ -244,16 +281,14 @@ func DiscoverWithStates(paths []string) ([]*Skill, []*SkillState) {
246 // fastwalk traversal order is non-deterministic, so sort for stable output.
283 // fastwalk traversal order is non-deterministic, so sort for stable output.
247− sort.SliceStable(skills, func(i, j int) bool {
284+ // Sort by path first, then alphabetically by name within each path.
248− left := strings.ToLower(skills[i].SkillFilePath)
285+ slices.SortStableFunc(skills, func(a, b *Skill) int {
249− right := strings.ToLower(skills[j].SkillFilePath)
286+ if c := strings.Compare(strings.ToLower(a.Path), strings.ToLower(b.Path)); c != 0 {
250− if left == right {
287+ return c
251− return skills[i].SkillFilePath < skills[j].SkillFilePath
253− return left < right
289+ return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
256− broker.Publish(pubsub.UpdatedEvent, Event{States: states})
257 return skills, states
292 return skills, states
@@ -283,6 +318,26 @@ func escape(s string) string {
283 return promptReplacer.Replace(s)
318 return promptReplacer.Replace(s)
321+// DeduplicateStates removes duplicate skill states by name. When duplicates exist,
322+// the last occurrence wins (consistent with Deduplicate for skills).
323+func DeduplicateStates(all []*SkillState) []*SkillState {
324+ seen := make(map[string]int, len(all))
325+ for i, s := range all {
331+ result := make([]*SkillState, 0, len(seen))
332+ for i, s := range all {
333+ // If it's the last occurrence of this name, or it has no name (error state), keep it
334+ if s.Name == "" || seen[s.Name] == i {
335+ result = append(result, s)
286// Deduplicate removes duplicate skills by name. When duplicates exist, the
341// Deduplicate removes duplicate skills by name. When duplicates exist, the
287// last occurrence wins. This means user skills (appended after builtins)
342// last occurrence wins. This means user skills (appended after builtins)
288// override builtin skills with the same name.
343// override builtin skills with the same name.