fix(ui): show custom skills reliably on startup

Ilgaz created

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

Change summary

README.md                           |  2 
internal/agent/coordinator.go       | 11 ++++
internal/config/load.go             |  3 +
internal/skills/diagnostics_test.go | 42 ++++++++++++++++++
internal/skills/skills.go           | 71 +++++++++++++++++++++++++++---
internal/ui/model/ui.go             |  1 
6 files changed, 123 insertions(+), 7 deletions(-)

Detailed changes

README.md 🔗

@@ -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\`

internal/agent/coordinator.go 🔗

@@ -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
 }

internal/config/load.go 🔗

@@ -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`.

internal/skills/diagnostics_test.go 🔗

@@ -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")
+}

internal/skills/skills.go 🔗

@@ -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("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
+
+	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.

internal/ui/model/ui.go 🔗

@@ -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)