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