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 fmt.Fprintf(b, "loaded_this_session = %d/%d\n", tracker.LoadedCount(), len(activeSkills))
341 for _, e := range entries {
342 fmt.Fprintf(b, "%s = %s, %s\n", e.name, e.origin, e.state)
343 }
344 b.WriteString("\n")
345}
346
347func writePermissions(b *strings.Builder, cfg *config.ConfigStore) {
348 c := cfg.Config()
349 overrides := cfg.Overrides()
350
351 if c.Permissions == nil {
352 if !overrides.SkipPermissionRequests {
353 return
354 }
355 } else if !overrides.SkipPermissionRequests && len(c.Permissions.AllowedTools) == 0 {
356 return
357 }
358 b.WriteString("[permissions]\n")
359 if overrides.SkipPermissionRequests {
360 b.WriteString("mode = yolo\n")
361 }
362 if c.Permissions != nil && len(c.Permissions.AllowedTools) > 0 {
363 sorted := slices.Clone(c.Permissions.AllowedTools)
364 slices.Sort(sorted)
365 fmt.Fprintf(b, "allowed_tools = %s\n", strings.Join(sorted, ", "))
366 }
367 b.WriteString("\n")
368}
369
370func writeDisabledTools(b *strings.Builder, cfg *config.ConfigStore) {
371 c := cfg.Config()
372 if c.Options == nil || len(c.Options.DisabledTools) == 0 {
373 return
374 }
375 sorted := slices.Clone(c.Options.DisabledTools)
376 slices.Sort(sorted)
377 b.WriteString("[tools]\n")
378 fmt.Fprintf(b, "disabled = %s\n", strings.Join(sorted, ", "))
379 b.WriteString("\n")
380}
381
382func writeOptions(b *strings.Builder, cfg *config.ConfigStore) {
383 c := cfg.Config()
384 if c.Options == nil {
385 return
386 }
387 type kv struct {
388 key string
389 value string
390 }
391 var opts []kv
392
393 opts = append(opts, kv{"data_directory", c.Options.DataDirectory})
394 opts = append(opts, kv{"debug", fmt.Sprintf("%v", c.Options.Debug)})
395 autoLSP := c.Options.AutoLSP == nil || *c.Options.AutoLSP
396 opts = append(opts, kv{"auto_lsp", fmt.Sprintf("%v", autoLSP)})
397 autoSummarize := !c.Options.DisableAutoSummarize
398 opts = append(opts, kv{"auto_summarize", fmt.Sprintf("%v", autoSummarize)})
399
400 slices.SortFunc(opts, func(a, b kv) int { return strings.Compare(a.key, b.key) })
401 b.WriteString("[options]\n")
402 for _, o := range opts {
403 fmt.Fprintf(b, "%s = %s\n", o.key, o.value)
404 }
405 b.WriteString("\n")
406}
407
408func writeAttribution(b *strings.Builder, cfg *config.ConfigStore) {
409 c := cfg.Config()
410 if c.Options == nil || c.Options.Attribution == nil {
411 return
412 }
413 b.WriteString("[attribution]\n")
414 trailerStyle := c.Options.Attribution.TrailerStyle
415 if trailerStyle == "" {
416 trailerStyle = config.TrailerStyleCoAuthoredBy
417 }
418 fmt.Fprintf(b, "trailer_style = %s\n", trailerStyle)
419 fmt.Fprintf(b, "generated_with = %v\n", c.Options.Attribution.GeneratedWith)
420 b.WriteString("\n")
421}
422
423func lspStateString(state lsp.ServerState) string {
424 switch state {
425 case lsp.StateUnstarted:
426 return "unstarted"
427 case lsp.StateStarting:
428 return "starting"
429 case lsp.StateReady:
430 return "ready"
431 case lsp.StateError:
432 return "error"
433 case lsp.StateStopped:
434 return "stopped"
435 case lsp.StateDisabled:
436 return "disabled"
437 default:
438 return "unknown"
439 }
440}