on main help sign in
fix(ui): show custom skills reliably on startup
8e7451fIlgaz
README.md+2
@@ -445,6 +445,8 @@ The global paths we looks for skills are:
445* `$CRUSH_SKILLS_DIR`
445* `$CRUSH_SKILLS_DIR`
446* `$XDG_CONFIG_HOME/agents/skills` or `~/.config/agents/skills/`
446* `$XDG_CONFIG_HOME/agents/skills` or `~/.config/agents/skills/`
447* `$XDG_CONFIG_HOME/crush/skills` or `~/.config/crush/skills/`
447* `$XDG_CONFIG_HOME/crush/skills` or `~/.config/crush/skills/`
448+* `~/.agents/skills/`
449+* `~/.claude/skills/`
448* On Windows, we _also_ look at
450* On Windows, we _also_ look at
449 * `%LOCALAPPDATA%\agents\skills\` or `%USERPROFILE%\AppData\Local\agents\skills\`
451 * `%LOCALAPPDATA%\agents\skills\` or `%USERPROFILE%\AppData\Local\agents\skills\`
450 * `%LOCALAPPDATA%\crush\skills\` or `%USERPROFILE%\AppData\Local\crush\skills\`
452 * `%LOCALAPPDATA%\crush\skills\` or `%USERPROFILE%\AppData\Local\crush\skills\`
internal/agent/coordinator.go+11
@@ -1144,6 +1144,17 @@ func discoverSkills(cfg *config.ConfigStore) (allSkills, activeSkills []*skills.
1144 }
1144 }
1145 activeSkills = skills.Filter(allSkills, disabledSkills)
1145 activeSkills = skills.Filter(allSkills, disabledSkills)
1146
1146
1147+ allStates := append([]*skills.SkillState(nil), builtinStates...)
1148+ allStates = append(allStates, userStates...)
1149+
1150+ allStates = skills.DeduplicateStates(allStates)
1151+
1152+ slices.SortStableFunc(allStates, func(a, b *skills.SkillState) int {
1153+ return strings.Compare(strings.ToLower(a.Path), strings.ToLower(b.Path))
1154+ })
1155+ skills.SetLatestStates(allStates)
1156+ skills.PublishStates(allStates)
1157+
1147 logDiscoveryStats(builtin, builtinStates, userStates, userPaths, allSkills, activeSkills, disabledSkills)
1158 logDiscoveryStats(builtin, builtinStates, userStates, userPaths, allSkills, activeSkills, disabledSkills)
1148 return allSkills, activeSkills
1159 return allSkills, activeSkills
1149}
1160}
internal/config/load.go+3
@@ -950,6 +950,9 @@ func GlobalSkillsDirs() []string {
950 paths := []string{
950 paths := []string{
951 filepath.Join(home.Config(), appName, "skills"),
951 filepath.Join(home.Config(), appName, "skills"),
952 filepath.Join(home.Config(), "agents", "skills"),
952 filepath.Join(home.Config(), "agents", "skills"),
953+ // Per the Agent Skills spec, scan ~/.agents/skills
954+ filepath.Join(home.Dir(), ".agents", "skills"),
955+ filepath.Join(home.Dir(), ".claude", "skills"),
953 }
956 }
954
957
955 // On Windows, also load from app data on top of `$HOME/.config/crush`.
958 // On Windows, also load from app data on top of `$HOME/.config/crush`.
internal/skills/diagnostics_test.go+42
@@ -1,6 +1,8 @@
1package skills
1package skills
2
2
3import (
3import (
4+ "os"
5+ "path/filepath"
4 "testing"
6 "testing"
5
7
6 "github.com/stretchr/testify/require"
8 "github.com/stretchr/testify/require"
@@ -60,3 +62,43 @@ func TestDiscoverWithStates_MissingPath(t *testing.T) {
60 skills, _ := DiscoverWithStates([]string{"/nonexistent/crush/skills/path"})
62 skills, _ := DiscoverWithStates([]string{"/nonexistent/crush/skills/path"})
61 require.Empty(t, skills)
63 require.Empty(t, skills)
62}
64}
65+
66+func TestGetLatestStates(t *testing.T) {
67+ // Not parallel - manipulates package-level cache.
68+ prev := GetLatestStates()
69+ t.Cleanup(func() { SetLatestStates(prev) })
70+
71+ SetLatestStates(nil)
72+ require.Nil(t, GetLatestStates())
73+
74+ dir := t.TempDir()
75+ skillDir := filepath.Join(dir, "my-skill")
76+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
77+ require.NoError(t, os.WriteFile(
78+ filepath.Join(skillDir, SkillFileName),
79+ []byte("---\nname: my-skill\ndescription: A test skill.\n---\nInstructions.\n"),
80+ 0o644,
81+ ))
82+
83+ _, states := DiscoverWithStates([]string{dir})
84+ SetLatestStates(states)
85+
86+ got := GetLatestStates()
87+ require.Len(t, got, 1)
88+ require.Equal(t, "my-skill", got[0].Name)
89+}
90+
91+func TestGetLatestStates_Isolation(t *testing.T) {
92+ // Not parallel - manipulates package-level cache.
93+ prev := GetLatestStates()
94+ t.Cleanup(func() { SetLatestStates(prev) })
95+
96+ initial := []*SkillState{{Name: "test"}}
97+ SetLatestStates(initial)
98+
99+ got := GetLatestStates()
100+ got[0].Name = "corrupted"
101+
102+ check := GetLatestStates()
103+ require.Equal(t, "test", check[0].Name, "Cache should be isolated from caller mutations")
104+}
internal/skills/skills.go+64−7
@@ -11,7 +11,6 @@ import (
11 "path/filepath"
11 "path/filepath"
12 "regexp"
12 "regexp"
13 "slices"
13 "slices"
14− "sort"
15 "strings"
14 "strings"
16 "sync"
15 "sync"
17
16
@@ -30,6 +29,9 @@ const (
30var (
29var (
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("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
31 promptReplacer = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
32+
33+ latestStates []*SkillState
34+ latestStatesMu sync.RWMutex
33)
35)
34
36
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)
76}
78}
77
79
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)})
83+}
84+
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 {
88+ if states == nil {
89+ return nil
90+ }
91+ result := make([]*SkillState, len(states))
92+ for i, s := range states {
93+ clone := *s
94+ result[i] = &clone
95+ }
96+ return result
97+}
98+
99+// GetLatestStates returns the latest discovery states.
100+func GetLatestStates() []*SkillState {
101+ latestStatesMu.RLock()
102+ defer latestStatesMu.RUnlock()
103+ return cloneStates(latestStates)
104+}
105+
106+// SetLatestStates stores the given states in the package-level cache so that
107+// GetLatestStates can return them synchronously before the first pubsub event
108+// arrives.
109+func SetLatestStates(states []*SkillState) {
110+ latestStatesMu.Lock()
111+ latestStates = cloneStates(states)
112+ latestStatesMu.Unlock()
113+}
114+
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,15 +281,15 @@ func DiscoverWithStates(paths []string) ([]*Skill, []*SkillState) {
244 }
281 }
245
282
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
252 }
288 }
253− return left < right
289+ return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
254 })
290 })
255
291
292+ // Publish states as-is; the coordinator will merge and re-sort them later.
256 broker.Publish(pubsub.UpdatedEvent, Event{States: states})
293 broker.Publish(pubsub.UpdatedEvent, Event{States: states})
257 return skills, states
294 return skills, states
258}
295}
@@ -283,6 +320,26 @@ func escape(s string) string {
283 return promptReplacer.Replace(s)
320 return promptReplacer.Replace(s)
284}
321}
285
322
323+// DeduplicateStates removes duplicate skill states by name. When duplicates exist,
324+// the last occurrence wins (consistent with Deduplicate for skills).
325+func DeduplicateStates(all []*SkillState) []*SkillState {
326+ seen := make(map[string]int, len(all))
327+ for i, s := range all {
328+ if s.Name != "" {
329+ seen[s.Name] = i
330+ }
331+ }
332+
333+ result := make([]*SkillState, 0, len(seen))
334+ for i, s := range all {
335+ // If it's the last occurrence of this name, or it has no name (error state), keep it
336+ if s.Name == "" || seen[s.Name] == i {
337+ result = append(result, s)
338+ }
339+ }
340+ return result
341+}
342+
286// Deduplicate removes duplicate skills by name. When duplicates exist, the
343// Deduplicate removes duplicate skills by name. When duplicates exist, the
287// last occurrence wins. This means user skills (appended after builtins)
344// last occurrence wins. This means user skills (appended after builtins)
288// override builtin skills with the same name.
345// override builtin skills with the same name.
internal/ui/model/ui.go+1
@@ -343,6 +343,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
343 notifyWindowFocused: true,
343 notifyWindowFocused: true,
344 initialSessionID: initialSessionID,
344 initialSessionID: initialSessionID,
345 continueLastSession: continueLast,
345 continueLastSession: continueLast,
346+ skillStates: skills.GetLatestStates(),
346 }
347 }
347
348
348 status := NewStatus(com, ui)
349 status := NewStatus(com, ui)