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 // resolvedPaths are the expanded SkillsPaths used during discovery.
31 // Stored so Catalog/ReadContent can label skills without
32 // re-resolving.
33 resolvedPaths []string
34 workingDir string
35
36 broker *pubsub.Broker[Event]
37 globalMirror bool
38}
39
40// ManagerOption configures a Manager at construction time.
41type ManagerOption func(*Manager)
42
43// WithGlobalMirror causes the manager to forward SetLatestStates and
44// PublishStates calls to the package-level cache and broker. Only safe
45// when the process hosts at most one Manager (e.g. local mode or the
46// client process).
47func WithGlobalMirror() ManagerOption {
48 return func(m *Manager) {
49 m.globalMirror = true
50 }
51}
52
53// WithResolvedPaths stores the expanded skills directory paths that
54// were used during discovery. Catalog and ReadContent use these for
55// source labelling.
56func WithResolvedPaths(paths []string) ManagerOption {
57 return func(m *Manager) {
58 m.resolvedPaths = paths
59 }
60}
61
62// WithWorkingDir stores the workspace working directory. Catalog and
63// ReadContent use it to distinguish project skills from user skills.
64func WithWorkingDir(dir string) ManagerOption {
65 return func(m *Manager) {
66 m.workingDir = dir
67 }
68}
69
70// NewManager constructs a workspace-scoped Manager with the given
71// pre-computed discovery results. The slices are stored as-is; callers
72// should not mutate them afterwards.
73func NewManager(allSkills, activeSkills []*Skill, states []*SkillState, opts ...ManagerOption) *Manager {
74 m := &Manager{
75 allSkills: allSkills,
76 activeSkills: activeSkills,
77 states: states,
78 broker: pubsub.NewBroker[Event](),
79 }
80 for _, opt := range opts {
81 opt(m)
82 }
83 if m.globalMirror {
84 SetLatestStates(states)
85 }
86 return m
87}
88
89// AllSkills returns the deduplicated list of all discovered skills.
90func (m *Manager) AllSkills() []*Skill {
91 m.mu.RLock()
92 defer m.mu.RUnlock()
93 return m.allSkills
94}
95
96// ActiveSkills returns the post-filter list of active skills (after
97// removing disabled entries).
98func (m *Manager) ActiveSkills() []*Skill {
99 m.mu.RLock()
100 defer m.mu.RUnlock()
101 return m.activeSkills
102}
103
104// ResolvedPaths returns the expanded skills directory paths stored at
105// construction time.
106func (m *Manager) ResolvedPaths() []string {
107 return m.resolvedPaths
108}
109
110// WorkingDir returns the workspace working directory stored at
111// construction time.
112func (m *Manager) WorkingDir() string {
113 return m.workingDir
114}
115
116// States returns a clone of the latest discovery state snapshot.
117func (m *Manager) States() []*SkillState {
118 m.mu.RLock()
119 defer m.mu.RUnlock()
120 return cloneStates(m.states)
121}
122
123// SetLatestStates updates the manager's cached discovery snapshot.
124func (m *Manager) SetLatestStates(states []*SkillState) {
125 m.mu.Lock()
126 m.states = cloneStates(states)
127 m.mu.Unlock()
128 if m.globalMirror {
129 SetLatestStates(states)
130 }
131}
132
133// PublishStates updates the manager's cached snapshot and publishes a
134// discovery event to subscribers. Callers should not call
135// SetLatestStates separately — PublishStates is the single mutation
136// point, keeping Manager.States(), workspaceToProto, and (when
137// WithGlobalMirror is set) skills.GetLatestStates consistent with what
138// subscribers observe.
139func (m *Manager) PublishStates(states []*SkillState) {
140 m.mu.Lock()
141 m.states = cloneStates(states)
142 m.mu.Unlock()
143 if m.globalMirror {
144 SetLatestStates(states)
145 }
146 m.broker.Publish(pubsub.UpdatedEvent, Event{States: cloneStates(states)})
147 if m.globalMirror {
148 PublishStates(states)
149 }
150}
151
152// SubscribeEvents returns a channel of discovery events for the
153// manager's workspace.
154func (m *Manager) SubscribeEvents(ctx context.Context) <-chan pubsub.Event[Event] {
155 return m.broker.Subscribe(ctx)
156}
157
158// Shutdown releases broker resources.
159func (m *Manager) Shutdown() {
160 if m.broker != nil {
161 m.broker.Shutdown()
162 }
163}
164
165// DiscoverFromConfig walks the embedded builtin FS and every path in
166// cfg.Options.SkillsPaths (after home / env expansion), then dedups and
167// filters by cfg.Options.DisabledSkills. It returns the three slices the
168// rest of the system needs:
169//
170// - allSkills: deduplicated, pre-filter (includes disabled).
171// - activeSkills: post-filter (DisabledSkills removed).
172// - states: per-file discovery outcome for diagnostics/UI.
173func DiscoverFromConfig(cfg DiscoveryConfig) (allSkills, activeSkills []*Skill, states []*SkillState) {
174 builtin, builtinStates := DiscoverBuiltinWithStates()
175 discovered := append([]*Skill(nil), builtin...)
176
177 var userStates []*SkillState
178 userPaths := cfg.ResolvePaths()
179 if len(userPaths) > 0 {
180 var userSkills []*Skill
181 userSkills, userStates = DiscoverWithStates(userPaths)
182 discovered = append(discovered, userSkills...)
183 }
184
185 allSkills = Deduplicate(discovered)
186 activeSkills = Filter(allSkills, cfg.DisabledSkills)
187
188 allStates := append([]*SkillState(nil), builtinStates...)
189 allStates = append(allStates, userStates...)
190 allStates = DeduplicateStates(allStates)
191 slices.SortStableFunc(allStates, func(a, b *SkillState) int {
192 return strings.Compare(strings.ToLower(a.Path), strings.ToLower(b.Path))
193 })
194 return allSkills, activeSkills, allStates
195}
196
197// DiscoveryConfig contains the inputs DiscoverFromConfig needs. Using a
198// dedicated struct (rather than importing internal/config) keeps the
199// skills package's dependency graph small.
200type DiscoveryConfig struct {
201 SkillsPaths []string
202 DisabledSkills []string
203 WorkingDir string
204 // Resolver expands $VAR-style references in paths. May be nil.
205 Resolver func(string) (string, error)
206}
207
208// ResolvePaths expands home-directory and $VAR references in
209// SkillsPaths. This is the canonical path-resolution logic used by
210// DiscoverFromConfig; callers that need the resolved list (e.g. for
211// Catalog labels) can call this directly.
212func (c DiscoveryConfig) ResolvePaths() []string {
213 if len(c.SkillsPaths) == 0 {
214 return nil
215 }
216 out := make([]string, 0, len(c.SkillsPaths))
217 for _, pth := range c.SkillsPaths {
218 expanded := home.Long(pth)
219 if strings.HasPrefix(expanded, "$") && c.Resolver != nil {
220 if resolved, err := c.Resolver(expanded); err == nil {
221 expanded = resolved
222 }
223 }
224 out = append(out, expanded)
225 }
226 return out
227}