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