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)
15
16const CrushInfoToolName = "crush_info"
17
18//go:embed crush_info.md
19var crushInfoDescription []byte
20
21type CrushInfoParams struct{}
22
23func NewCrushInfoTool(
24 cfg *config.ConfigStore,
25 lspManager *lsp.Manager,
26) fantasy.AgentTool {
27 return fantasy.NewAgentTool(
28 CrushInfoToolName,
29 string(crushInfoDescription),
30 func(ctx context.Context, _ CrushInfoParams, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
31 return fantasy.NewTextResponse(buildCrushInfo(cfg, lspManager)), nil
32 })
33}
34
35func buildCrushInfo(cfg *config.ConfigStore, lspManager *lsp.Manager) string {
36 var b strings.Builder
37
38 writeConfigFiles(&b, cfg)
39 writeConfigStaleness(&b, cfg)
40 writeModels(&b, cfg)
41 writeProviders(&b, cfg)
42 writeLSP(&b, lspManager, cfg)
43 writeMCP(&b, mcp.GetStates(), cfg)
44 writePermissions(&b, cfg)
45 writeDisabledTools(&b, cfg)
46 writeOptions(&b, cfg)
47
48 return b.String()
49}
50
51func writeConfigFiles(b *strings.Builder, cfg *config.ConfigStore) {
52 b.WriteString("[config_files]\n")
53 paths := cfg.LoadedPaths()
54 for _, p := range paths {
55 b.WriteString(p + "\n")
56 }
57 b.WriteString("\n")
58}
59
60func writeConfigStaleness(b *strings.Builder, cfg *config.ConfigStore) {
61 staleness := cfg.ConfigStaleness()
62
63 b.WriteString("[config]\n")
64 fmt.Fprintf(b, "dirty = %v\n", staleness.Dirty)
65
66 if len(staleness.Changed) > 0 {
67 sorted := slices.Clone(staleness.Changed)
68 slices.Sort(sorted)
69 fmt.Fprintf(b, "changed_paths = %s\n", strings.Join(sorted, ", "))
70 }
71
72 if len(staleness.Missing) > 0 {
73 sorted := slices.Clone(staleness.Missing)
74 slices.Sort(sorted)
75 fmt.Fprintf(b, "missing_paths = %s\n", strings.Join(sorted, ", "))
76 }
77
78 if len(staleness.Errors) > 0 {
79 var paths []string
80 for path := range staleness.Errors {
81 paths = append(paths, path)
82 }
83 slices.Sort(paths)
84 fmt.Fprintf(b, "errors = %s\n", strings.Join(paths, ", "))
85 }
86
87 b.WriteString("\n")
88}
89
90func writeModels(b *strings.Builder, cfg *config.ConfigStore) {
91 c := cfg.Config()
92 if len(c.Models) == 0 {
93 return
94 }
95 b.WriteString("[model]\n")
96 for _, typ := range []config.SelectedModelType{config.SelectedModelTypeLarge, config.SelectedModelTypeSmall} {
97 m, ok := c.Models[typ]
98 if !ok {
99 continue
100 }
101 fmt.Fprintf(b, "%s = %s (%s)\n", typ, m.Model, m.Provider)
102 }
103 b.WriteString("\n")
104}
105
106func writeProviders(b *strings.Builder, cfg *config.ConfigStore) {
107 c := cfg.Config()
108 type pv struct {
109 name string
110 count int
111 }
112 var providers []pv
113 for name, pc := range c.Providers.Seq2() {
114 if pc.Disable {
115 continue
116 }
117 providers = append(providers, pv{name: name, count: len(pc.Models)})
118 }
119 if len(providers) == 0 {
120 return
121 }
122 slices.SortFunc(providers, func(a, b pv) int { return strings.Compare(a.name, b.name) })
123 b.WriteString("[providers]\n")
124 for _, p := range providers {
125 fmt.Fprintf(b, "%s = enabled (%d models)\n", p.name, p.count)
126 }
127 b.WriteString("\n")
128}
129
130func writeLSP(b *strings.Builder, lspManager *lsp.Manager, cfg *config.ConfigStore) {
131 // Write runtime LSP clients
132 if lspManager != nil && lspManager.Clients().Len() > 0 {
133 type entry struct {
134 name string
135 state lsp.ServerState
136 fileTypes []string
137 }
138 var entries []entry
139 for name, client := range lspManager.Clients().Seq2() {
140 entries = append(entries, entry{
141 name: name,
142 state: client.GetServerState(),
143 fileTypes: client.FileTypes(),
144 })
145 }
146 if len(entries) > 0 {
147 slices.SortFunc(entries, func(a, b entry) int { return strings.Compare(a.name, b.name) })
148 b.WriteString("[lsp]\n")
149 for _, e := range entries {
150 stateStr := lspStateString(e.state)
151 if len(e.fileTypes) > 0 {
152 sorted := slices.Clone(e.fileTypes)
153 slices.Sort(sorted)
154 fmt.Fprintf(b, "%s = %s (%s)\n", e.name, stateStr, strings.Join(sorted, ", "))
155 } else {
156 fmt.Fprintf(b, "%s = %s\n", e.name, stateStr)
157 }
158 }
159 b.WriteString("\n")
160 }
161 }
162
163 // Write configured but not running LSP servers
164 c := cfg.Config()
165 if len(c.LSP) > 0 {
166 runtimeNames := make(map[string]bool)
167 if lspManager != nil {
168 for name := range lspManager.Clients().Seq2() {
169 runtimeNames[name] = true
170 }
171 }
172
173 type configuredEntry struct {
174 name string
175 status string
176 }
177 var entries []configuredEntry
178 for name, lspCfg := range c.LSP {
179 // Skip if already in runtime
180 if runtimeNames[name] {
181 continue
182 }
183 status := "not_started"
184 if lspCfg.Disabled {
185 status = "disabled"
186 }
187 entries = append(entries, configuredEntry{name: name, status: status})
188 }
189
190 if len(entries) > 0 {
191 slices.SortFunc(entries, func(a, b configuredEntry) int { return strings.Compare(a.name, b.name) })
192 b.WriteString("[lsp_configured]\n")
193 for _, e := range entries {
194 fmt.Fprintf(b, "%s = %s\n", e.name, e.status)
195 }
196 b.WriteString("\n")
197 }
198 }
199}
200
201func writeMCP(b *strings.Builder, states map[string]mcp.ClientInfo, cfg *config.ConfigStore) {
202 // Write runtime MCP states
203 if len(states) > 0 {
204 type entry struct {
205 name string
206 state mcp.State
207 err error
208 tools int
209 resources int
210 connectedAt string
211 }
212 var entries []entry
213 for name, info := range states {
214 e := entry{
215 name: name,
216 state: info.State,
217 err: info.Error,
218 }
219 if info.State == mcp.StateConnected {
220 e.tools = info.Counts.Tools
221 e.resources = info.Counts.Resources
222 if !info.ConnectedAt.IsZero() {
223 e.connectedAt = info.ConnectedAt.Format("15:04:05")
224 }
225 }
226 entries = append(entries, e)
227 }
228 slices.SortFunc(entries, func(a, b entry) int { return strings.Compare(a.name, b.name) })
229 b.WriteString("[mcp]\n")
230 for _, e := range entries {
231 switch e.state {
232 case mcp.StateConnected:
233 if e.connectedAt != "" {
234 fmt.Fprintf(b, "%s = connected (%d tools, %d resources) since %s\n", e.name, e.tools, e.resources, e.connectedAt)
235 } else {
236 fmt.Fprintf(b, "%s = connected (%d tools, %d resources)\n", e.name, e.tools, e.resources)
237 }
238 case mcp.StateError:
239 if e.err != nil {
240 fmt.Fprintf(b, "%s = error: %s\n", e.name, e.err.Error())
241 } else {
242 fmt.Fprintf(b, "%s = error\n", e.name)
243 }
244 default:
245 fmt.Fprintf(b, "%s = %s\n", e.name, e.state)
246 }
247 }
248 b.WriteString("\n")
249 }
250
251 // Write configured but not running MCP servers
252 c := cfg.Config()
253 if len(c.MCP) > 0 {
254 runtimeNames := make(map[string]bool)
255 for name := range states {
256 runtimeNames[name] = true
257 }
258
259 type configuredEntry struct {
260 name string
261 status string
262 }
263 var entries []configuredEntry
264 for name, mcpCfg := range c.MCP {
265 // Skip if already in runtime
266 if runtimeNames[name] {
267 continue
268 }
269 status := "not_started"
270 if mcpCfg.Disabled {
271 status = "disabled"
272 }
273 entries = append(entries, configuredEntry{name: name, status: status})
274 }
275
276 if len(entries) > 0 {
277 slices.SortFunc(entries, func(a, b configuredEntry) int { return strings.Compare(a.name, b.name) })
278 b.WriteString("[mcp_configured]\n")
279 for _, e := range entries {
280 fmt.Fprintf(b, "%s = %s\n", e.name, e.status)
281 }
282 b.WriteString("\n")
283 }
284 }
285}
286
287func writePermissions(b *strings.Builder, cfg *config.ConfigStore) {
288 c := cfg.Config()
289 overrides := cfg.Overrides()
290
291 if c.Permissions == nil {
292 if !overrides.SkipPermissionRequests {
293 return
294 }
295 } else if !overrides.SkipPermissionRequests && len(c.Permissions.AllowedTools) == 0 {
296 return
297 }
298 b.WriteString("[permissions]\n")
299 if overrides.SkipPermissionRequests {
300 b.WriteString("mode = yolo\n")
301 }
302 if c.Permissions != nil && len(c.Permissions.AllowedTools) > 0 {
303 sorted := slices.Clone(c.Permissions.AllowedTools)
304 slices.Sort(sorted)
305 fmt.Fprintf(b, "allowed_tools = %s\n", strings.Join(sorted, ", "))
306 }
307 b.WriteString("\n")
308}
309
310func writeDisabledTools(b *strings.Builder, cfg *config.ConfigStore) {
311 c := cfg.Config()
312 if c.Options == nil || len(c.Options.DisabledTools) == 0 {
313 return
314 }
315 sorted := slices.Clone(c.Options.DisabledTools)
316 slices.Sort(sorted)
317 b.WriteString("[tools]\n")
318 fmt.Fprintf(b, "disabled = %s\n", strings.Join(sorted, ", "))
319 b.WriteString("\n")
320}
321
322func writeOptions(b *strings.Builder, cfg *config.ConfigStore) {
323 c := cfg.Config()
324 if c.Options == nil {
325 return
326 }
327 type kv struct {
328 key string
329 value string
330 }
331 var opts []kv
332
333 opts = append(opts, kv{"data_directory", c.Options.DataDirectory})
334 opts = append(opts, kv{"debug", fmt.Sprintf("%v", c.Options.Debug)})
335 autoLSP := c.Options.AutoLSP == nil || *c.Options.AutoLSP
336 opts = append(opts, kv{"auto_lsp", fmt.Sprintf("%v", autoLSP)})
337 autoSummarize := !c.Options.DisableAutoSummarize
338 opts = append(opts, kv{"auto_summarize", fmt.Sprintf("%v", autoSummarize)})
339
340 slices.SortFunc(opts, func(a, b kv) int { return strings.Compare(a.key, b.key) })
341 b.WriteString("[options]\n")
342 for _, o := range opts {
343 fmt.Fprintf(b, "%s = %s\n", o.key, o.value)
344 }
345 b.WriteString("\n")
346}
347
348func lspStateString(state lsp.ServerState) string {
349 switch state {
350 case lsp.StateUnstarted:
351 return "unstarted"
352 case lsp.StateStarting:
353 return "starting"
354 case lsp.StateReady:
355 return "ready"
356 case lsp.StateError:
357 return "error"
358 case lsp.StateStopped:
359 return "stopped"
360 case lsp.StateDisabled:
361 return "disabled"
362 default:
363 return "unknown"
364 }
365}