Merge branch 'main' into fast-ui

Christian Rocha created

Change summary

.github/cla-signatures.json         |  8 +++
README.md                           |  2 
internal/agent/coordinator.go       | 11 ++++
internal/agent/hyper/provider.json  | 16 ++++++
internal/config/load.go             |  3 +
internal/skills/diagnostics_test.go | 42 ++++++++++++++++++
internal/skills/skills.go           | 71 +++++++++++++++++++++++++++---
internal/skills/skills_test.go      | 36 +++-----------
internal/ui/model/ui.go             |  1 
9 files changed, 153 insertions(+), 37 deletions(-)

Detailed changes

.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
     }
   ]
 }

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/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",

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,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.

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

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)