diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 2fe202278885ee0a1bc2eadc6520042f5056c96d..dc949f1c778b74b33d50bb41b3ed1afbac53b875 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1759,6 +1759,14 @@ "created_at": "2026-05-14T02:28:13Z", "repoId": 987670088, "pullRequestNo": 2912 + }, + { + "name": "leonardoaraujosantos", + "id": 898383, + "comment_id": 3166308642, + "created_at": "2025-08-08T01:40:17Z", + "repoId": 987670088, + "pullRequestNo": 644 } ] } \ No newline at end of file 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/agent/hyper/provider.json b/internal/agent/hyper/provider.json index d736583ace952533e5f186b1e99b1f42c1fffe19..6edb056dcedeb6f9f20bbe1221b9803645ecc768 100644 --- a/internal/agent/hyper/provider.json +++ b/internal/agent/hyper/provider.json @@ -3,8 +3,8 @@ "id": "hyper", "api_endpoint": "https://hyper.charm.land/api/v1/fantasy", "type": "hyper", - "default_large_model_id": "kimi-k2.5", - "default_small_model_id": "gpt-oss-120b", + "default_large_model_id": "kimi-k2.6", + "default_small_model_id": "deepseek-v4-flash", "models": [ { "id": "deepseek-v4-flash", @@ -132,6 +132,18 @@ "can_reason": true, "supports_attachments": true }, + { + "id": "minimax-m2.7", + "name": "MiniMax M2.7", + "cost_per_1m_in": 0.3, + "cost_per_1m_out": 1.2, + "cost_per_1m_in_cached": 0.06, + "cost_per_1m_out_cached": 0, + "context_window": 204800, + "default_max_tokens": 20480, + "can_reason": false, + "supports_attachments": false + }, { "id": "mistral-large-instruct-2411", "name": "Mistral Large Instruct 2411", 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..7071ba61820158a5324a48fbb5faf5cc89bd5f38 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,16 +281,14 @@ 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)) }) - broker.Publish(pubsub.UpdatedEvent, Event{States: states}) return skills, states } @@ -283,6 +318,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/skills/skills_test.go b/internal/skills/skills_test.go index 34a70c40a2e1479a7c86f3b86e4e02a2e551bf23..f11a48c37f58fe727da97a90939efdedd0189250 100644 --- a/internal/skills/skills_test.go +++ b/internal/skills/skills_test.go @@ -1,7 +1,6 @@ package skills import ( - "context" "os" "path/filepath" "strings" @@ -216,7 +215,8 @@ func TestSkillValidate(t *testing.T) { } func TestDiscover(t *testing.T) { - // Not parallel: shares global broker with other Discover tests. + t.Parallel() + tmpDir := t.TempDir() // Create valid skill 1. @@ -248,14 +248,8 @@ description: Name doesn't match directory. --- `), 0o644)) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - ch := SubscribeEvents(ctx) - - skills := Discover([]string{tmpDir}) + skills, states := DiscoverWithStates([]string{tmpDir}) - evt := <-ch - states := evt.Payload.States var normalCount int var errorCount int var hasInvalidDir bool @@ -285,32 +279,20 @@ description: Name doesn't match directory. } func TestDiscoverEmptyDir(t *testing.T) { - // Not parallel: shares global broker with other Discover tests. + t.Parallel() tmpDir := t.TempDir() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - ch := SubscribeEvents(ctx) - - skills := Discover([]string{tmpDir}) - - evt := <-ch - require.Empty(t, evt.Payload.States) + skills, states := DiscoverWithStates([]string{tmpDir}) + require.Empty(t, states) require.Empty(t, skills) } func TestDiscoverMissingPath(t *testing.T) { - // Not parallel: shares global broker with other Discover tests. - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - ch := SubscribeEvents(ctx) - - skills := Discover([]string{filepath.Join(t.TempDir(), "missing")}) + t.Parallel() - evt := <-ch - require.Empty(t, evt.Payload.States) + skills, states := DiscoverWithStates([]string{filepath.Join(t.TempDir(), "missing")}) + require.Empty(t, states) require.Empty(t, skills) } 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)