on main help sign in
fix(ui): show custom skills reliably on startup

Resolve race condition by implementing a package-level cache for skill discovery states. Add ~/.agents/skills/ and ~/.claude/skills/ as global skill scan paths.

commit8e7451fc1e2c44523b5a52a288738839507fb9e4 authorIlgaz <ilgax@xcyn.dev> dateWed 6 May 2026, 22:57 (5 months ago) parent62ee620
6 files changed+123−7
README.md+2 internal/agent/coordinator.go+11 internal/config/load.go+3 internal/skills/diagnostics_test.go+42 internal/skills/skills.go+64−7 internal/ui/model/ui.go+1
README.md+2
@@ -445,6 +445,8 @@ The global paths we looks for skills are:
445445* `$CRUSH_SKILLS_DIR`
446446* `$XDG_CONFIG_HOME/agents/skills` or `~/.config/agents/skills/`
447447* `$XDG_CONFIG_HOME/crush/skills` or `~/.config/crush/skills/`
448+* `~/.agents/skills/`
449+* `~/.claude/skills/`
448450* On Windows, we _also_ look at
449451 * `%LOCALAPPDATA%\agents\skills\` or `%USERPROFILE%\AppData\Local\agents\skills\`
450452 * `%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.
11441144 }
11451145 activeSkills = skills.Filter(allSkills, disabledSkills)
11461146
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+
11471158 logDiscoveryStats(builtin, builtinStates, userStates, userPaths, allSkills, activeSkills, disabledSkills)
11481159 return allSkills, activeSkills
11491160}
internal/config/load.go+3
@@ -950,6 +950,9 @@ func GlobalSkillsDirs() []string {
950950 paths := []string{
951951 filepath.Join(home.Config(), appName, "skills"),
952952 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"),
953956 }
954957
955958 // On Windows, also load from app data on top of `$HOME/.config/crush`.
internal/skills/diagnostics_test.go+42
@@ -1,6 +1,8 @@
11package skills
22
33import (
4+ "os"
5+ "path/filepath"
46 "testing"
57
68 "github.com/stretchr/testify/require"
@@ -60,3 +62,43 @@ func TestDiscoverWithStates_MissingPath(t *testing.T) {
6062 skills, _ := DiscoverWithStates([]string{"/nonexistent/crush/skills/path"})
6163 require.Empty(t, skills)
6264}
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 (
1111 "path/filepath"
1212 "regexp"
1313 "slices"
14− "sort"
1514 "strings"
1615 "sync"
1716
@@ -30,6 +29,9 @@ const (
3029var (
3130 namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
3231 promptReplacer = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
32+
33+ latestStates []*SkillState
34+ latestStatesMu sync.RWMutex
3335)
3436
3537// Skill represents a parsed SKILL.md file.
@@ -75,6 +77,41 @@ func SubscribeEvents(ctx context.Context) <-chan pubsub.Event[Event] {
7577 return broker.Subscribe(ctx)
7678}
7779
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+
78115// Validate checks if the skill meets spec requirements.
79116func (s *Skill) Validate() error {
80117 var errs []error
@@ -244,15 +281,15 @@ func DiscoverWithStates(paths []string) ([]*Skill, []*SkillState) {
244281 }
245282
246283 // fastwalk traversal order is non-deterministic, so sort for stable output.
247− sort.SliceStable(skills, func(i, j int) bool {
248− left := strings.ToLower(skills[i].SkillFilePath)
249− right := strings.ToLower(skills[j].SkillFilePath)
250− if left == right {
251− return skills[i].SkillFilePath < skills[j].SkillFilePath
284+ // Sort by path first, then alphabetically by name within each path.
285+ slices.SortStableFunc(skills, func(a, b *Skill) int {
286+ if c := strings.Compare(strings.ToLower(a.Path), strings.ToLower(b.Path)); c != 0 {
287+ return c
252288 }
253− return left < right
289+ return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
254290 })
255291
292+ // Publish states as-is; the coordinator will merge and re-sort them later.
256293 broker.Publish(pubsub.UpdatedEvent, Event{States: states})
257294 return skills, states
258295}
@@ -283,6 +320,26 @@ func escape(s string) string {
283320 return promptReplacer.Replace(s)
284321}
285322
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+
286343// Deduplicate removes duplicate skills by name. When duplicates exist, the
287344// last occurrence wins. This means user skills (appended after builtins)
288345// 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 {
343343 notifyWindowFocused: true,
344344 initialSessionID: initialSessionID,
345345 continueLastSession: continueLast,
346+ skillStates: skills.GetLatestStates(),
346347 }
347348
348349 status := NewStatus(com, ui)