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}