1package tools
2
3import (
4 "context"
5 _ "embed"
6 "fmt"
7 "slices"
8 "strings"
9
10 "charm.land/fantasy"
11 "git.secluded.site/crush/internal/agent/tools/mcp"
12 "git.secluded.site/crush/internal/config"
13 "git.secluded.site/crush/internal/lsp"
14 "git.secluded.site/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}