From 62ee620962d7c39231e51b9c9c5e8fd3980cb3b5 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 14 May 2026 13:56:37 -0300 Subject: [PATCH 1/4] chore(legal): @taciturnaxolotl has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) 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 From 8e7451fc1e2c44523b5a52a288738839507fb9e4 Mon Sep 17 00:00:00 2001 From: Ilgaz Date: Wed, 6 May 2026 22:57:30 +0300 Subject: [PATCH 2/4] fix(ui): show custom skills reliably on startup Resolve race condition by implementing a package-level cache for skill discovery states. Add ~/.agents/skills/ and ~/.claude/skills/ as global skill scan paths. --- README.md | 2 + internal/agent/coordinator.go | 11 +++++ internal/config/load.go | 3 ++ internal/skills/diagnostics_test.go | 42 +++++++++++++++++ internal/skills/skills.go | 71 ++++++++++++++++++++++++++--- internal/ui/model/ui.go | 1 + 6 files changed, 123 insertions(+), 7 deletions(-) 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) From b8989b964abf98e0e09b4c4c2f2af49ccc608f80 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 11 May 2026 10:42:43 -0400 Subject: [PATCH 3/4] refactor(skills): make coordinator the sole skill discovery publisher Remove the auto-publish from DiscoverWithStates so that discoverSkills in the coordinator is the only code path that emits skill discovery events. This eliminates the double-publish where DiscoverWithStates would emit user-only states, followed by the coordinator emitting the merged builtin+user states. Update the Discover tests to call DiscoverWithStates directly and assert on the returned states slice, rather than subscribing to the pubsub broker. This also lets those tests run in parallel. --- internal/skills/skills.go | 2 -- internal/skills/skills_test.go | 36 +++++++++------------------------- 2 files changed, 9 insertions(+), 29 deletions(-) diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 4b9d32d69038c11a315c54e41bd0ce01ac87391f..7071ba61820158a5324a48fbb5faf5cc89bd5f38 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -289,8 +289,6 @@ func DiscoverWithStates(paths []string) ([]*Skill, []*SkillState) { 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 } 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) } From 3413970fee2b99ae6915c20f26fe50de72484b47 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 14 May 2026 18:02:01 +0000 Subject: [PATCH 4/4] chore: auto-update files --- internal/agent/hyper/provider.json | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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",