Detailed changes
@@ -445,6 +445,8 @@ The global paths we looks for skills are:
* `$CRUSH_SKILLS_DIR`
* `$XDG_CONFIG_HOME/agents/skills` or `~/.config/agents/skills/`
* `$XDG_CONFIG_HOME/crush/skills` or `~/.config/crush/skills/`
+* `~/.agents/skills/`
+* `~/.claude/skills/`
* On Windows, we _also_ look at
* `%LOCALAPPDATA%\agents\skills\` or `%USERPROFILE%\AppData\Local\agents\skills\`
* `%LOCALAPPDATA%\crush\skills\` or `%USERPROFILE%\AppData\Local\crush\skills\`
@@ -1144,6 +1144,17 @@ func discoverSkills(cfg *config.ConfigStore) (allSkills, activeSkills []*skills.
}
activeSkills = skills.Filter(allSkills, disabledSkills)
+ allStates := append([]*skills.SkillState(nil), builtinStates...)
+ allStates = append(allStates, userStates...)
+
+ allStates = skills.DeduplicateStates(allStates)
+
+ slices.SortStableFunc(allStates, func(a, b *skills.SkillState) int {
+ return strings.Compare(strings.ToLower(a.Path), strings.ToLower(b.Path))
+ })
+ skills.SetLatestStates(allStates)
+ skills.PublishStates(allStates)
+
logDiscoveryStats(builtin, builtinStates, userStates, userPaths, allSkills, activeSkills, disabledSkills)
return allSkills, activeSkills
}
@@ -950,6 +950,9 @@ func GlobalSkillsDirs() []string {
paths := []string{
filepath.Join(home.Config(), appName, "skills"),
filepath.Join(home.Config(), "agents", "skills"),
+ // Per the Agent Skills spec, scan ~/.agents/skills
+ filepath.Join(home.Dir(), ".agents", "skills"),
+ filepath.Join(home.Dir(), ".claude", "skills"),
}
// On Windows, also load from app data on top of `$HOME/.config/crush`.
@@ -1,6 +1,8 @@
package skills
import (
+ "os"
+ "path/filepath"
"testing"
"github.com/stretchr/testify/require"
@@ -60,3 +62,43 @@ func TestDiscoverWithStates_MissingPath(t *testing.T) {
skills, _ := DiscoverWithStates([]string{"/nonexistent/crush/skills/path"})
require.Empty(t, skills)
}
+
+func TestGetLatestStates(t *testing.T) {
+ // Not parallel - manipulates package-level cache.
+ prev := GetLatestStates()
+ t.Cleanup(func() { SetLatestStates(prev) })
+
+ SetLatestStates(nil)
+ require.Nil(t, GetLatestStates())
+
+ dir := t.TempDir()
+ skillDir := filepath.Join(dir, "my-skill")
+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(skillDir, SkillFileName),
+ []byte("---\nname: my-skill\ndescription: A test skill.\n---\nInstructions.\n"),
+ 0o644,
+ ))
+
+ _, states := DiscoverWithStates([]string{dir})
+ SetLatestStates(states)
+
+ got := GetLatestStates()
+ require.Len(t, got, 1)
+ require.Equal(t, "my-skill", got[0].Name)
+}
+
+func TestGetLatestStates_Isolation(t *testing.T) {
+ // Not parallel - manipulates package-level cache.
+ prev := GetLatestStates()
+ t.Cleanup(func() { SetLatestStates(prev) })
+
+ initial := []*SkillState{{Name: "test"}}
+ SetLatestStates(initial)
+
+ got := GetLatestStates()
+ got[0].Name = "corrupted"
+
+ check := GetLatestStates()
+ require.Equal(t, "test", check[0].Name, "Cache should be isolated from caller mutations")
+}
@@ -11,7 +11,6 @@ import (
"path/filepath"
"regexp"
"slices"
- "sort"
"strings"
"sync"
@@ -30,6 +29,9 @@ const (
var (
namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
promptReplacer = strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
+
+ latestStates []*SkillState
+ latestStatesMu sync.RWMutex
)
// Skill represents a parsed SKILL.md file.
@@ -75,6 +77,41 @@ func SubscribeEvents(ctx context.Context) <-chan pubsub.Event[Event] {
return broker.Subscribe(ctx)
}
+// PublishStates publishes a skill discovery event with the given states.
+func PublishStates(states []*SkillState) {
+ broker.Publish(pubsub.UpdatedEvent, Event{States: cloneStates(states)})
+}
+
+// cloneStates returns a deep copy of the given state slice so callers cannot
+// accidentally mutate the source.
+func cloneStates(states []*SkillState) []*SkillState {
+ if states == nil {
+ return nil
+ }
+ result := make([]*SkillState, len(states))
+ for i, s := range states {
+ clone := *s
+ result[i] = &clone
+ }
+ return result
+}
+
+// GetLatestStates returns the latest discovery states.
+func GetLatestStates() []*SkillState {
+ latestStatesMu.RLock()
+ defer latestStatesMu.RUnlock()
+ return cloneStates(latestStates)
+}
+
+// SetLatestStates stores the given states in the package-level cache so that
+// GetLatestStates can return them synchronously before the first pubsub event
+// arrives.
+func SetLatestStates(states []*SkillState) {
+ latestStatesMu.Lock()
+ latestStates = cloneStates(states)
+ latestStatesMu.Unlock()
+}
+
// Validate checks if the skill meets spec requirements.
func (s *Skill) Validate() error {
var errs []error
@@ -244,15 +281,15 @@ func DiscoverWithStates(paths []string) ([]*Skill, []*SkillState) {
}
// fastwalk traversal order is non-deterministic, so sort for stable output.
- sort.SliceStable(skills, func(i, j int) bool {
- left := strings.ToLower(skills[i].SkillFilePath)
- right := strings.ToLower(skills[j].SkillFilePath)
- if left == right {
- return skills[i].SkillFilePath < skills[j].SkillFilePath
+ // Sort by path first, then alphabetically by name within each path.
+ slices.SortStableFunc(skills, func(a, b *Skill) int {
+ if c := strings.Compare(strings.ToLower(a.Path), strings.ToLower(b.Path)); c != 0 {
+ return c
}
- return left < right
+ return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
+ // Publish states as-is; the coordinator will merge and re-sort them later.
broker.Publish(pubsub.UpdatedEvent, Event{States: states})
return skills, states
}
@@ -283,6 +320,26 @@ func escape(s string) string {
return promptReplacer.Replace(s)
}
+// DeduplicateStates removes duplicate skill states by name. When duplicates exist,
+// the last occurrence wins (consistent with Deduplicate for skills).
+func DeduplicateStates(all []*SkillState) []*SkillState {
+ seen := make(map[string]int, len(all))
+ for i, s := range all {
+ if s.Name != "" {
+ seen[s.Name] = i
+ }
+ }
+
+ result := make([]*SkillState, 0, len(seen))
+ for i, s := range all {
+ // If it's the last occurrence of this name, or it has no name (error state), keep it
+ if s.Name == "" || seen[s.Name] == i {
+ result = append(result, s)
+ }
+ }
+ return result
+}
+
// Deduplicate removes duplicate skills by name. When duplicates exist, the
// last occurrence wins. This means user skills (appended after builtins)
// override builtin skills with the same name.
@@ -343,6 +343,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
notifyWindowFocused: true,
initialSessionID: initialSessionID,
continueLastSession: continueLast,
+ skillStates: skills.GetLatestStates(),
}
status := NewStatus(com, ui)