diff --git a/README.md b/README.md index f2acd1af54046c313e29e8b3b7d9fc8787b429da..7487a968ead6c5d4bff1005178cff3930265a778 100644 --- a/README.md +++ b/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\` diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 5db3309bc8e02bdca3a94070030b7a1c2d8ab301..3dea024a844f20ecb48dca5c6efae3842bf1de32 100644 --- a/internal/agent/coordinator.go +++ b/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 } diff --git a/internal/config/load.go b/internal/config/load.go index 9aefbb26607482ed195e6beb05214e63ba4187a0..05637278952b03bddcb1e51015c59352b58015c9 100644 --- a/internal/config/load.go +++ b/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`. diff --git a/internal/skills/diagnostics_test.go b/internal/skills/diagnostics_test.go index 3d5e784074112817287e8ec7b8deae64264915e2..c2d8730d563cbacbf711281283ceb8d2c1733e5d 100644 --- a/internal/skills/diagnostics_test.go +++ b/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") +} diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 0e3b186a96ced772db76254570ef05b07af8d2a0..4b9d32d69038c11a315c54e41bd0ce01ac87391f 100644 --- a/internal/skills/skills.go +++ b/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("&", "&", "<", "<", ">", ">", "\"", """, "'", "'") + + 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. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index fbc0eca985c71373e3a26a0008287437e203e759..6087c57cd584ae2302b59b3d28ac7e373b168108 100644 --- a/internal/ui/model/ui.go +++ b/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)