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}