Detailed changes
@@ -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
}
]
}
@@ -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\`
@@ -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
}
@@ -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",
@@ -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`.
@@ -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")
+}
@@ -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.
@@ -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)
}
@@ -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)