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