1package skills
2
3import (
4 "context"
5 "slices"
6 "strings"
7 "sync"
8
9 "github.com/charmbracelet/crush/internal/home"
10 "github.com/charmbracelet/crush/internal/pubsub"
11)
12
13// Manager owns per-workspace skill discovery state: the latest discovery
14// snapshot, the full skill metadata (with Instructions) for the
15// coordinator, and a pubsub broker for change events. There is exactly
16// one Manager per workspace.
17//
18// Package-level helpers (GetLatestStates, SetLatestStates,
19// PublishStates, SubscribeEvents) are preserved for callers that share a
20// process with the TUI. To bridge a Manager to those globals, construct
21// it with WithGlobalMirror. Only do this when the process hosts a single
22// workspace (local mode or a client process); the backend server hosts
23// multiple workspaces concurrently and must not enable mirroring.
24type Manager struct {
25 mu sync.RWMutex
26 allSkills []*Skill
27 activeSkills []*Skill
28 states []*SkillState
29
30 broker *pubsub.Broker[Event]
31 globalMirror bool
32}
33
34// ManagerOption configures a Manager at construction time.
35type ManagerOption func(*Manager)
36
37// WithGlobalMirror causes the manager to forward SetLatestStates and
38// PublishStates calls to the package-level cache and broker. Only safe
39// when the process hosts at most one Manager (e.g. local mode or the
40// client process).
41func WithGlobalMirror() ManagerOption {
42 return func(m *Manager) {
43 m.globalMirror = true
44 }
45}
46
47// NewManager constructs a workspace-scoped Manager with the given
48// pre-computed discovery results. The slices are stored as-is; callers
49// should not mutate them afterwards.
50func NewManager(allSkills, activeSkills []*Skill, states []*SkillState, opts ...ManagerOption) *Manager {
51 m := &Manager{
52 allSkills: allSkills,
53 activeSkills: activeSkills,
54 states: states,
55 broker: pubsub.NewBroker[Event](),
56 }
57 for _, opt := range opts {
58 opt(m)
59 }
60 if m.globalMirror {
61 SetLatestStates(states)
62 }
63 return m
64}
65
66// AllSkills returns the deduplicated list of all discovered skills.
67func (m *Manager) AllSkills() []*Skill {
68 m.mu.RLock()
69 defer m.mu.RUnlock()
70 return m.allSkills
71}
72
73// ActiveSkills returns the post-filter list of active skills (after
74// removing disabled entries).
75func (m *Manager) ActiveSkills() []*Skill {
76 m.mu.RLock()
77 defer m.mu.RUnlock()
78 return m.activeSkills
79}
80
81// States returns a clone of the latest discovery state snapshot.
82func (m *Manager) States() []*SkillState {
83 m.mu.RLock()
84 defer m.mu.RUnlock()
85 return cloneStates(m.states)
86}
87
88// SetLatestStates updates the manager's cached discovery snapshot.
89func (m *Manager) SetLatestStates(states []*SkillState) {
90 m.mu.Lock()
91 m.states = cloneStates(states)
92 m.mu.Unlock()
93 if m.globalMirror {
94 SetLatestStates(states)
95 }
96}
97
98// PublishStates updates the manager's cached snapshot and publishes a
99// discovery event to subscribers. Callers should not call
100// SetLatestStates separately — PublishStates is the single mutation
101// point, keeping Manager.States(), workspaceToProto, and (when
102// WithGlobalMirror is set) skills.GetLatestStates consistent with what
103// subscribers observe.
104func (m *Manager) PublishStates(states []*SkillState) {
105 m.mu.Lock()
106 m.states = cloneStates(states)
107 m.mu.Unlock()
108 if m.globalMirror {
109 SetLatestStates(states)
110 }
111 m.broker.Publish(pubsub.UpdatedEvent, Event{States: cloneStates(states)})
112 if m.globalMirror {
113 PublishStates(states)
114 }
115}
116
117// SubscribeEvents returns a channel of discovery events for the
118// manager's workspace.
119func (m *Manager) SubscribeEvents(ctx context.Context) <-chan pubsub.Event[Event] {
120 return m.broker.Subscribe(ctx)
121}
122
123// Shutdown releases broker resources.
124func (m *Manager) Shutdown() {
125 if m.broker != nil {
126 m.broker.Shutdown()
127 }
128}
129
130// DiscoverFromConfig walks the embedded builtin FS and every path in
131// cfg.Options.SkillsPaths (after home / env expansion), then dedups and
132// filters by cfg.Options.DisabledSkills. It returns the three slices the
133// rest of the system needs:
134//
135// - allSkills: deduplicated, pre-filter (includes disabled).
136// - activeSkills: post-filter (DisabledSkills removed).
137// - states: per-file discovery outcome for diagnostics/UI.
138func DiscoverFromConfig(cfg DiscoveryConfig) (allSkills, activeSkills []*Skill, states []*SkillState) {
139 builtin, builtinStates := DiscoverBuiltinWithStates()
140 discovered := append([]*Skill(nil), builtin...)
141
142 var userStates []*SkillState
143 var userPaths []string
144 if len(cfg.SkillsPaths) > 0 {
145 userPaths = make([]string, 0, len(cfg.SkillsPaths))
146 for _, pth := range cfg.SkillsPaths {
147 expanded := home.Long(pth)
148 if strings.HasPrefix(expanded, "$") && cfg.Resolver != nil {
149 if resolved, err := cfg.Resolver(expanded); err == nil {
150 expanded = resolved
151 }
152 }
153 userPaths = append(userPaths, expanded)
154 }
155 var userSkills []*Skill
156 userSkills, userStates = DiscoverWithStates(userPaths)
157 discovered = append(discovered, userSkills...)
158 }
159
160 allSkills = Deduplicate(discovered)
161 activeSkills = Filter(allSkills, cfg.DisabledSkills)
162
163 allStates := append([]*SkillState(nil), builtinStates...)
164 allStates = append(allStates, userStates...)
165 allStates = DeduplicateStates(allStates)
166 slices.SortStableFunc(allStates, func(a, b *SkillState) int {
167 return strings.Compare(strings.ToLower(a.Path), strings.ToLower(b.Path))
168 })
169 return allSkills, activeSkills, allStates
170}
171
172// DiscoveryConfig contains the inputs DiscoverFromConfig needs. Using a
173// dedicated struct (rather than importing internal/config) keeps the
174// skills package's dependency graph small.
175type DiscoveryConfig struct {
176 SkillsPaths []string
177 DisabledSkills []string
178 // Resolver expands $VAR-style references in paths. May be nil.
179 Resolver func(string) (string, error)
180}