manager.go

  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}