crush_info.go

  1package tools
  2
  3import (
  4	"context"
  5	_ "embed"
  6	"fmt"
  7	"slices"
  8	"strings"
  9
 10	"charm.land/fantasy"
 11	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 12	"github.com/charmbracelet/crush/internal/config"
 13	"github.com/charmbracelet/crush/internal/lsp"
 14	"github.com/charmbracelet/crush/internal/skills"
 15)
 16
 17const CrushInfoToolName = "crush_info"
 18
 19//go:embed crush_info.md
 20var crushInfoDescription []byte
 21
 22type CrushInfoParams struct{}
 23
 24func NewCrushInfoTool(
 25	cfg *config.ConfigStore,
 26	lspManager *lsp.Manager,
 27	allSkills []*skills.Skill,
 28	activeSkills []*skills.Skill,
 29	skillTracker *skills.Tracker,
 30) fantasy.AgentTool {
 31	return fantasy.NewAgentTool(
 32		CrushInfoToolName,
 33		string(crushInfoDescription),
 34		func(ctx context.Context, _ CrushInfoParams, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
 35			return fantasy.NewTextResponse(buildCrushInfo(cfg, lspManager, allSkills, activeSkills, skillTracker)), nil
 36		})
 37}
 38
 39func buildCrushInfo(cfg *config.ConfigStore, lspManager *lsp.Manager, allSkills []*skills.Skill, activeSkills []*skills.Skill, skillTracker *skills.Tracker) string {
 40	var b strings.Builder
 41
 42	writeConfigFiles(&b, cfg)
 43	writeConfigStaleness(&b, cfg)
 44	writeModels(&b, cfg)
 45	writeProviders(&b, cfg)
 46	writeLSP(&b, lspManager, cfg)
 47	writeMCP(&b, mcp.GetStates(), cfg)
 48	writeSkills(&b, allSkills, activeSkills, skillTracker, cfg)
 49	writePermissions(&b, cfg)
 50	writeDisabledTools(&b, cfg)
 51	writeOptions(&b, cfg)
 52
 53	return b.String()
 54}
 55
 56func writeConfigFiles(b *strings.Builder, cfg *config.ConfigStore) {
 57	b.WriteString("[config_files]\n")
 58	paths := cfg.LoadedPaths()
 59	for _, p := range paths {
 60		b.WriteString(p + "\n")
 61	}
 62	b.WriteString("\n")
 63}
 64
 65func writeConfigStaleness(b *strings.Builder, cfg *config.ConfigStore) {
 66	staleness := cfg.ConfigStaleness()
 67
 68	b.WriteString("[config]\n")
 69	fmt.Fprintf(b, "dirty = %v\n", staleness.Dirty)
 70
 71	if len(staleness.Changed) > 0 {
 72		sorted := slices.Clone(staleness.Changed)
 73		slices.Sort(sorted)
 74		fmt.Fprintf(b, "changed_paths = %s\n", strings.Join(sorted, ", "))
 75	}
 76
 77	if len(staleness.Missing) > 0 {
 78		sorted := slices.Clone(staleness.Missing)
 79		slices.Sort(sorted)
 80		fmt.Fprintf(b, "missing_paths = %s\n", strings.Join(sorted, ", "))
 81	}
 82
 83	if len(staleness.Errors) > 0 {
 84		var paths []string
 85		for path := range staleness.Errors {
 86			paths = append(paths, path)
 87		}
 88		slices.Sort(paths)
 89		fmt.Fprintf(b, "errors = %s\n", strings.Join(paths, ", "))
 90	}
 91
 92	b.WriteString("\n")
 93}
 94
 95func writeModels(b *strings.Builder, cfg *config.ConfigStore) {
 96	c := cfg.Config()
 97	if len(c.Models) == 0 {
 98		return
 99	}
100	b.WriteString("[model]\n")
101	for _, typ := range []config.SelectedModelType{config.SelectedModelTypeLarge, config.SelectedModelTypeSmall} {
102		m, ok := c.Models[typ]
103		if !ok {
104			continue
105		}
106		fmt.Fprintf(b, "%s = %s (%s)\n", typ, m.Model, m.Provider)
107	}
108	b.WriteString("\n")
109}
110
111func writeProviders(b *strings.Builder, cfg *config.ConfigStore) {
112	c := cfg.Config()
113	type pv struct {
114		name  string
115		count int
116	}
117	var providers []pv
118	for name, pc := range c.Providers.Seq2() {
119		if pc.Disable {
120			continue
121		}
122		providers = append(providers, pv{name: name, count: len(pc.Models)})
123	}
124	if len(providers) == 0 {
125		return
126	}
127	slices.SortFunc(providers, func(a, b pv) int { return strings.Compare(a.name, b.name) })
128	b.WriteString("[providers]\n")
129	for _, p := range providers {
130		fmt.Fprintf(b, "%s = enabled (%d models)\n", p.name, p.count)
131	}
132	b.WriteString("\n")
133}
134
135func writeLSP(b *strings.Builder, lspManager *lsp.Manager, cfg *config.ConfigStore) {
136	// Write runtime LSP clients
137	if lspManager != nil && lspManager.Clients().Len() > 0 {
138		type entry struct {
139			name      string
140			state     lsp.ServerState
141			fileTypes []string
142		}
143		var entries []entry
144		for name, client := range lspManager.Clients().Seq2() {
145			entries = append(entries, entry{
146				name:      name,
147				state:     client.GetServerState(),
148				fileTypes: client.FileTypes(),
149			})
150		}
151		if len(entries) > 0 {
152			slices.SortFunc(entries, func(a, b entry) int { return strings.Compare(a.name, b.name) })
153			b.WriteString("[lsp]\n")
154			for _, e := range entries {
155				stateStr := lspStateString(e.state)
156				if len(e.fileTypes) > 0 {
157					sorted := slices.Clone(e.fileTypes)
158					slices.Sort(sorted)
159					fmt.Fprintf(b, "%s = %s (%s)\n", e.name, stateStr, strings.Join(sorted, ", "))
160				} else {
161					fmt.Fprintf(b, "%s = %s\n", e.name, stateStr)
162				}
163			}
164			b.WriteString("\n")
165		}
166	}
167
168	// Write configured but not running LSP servers
169	c := cfg.Config()
170	if len(c.LSP) > 0 {
171		runtimeNames := make(map[string]bool)
172		if lspManager != nil {
173			for name := range lspManager.Clients().Seq2() {
174				runtimeNames[name] = true
175			}
176		}
177
178		type configuredEntry struct {
179			name   string
180			status string
181		}
182		var entries []configuredEntry
183		for name, lspCfg := range c.LSP {
184			// Skip if already in runtime
185			if runtimeNames[name] {
186				continue
187			}
188			status := "not_started"
189			if lspCfg.Disabled {
190				status = "disabled"
191			}
192			entries = append(entries, configuredEntry{name: name, status: status})
193		}
194
195		if len(entries) > 0 {
196			slices.SortFunc(entries, func(a, b configuredEntry) int { return strings.Compare(a.name, b.name) })
197			b.WriteString("[lsp_configured]\n")
198			for _, e := range entries {
199				fmt.Fprintf(b, "%s = %s\n", e.name, e.status)
200			}
201			b.WriteString("\n")
202		}
203	}
204}
205
206func writeMCP(b *strings.Builder, states map[string]mcp.ClientInfo, cfg *config.ConfigStore) {
207	// Write runtime MCP states
208	if len(states) > 0 {
209		type entry struct {
210			name        string
211			state       mcp.State
212			err         error
213			tools       int
214			resources   int
215			connectedAt string
216		}
217		var entries []entry
218		for name, info := range states {
219			e := entry{
220				name:  name,
221				state: info.State,
222				err:   info.Error,
223			}
224			if info.State == mcp.StateConnected {
225				e.tools = info.Counts.Tools
226				e.resources = info.Counts.Resources
227				if !info.ConnectedAt.IsZero() {
228					e.connectedAt = info.ConnectedAt.Format("15:04:05")
229				}
230			}
231			entries = append(entries, e)
232		}
233		slices.SortFunc(entries, func(a, b entry) int { return strings.Compare(a.name, b.name) })
234		b.WriteString("[mcp]\n")
235		for _, e := range entries {
236			switch e.state {
237			case mcp.StateConnected:
238				if e.connectedAt != "" {
239					fmt.Fprintf(b, "%s = connected (%d tools, %d resources) since %s\n", e.name, e.tools, e.resources, e.connectedAt)
240				} else {
241					fmt.Fprintf(b, "%s = connected (%d tools, %d resources)\n", e.name, e.tools, e.resources)
242				}
243			case mcp.StateError:
244				if e.err != nil {
245					fmt.Fprintf(b, "%s = error: %s\n", e.name, e.err.Error())
246				} else {
247					fmt.Fprintf(b, "%s = error\n", e.name)
248				}
249			default:
250				fmt.Fprintf(b, "%s = %s\n", e.name, e.state)
251			}
252		}
253		b.WriteString("\n")
254	}
255
256	// Write configured but not running MCP servers
257	c := cfg.Config()
258	if len(c.MCP) > 0 {
259		runtimeNames := make(map[string]bool)
260		for name := range states {
261			runtimeNames[name] = true
262		}
263
264		type configuredEntry struct {
265			name   string
266			status string
267		}
268		var entries []configuredEntry
269		for name, mcpCfg := range c.MCP {
270			// Skip if already in runtime
271			if runtimeNames[name] {
272				continue
273			}
274			status := "not_started"
275			if mcpCfg.Disabled {
276				status = "disabled"
277			}
278			entries = append(entries, configuredEntry{name: name, status: status})
279		}
280
281		if len(entries) > 0 {
282			slices.SortFunc(entries, func(a, b configuredEntry) int { return strings.Compare(a.name, b.name) })
283			b.WriteString("[mcp_configured]\n")
284			for _, e := range entries {
285				fmt.Fprintf(b, "%s = %s\n", e.name, e.status)
286			}
287			b.WriteString("\n")
288		}
289	}
290}
291
292func writeSkills(b *strings.Builder, allSkills []*skills.Skill, activeSkills []*skills.Skill, tracker *skills.Tracker, cfg *config.ConfigStore) {
293	var disabled []string
294	if cfg.Config().Options != nil {
295		disabled = cfg.Config().Options.DisabledSkills
296	}
297	if len(activeSkills) == 0 && len(disabled) == 0 {
298		return
299	}
300
301	// Build origin map from the pre-filter list.
302	originMap := make(map[string]string, len(allSkills))
303	for _, s := range allSkills {
304		if s.Builtin {
305			originMap[s.Name] = "builtin"
306		} else {
307			originMap[s.Name] = "user"
308		}
309	}
310
311	type entry struct {
312		name   string
313		origin string
314		state  string
315	}
316	var entries []entry
317
318	// Active skills: loaded or unloaded.
319	for _, s := range activeSkills {
320		state := "unloaded"
321		if tracker.IsLoaded(s.Name) {
322			state = "loaded"
323		}
324		origin := originMap[s.Name]
325		entries = append(entries, entry{name: s.Name, origin: origin, state: state})
326	}
327
328	// Disabled skills.
329	for _, name := range disabled {
330		origin := originMap[name]
331		if origin == "" {
332			origin = "user"
333		}
334		entries = append(entries, entry{name: name, origin: origin, state: "disabled"})
335	}
336
337	slices.SortFunc(entries, func(a, b entry) int { return strings.Compare(a.name, b.name) })
338	b.WriteString("[skills]\n")
339	for _, e := range entries {
340		fmt.Fprintf(b, "%s = %s, %s\n", e.name, e.origin, e.state)
341	}
342	b.WriteString("\n")
343}
344
345func writePermissions(b *strings.Builder, cfg *config.ConfigStore) {
346	c := cfg.Config()
347	overrides := cfg.Overrides()
348
349	if c.Permissions == nil {
350		if !overrides.SkipPermissionRequests {
351			return
352		}
353	} else if !overrides.SkipPermissionRequests && len(c.Permissions.AllowedTools) == 0 {
354		return
355	}
356	b.WriteString("[permissions]\n")
357	if overrides.SkipPermissionRequests {
358		b.WriteString("mode = yolo\n")
359	}
360	if c.Permissions != nil && len(c.Permissions.AllowedTools) > 0 {
361		sorted := slices.Clone(c.Permissions.AllowedTools)
362		slices.Sort(sorted)
363		fmt.Fprintf(b, "allowed_tools = %s\n", strings.Join(sorted, ", "))
364	}
365	b.WriteString("\n")
366}
367
368func writeDisabledTools(b *strings.Builder, cfg *config.ConfigStore) {
369	c := cfg.Config()
370	if c.Options == nil || len(c.Options.DisabledTools) == 0 {
371		return
372	}
373	sorted := slices.Clone(c.Options.DisabledTools)
374	slices.Sort(sorted)
375	b.WriteString("[tools]\n")
376	fmt.Fprintf(b, "disabled = %s\n", strings.Join(sorted, ", "))
377	b.WriteString("\n")
378}
379
380func writeOptions(b *strings.Builder, cfg *config.ConfigStore) {
381	c := cfg.Config()
382	if c.Options == nil {
383		return
384	}
385	type kv struct {
386		key   string
387		value string
388	}
389	var opts []kv
390
391	opts = append(opts, kv{"data_directory", c.Options.DataDirectory})
392	opts = append(opts, kv{"debug", fmt.Sprintf("%v", c.Options.Debug)})
393	autoLSP := c.Options.AutoLSP == nil || *c.Options.AutoLSP
394	opts = append(opts, kv{"auto_lsp", fmt.Sprintf("%v", autoLSP)})
395	autoSummarize := !c.Options.DisableAutoSummarize
396	opts = append(opts, kv{"auto_summarize", fmt.Sprintf("%v", autoSummarize)})
397
398	slices.SortFunc(opts, func(a, b kv) int { return strings.Compare(a.key, b.key) })
399	b.WriteString("[options]\n")
400	for _, o := range opts {
401		fmt.Fprintf(b, "%s = %s\n", o.key, o.value)
402	}
403	b.WriteString("\n")
404}
405
406func lspStateString(state lsp.ServerState) string {
407	switch state {
408	case lsp.StateUnstarted:
409		return "unstarted"
410	case lsp.StateStarting:
411		return "starting"
412	case lsp.StateReady:
413		return "ready"
414	case lsp.StateError:
415		return "error"
416	case lsp.StateStopped:
417		return "stopped"
418	case lsp.StateDisabled:
419		return "disabled"
420	default:
421		return "unknown"
422	}
423}