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