on main help sign in
Merge branch 'main' into fast-ui
5520540Christian Rocha
.github/cla-signatures.json+8
@@ -1759,6 +1759,14 @@
1759 "created_at": "2026-05-14T02:28:13Z",
1759 "created_at": "2026-05-14T02:28:13Z",
1760 "repoId": 987670088,
1760 "repoId": 987670088,
1761 "pullRequestNo": 2912
1761 "pullRequestNo": 2912
1762+ },
1763+ {
1764+ "name": "leonardoaraujosantos",
1765+ "id": 898383,
1766+ "comment_id": 3166308642,
1767+ "created_at": "2025-08-08T01:40:17Z",
1768+ "repoId": 987670088,
1769+ "pullRequestNo": 644
1762 }
1770 }
1763 ]
1771 ]
1764}
1772}
README.md+2
@@ -445,6 +445,8 @@ The global paths we looks for skills are:
445* `$CRUSH_SKILLS_DIR`
445* `$CRUSH_SKILLS_DIR`
446* `$XDG_CONFIG_HOME/agents/skills` or `~/.config/agents/skills/`
446* `$XDG_CONFIG_HOME/agents/skills` or `~/.config/agents/skills/`
447* `$XDG_CONFIG_HOME/crush/skills` or `~/.config/crush/skills/`
447* `$XDG_CONFIG_HOME/crush/skills` or `~/.config/crush/skills/`
448+* `~/.agents/skills/`
449+* `~/.claude/skills/`
448* On Windows, we _also_ look at
450* On Windows, we _also_ look at
449 * `%LOCALAPPDATA%\agents\skills\` or `%USERPROFILE%\AppData\Local\agents\skills\`
451 * `%LOCALAPPDATA%\agents\skills\` or `%USERPROFILE%\AppData\Local\agents\skills\`
450 * `%LOCALAPPDATA%\crush\skills\` or `%USERPROFILE%\AppData\Local\crush\skills\`
452 * `%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.
1144 }
1144 }
1145 activeSkills = skills.Filter(allSkills, disabledSkills)
1145 activeSkills = skills.Filter(allSkills, disabledSkills)
1146
1146
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+
1147 logDiscoveryStats(builtin, builtinStates, userStates, userPaths, allSkills, activeSkills, disabledSkills)
1158 logDiscoveryStats(builtin, builtinStates, userStates, userPaths, allSkills, activeSkills, disabledSkills)
1148 return allSkills, activeSkills
1159 return allSkills, activeSkills
1149}
1160}
internal/agent/hyper/provider.json+14−2
@@ -3,8 +3,8 @@
3 "id": "hyper",
3 "id": "hyper",
4 "api_endpoint": "https://hyper.charm.land/api/v1/fantasy",
4 "api_endpoint": "https://hyper.charm.land/api/v1/fantasy",
5 "type": "hyper",
5 "type": "hyper",
6− "default_large_model_id": "kimi-k2.5",
6+ "default_large_model_id": "kimi-k2.6",
7− "default_small_model_id": "gpt-oss-120b",
7+ "default_small_model_id": "deepseek-v4-flash",
8 "models": [
8 "models": [
9 {
9 {
10 "id": "deepseek-v4-flash",
10 "id": "deepseek-v4-flash",
@@ -132,6 +132,18 @@
132 "can_reason": true,
132 "can_reason": true,
133 "supports_attachments": true
133 "supports_attachments": true
134 },
134 },
135+ {
136+ "id": "minimax-m2.7",
137+ "name": "MiniMax M2.7",
138+ "cost_per_1m_in": 0.3,
139+ "cost_per_1m_out": 1.2,
140+ "cost_per_1m_in_cached": 0.06,
141+ "cost_per_1m_out_cached": 0,
142+ "context_window": 204800,
143+ "default_max_tokens": 20480,
144+ "can_reason": false,
145+ "supports_attachments": false
146+ },
135 {
147 {
136 "id": "mistral-large-instruct-2411",
148 "id": "mistral-large-instruct-2411",
137 "name": "Mistral Large Instruct 2411",
149 "name": "Mistral Large Instruct 2411",
internal/config/load.go+3
@@ -950,6 +950,9 @@ func GlobalSkillsDirs() []string {
950 paths := []string{
950 paths := []string{
951 filepath.Join(home.Config(), appName, "skills"),
951 filepath.Join(home.Config(), appName, "skills"),
952 filepath.Join(home.Config(), "agents", "skills"),
952 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"),
953 }
956 }
954
957
955 // On Windows, also load from app data on top of `$HOME/.config/crush`.
958 // On Windows, also load from app data on top of `$HOME/.config/crush`.
internal/skills/diagnostics_test.go+42
@@ -1,6 +1,8 @@
1package skills
1package skills
2
2
3import (
3import (
4+ "os"
5+ "path/filepath"
4 "testing"
6 "testing"
5
7
6 "github.com/stretchr/testify/require"
8 "github.com/stretchr/testify/require"
@@ -60,3 +62,43 @@ func TestDiscoverWithStates_MissingPath(t *testing.T) {
60 skills, _ := DiscoverWithStates([]string{"/nonexistent/crush/skills/path"})
62 skills, _ := DiscoverWithStates([]string{"/nonexistent/crush/skills/path"})
61 require.Empty(t, skills)
63 require.Empty(t, skills)
62}
64}
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+63−8
@@ -11,7 +11,6 @@ import (
11 "path/filepath"
11 "path/filepath"
12 "regexp"
12 "regexp"
13 "slices"
13 "slices"
14− "sort"
15 "strings"
14 "strings"
16 "sync"
15 "sync"
17
16
@@ -30,6 +29,9 @@ const (
30var (
29var (
31 namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
30 namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
32 promptReplacer = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
31 promptReplacer = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
32+
33+ latestStates []*SkillState
34+ latestStatesMu sync.RWMutex
33)
35)
34
36
35// Skill represents a parsed SKILL.md file.
37// Skill represents a parsed SKILL.md file.
@@ -75,6 +77,41 @@ func SubscribeEvents(ctx context.Context) <-chan pubsub.Event[Event] {
75 return broker.Subscribe(ctx)
77 return broker.Subscribe(ctx)
76}
78}
77
79
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+
78// Validate checks if the skill meets spec requirements.
115// Validate checks if the skill meets spec requirements.
79func (s *Skill) Validate() error {
116func (s *Skill) Validate() error {
80 var errs []error
117 var errs []error
@@ -244,16 +281,14 @@ func DiscoverWithStates(paths []string) ([]*Skill, []*SkillState) {
244 }
281 }
245
282
246 // fastwalk traversal order is non-deterministic, so sort for stable output.
283 // fastwalk traversal order is non-deterministic, so sort for stable output.
247− sort.SliceStable(skills, func(i, j int) bool {
284+ // Sort by path first, then alphabetically by name within each path.
248− left := strings.ToLower(skills[i].SkillFilePath)
285+ slices.SortStableFunc(skills, func(a, b *Skill) int {
249− right := strings.ToLower(skills[j].SkillFilePath)
286+ if c := strings.Compare(strings.ToLower(a.Path), strings.ToLower(b.Path)); c != 0 {
250− if left == right {
287+ return c
251− return skills[i].SkillFilePath < skills[j].SkillFilePath
252 }
288 }
253− return left < right
289+ return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
254 })
290 })
255
291
256− broker.Publish(pubsub.UpdatedEvent, Event{States: states})
257 return skills, states
292 return skills, states
258}
293}
259
294
@@ -283,6 +318,26 @@ func escape(s string) string {
283 return promptReplacer.Replace(s)
318 return promptReplacer.Replace(s)
284}
319}
285
320
321+// DeduplicateStates removes duplicate skill states by name. When duplicates exist,
322+// the last occurrence wins (consistent with Deduplicate for skills).
323+func DeduplicateStates(all []*SkillState) []*SkillState {
324+ seen := make(map[string]int, len(all))
325+ for i, s := range all {
326+ if s.Name != "" {
327+ seen[s.Name] = i
328+ }
329+ }
330+
331+ result := make([]*SkillState, 0, len(seen))
332+ for i, s := range all {
333+ // If it's the last occurrence of this name, or it has no name (error state), keep it
334+ if s.Name == "" || seen[s.Name] == i {
335+ result = append(result, s)
336+ }
337+ }
338+ return result
339+}
340+
286// Deduplicate removes duplicate skills by name. When duplicates exist, the
341// Deduplicate removes duplicate skills by name. When duplicates exist, the
287// last occurrence wins. This means user skills (appended after builtins)
342// last occurrence wins. This means user skills (appended after builtins)
288// override builtin skills with the same name.
343// override builtin skills with the same name.
internal/skills/skills_test.go+9−27
@@ -1,7 +1,6 @@
1package skills
1package skills
2
2
3import (
3import (
4− "context"
5 "os"
4 "os"
6 "path/filepath"
5 "path/filepath"
7 "strings"
6 "strings"
@@ -216,7 +215,8 @@ func TestSkillValidate(t *testing.T) {
216}
215}
217
216
218func TestDiscover(t *testing.T) {
217func TestDiscover(t *testing.T) {
219− // Not parallel: shares global broker with other Discover tests.
218+ t.Parallel()
219+
220 tmpDir := t.TempDir()
220 tmpDir := t.TempDir()
221
221
222 // Create valid skill 1.
222 // Create valid skill 1.
@@ -248,14 +248,8 @@ description: Name doesn't match directory.
248---
248---
249`), 0o644))
249`), 0o644))
250
250
251− ctx, cancel := context.WithCancel(context.Background())
251+ skills, states := DiscoverWithStates([]string{tmpDir})
252− defer cancel()
253− ch := SubscribeEvents(ctx)
254−
255− skills := Discover([]string{tmpDir})
256
252
257− evt := <-ch
258− states := evt.Payload.States
259 var normalCount int
253 var normalCount int
260 var errorCount int
254 var errorCount int
261 var hasInvalidDir bool
255 var hasInvalidDir bool
@@ -285,32 +279,20 @@ description: Name doesn't match directory.
285}
279}
286
280
287func TestDiscoverEmptyDir(t *testing.T) {
281func TestDiscoverEmptyDir(t *testing.T) {
288− // Not parallel: shares global broker with other Discover tests.
282+ t.Parallel()
289
283
290 tmpDir := t.TempDir()
284 tmpDir := t.TempDir()
291
285
292− ctx, cancel := context.WithCancel(context.Background())
293− defer cancel()
294− ch := SubscribeEvents(ctx)
295−
296− skills := Discover([]string{tmpDir})
286+ skills, states := DiscoverWithStates([]string{tmpDir})
297−
298− evt := <-ch
299− require.Empty(t, evt.Payload.States)
287+ require.Empty(t, states)
300 require.Empty(t, skills)
288 require.Empty(t, skills)
301}
289}
302
290
303func TestDiscoverMissingPath(t *testing.T) {
291func TestDiscoverMissingPath(t *testing.T) {
304− // Not parallel: shares global broker with other Discover tests.
292+ t.Parallel()
305−
306− ctx, cancel := context.WithCancel(context.Background())
307− defer cancel()
308− ch := SubscribeEvents(ctx)
309−
310− skills := Discover([]string{filepath.Join(t.TempDir(), "missing")})
311
293
312− evt := <-ch
294+ skills, states := DiscoverWithStates([]string{filepath.Join(t.TempDir(), "missing")})
313− require.Empty(t, evt.Payload.States)
295+ require.Empty(t, states)
314 require.Empty(t, skills)
296 require.Empty(t, skills)
315}
297}
316
298
internal/ui/model/ui.go+1
@@ -343,6 +343,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
343 notifyWindowFocused: true,
343 notifyWindowFocused: true,
344 initialSessionID: initialSessionID,
344 initialSessionID: initialSessionID,
345 continueLastSession: continueLast,
345 continueLastSession: continueLast,
346+ skillStates: skills.GetLatestStates(),
346 }
347 }
347
348
348 status := NewStatus(com, ui)
349 status := NewStatus(com, ui)