@@ -0,0 +1,334 @@
+package tools
+
+import (
+ "context"
+ _ "embed"
+ "fmt"
+ "slices"
+ "strings"
+
+ "charm.land/fantasy"
+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/lsp"
+)
+
+const CrushInfoToolName = "crush_info"
+
+//go:embed crush_info.md
+var crushInfoDescription []byte
+
+type CrushInfoParams struct{}
+
+func NewCrushInfoTool(
+ cfg *config.ConfigStore,
+ lspManager *lsp.Manager,
+) fantasy.AgentTool {
+ return fantasy.NewAgentTool(
+ CrushInfoToolName,
+ string(crushInfoDescription),
+ func(ctx context.Context, _ CrushInfoParams, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
+ return fantasy.NewTextResponse(buildCrushInfo(cfg, lspManager)), nil
+ })
+}
+
+func buildCrushInfo(cfg *config.ConfigStore, lspManager *lsp.Manager) string {
+ var b strings.Builder
+
+ writeConfigFiles(&b, cfg)
+ writeModels(&b, cfg)
+ writeProviders(&b, cfg)
+ writeLSP(&b, lspManager, cfg)
+ writeMCP(&b, mcp.GetStates(), cfg)
+ writePermissions(&b, cfg)
+ writeDisabledTools(&b, cfg)
+ writeOptions(&b, cfg)
+
+ return b.String()
+}
+
+func writeConfigFiles(b *strings.Builder, cfg *config.ConfigStore) {
+ b.WriteString("[config_files]\n")
+ paths := cfg.LoadedPaths()
+ for _, p := range paths {
+ b.WriteString(p + "\n")
+ }
+ b.WriteString("\n")
+}
+
+func writeModels(b *strings.Builder, cfg *config.ConfigStore) {
+ c := cfg.Config()
+ if len(c.Models) == 0 {
+ return
+ }
+ b.WriteString("[model]\n")
+ for _, typ := range []config.SelectedModelType{config.SelectedModelTypeLarge, config.SelectedModelTypeSmall} {
+ m, ok := c.Models[typ]
+ if !ok {
+ continue
+ }
+ fmt.Fprintf(b, "%s = %s (%s)\n", typ, m.Model, m.Provider)
+ }
+ b.WriteString("\n")
+}
+
+func writeProviders(b *strings.Builder, cfg *config.ConfigStore) {
+ c := cfg.Config()
+ type pv struct {
+ name string
+ count int
+ }
+ var providers []pv
+ for name, pc := range c.Providers.Seq2() {
+ if pc.Disable {
+ continue
+ }
+ providers = append(providers, pv{name: name, count: len(pc.Models)})
+ }
+ if len(providers) == 0 {
+ return
+ }
+ slices.SortFunc(providers, func(a, b pv) int { return strings.Compare(a.name, b.name) })
+ b.WriteString("[providers]\n")
+ for _, p := range providers {
+ fmt.Fprintf(b, "%s = enabled (%d models)\n", p.name, p.count)
+ }
+ b.WriteString("\n")
+}
+
+func writeLSP(b *strings.Builder, lspManager *lsp.Manager, cfg *config.ConfigStore) {
+ // Write runtime LSP clients
+ if lspManager != nil && lspManager.Clients().Len() > 0 {
+ type entry struct {
+ name string
+ state lsp.ServerState
+ fileTypes []string
+ }
+ var entries []entry
+ for name, client := range lspManager.Clients().Seq2() {
+ entries = append(entries, entry{
+ name: name,
+ state: client.GetServerState(),
+ fileTypes: client.FileTypes(),
+ })
+ }
+ if len(entries) > 0 {
+ slices.SortFunc(entries, func(a, b entry) int { return strings.Compare(a.name, b.name) })
+ b.WriteString("[lsp]\n")
+ for _, e := range entries {
+ stateStr := lspStateString(e.state)
+ if len(e.fileTypes) > 0 {
+ sorted := slices.Clone(e.fileTypes)
+ slices.Sort(sorted)
+ fmt.Fprintf(b, "%s = %s (%s)\n", e.name, stateStr, strings.Join(sorted, ", "))
+ } else {
+ fmt.Fprintf(b, "%s = %s\n", e.name, stateStr)
+ }
+ }
+ b.WriteString("\n")
+ }
+ }
+
+ // Write configured but not running LSP servers
+ c := cfg.Config()
+ if len(c.LSP) > 0 {
+ runtimeNames := make(map[string]bool)
+ if lspManager != nil {
+ for name := range lspManager.Clients().Seq2() {
+ runtimeNames[name] = true
+ }
+ }
+
+ type configuredEntry struct {
+ name string
+ status string
+ }
+ var entries []configuredEntry
+ for name, lspCfg := range c.LSP {
+ // Skip if already in runtime
+ if runtimeNames[name] {
+ continue
+ }
+ status := "not_started"
+ if lspCfg.Disabled {
+ status = "disabled"
+ }
+ entries = append(entries, configuredEntry{name: name, status: status})
+ }
+
+ if len(entries) > 0 {
+ slices.SortFunc(entries, func(a, b configuredEntry) int { return strings.Compare(a.name, b.name) })
+ b.WriteString("[lsp_configured]\n")
+ for _, e := range entries {
+ fmt.Fprintf(b, "%s = %s\n", e.name, e.status)
+ }
+ b.WriteString("\n")
+ }
+ }
+}
+
+func writeMCP(b *strings.Builder, states map[string]mcp.ClientInfo, cfg *config.ConfigStore) {
+ // Write runtime MCP states
+ if len(states) > 0 {
+ type entry struct {
+ name string
+ state mcp.State
+ err error
+ tools int
+ resources int
+ connectedAt string
+ }
+ var entries []entry
+ for name, info := range states {
+ e := entry{
+ name: name,
+ state: info.State,
+ err: info.Error,
+ }
+ if info.State == mcp.StateConnected {
+ e.tools = info.Counts.Tools
+ e.resources = info.Counts.Resources
+ if !info.ConnectedAt.IsZero() {
+ e.connectedAt = info.ConnectedAt.Format("15:04:05")
+ }
+ }
+ entries = append(entries, e)
+ }
+ slices.SortFunc(entries, func(a, b entry) int { return strings.Compare(a.name, b.name) })
+ b.WriteString("[mcp]\n")
+ for _, e := range entries {
+ switch e.state {
+ case mcp.StateConnected:
+ if e.connectedAt != "" {
+ fmt.Fprintf(b, "%s = connected (%d tools, %d resources) since %s\n", e.name, e.tools, e.resources, e.connectedAt)
+ } else {
+ fmt.Fprintf(b, "%s = connected (%d tools, %d resources)\n", e.name, e.tools, e.resources)
+ }
+ case mcp.StateError:
+ if e.err != nil {
+ fmt.Fprintf(b, "%s = error: %s\n", e.name, e.err.Error())
+ } else {
+ fmt.Fprintf(b, "%s = error\n", e.name)
+ }
+ default:
+ fmt.Fprintf(b, "%s = %s\n", e.name, e.state)
+ }
+ }
+ b.WriteString("\n")
+ }
+
+ // Write configured but not running MCP servers
+ c := cfg.Config()
+ if len(c.MCP) > 0 {
+ runtimeNames := make(map[string]bool)
+ for name := range states {
+ runtimeNames[name] = true
+ }
+
+ type configuredEntry struct {
+ name string
+ status string
+ }
+ var entries []configuredEntry
+ for name, mcpCfg := range c.MCP {
+ // Skip if already in runtime
+ if runtimeNames[name] {
+ continue
+ }
+ status := "not_started"
+ if mcpCfg.Disabled {
+ status = "disabled"
+ }
+ entries = append(entries, configuredEntry{name: name, status: status})
+ }
+
+ if len(entries) > 0 {
+ slices.SortFunc(entries, func(a, b configuredEntry) int { return strings.Compare(a.name, b.name) })
+ b.WriteString("[mcp_configured]\n")
+ for _, e := range entries {
+ fmt.Fprintf(b, "%s = %s\n", e.name, e.status)
+ }
+ b.WriteString("\n")
+ }
+ }
+}
+
+func writePermissions(b *strings.Builder, cfg *config.ConfigStore) {
+ c := cfg.Config()
+ overrides := cfg.Overrides()
+
+ if c.Permissions == nil {
+ if !overrides.SkipPermissionRequests {
+ return
+ }
+ } else if !overrides.SkipPermissionRequests && len(c.Permissions.AllowedTools) == 0 {
+ return
+ }
+ b.WriteString("[permissions]\n")
+ if overrides.SkipPermissionRequests {
+ b.WriteString("mode = yolo\n")
+ }
+ if c.Permissions != nil && len(c.Permissions.AllowedTools) > 0 {
+ sorted := slices.Clone(c.Permissions.AllowedTools)
+ slices.Sort(sorted)
+ fmt.Fprintf(b, "allowed_tools = %s\n", strings.Join(sorted, ", "))
+ }
+ b.WriteString("\n")
+}
+
+func writeDisabledTools(b *strings.Builder, cfg *config.ConfigStore) {
+ c := cfg.Config()
+ if c.Options == nil || len(c.Options.DisabledTools) == 0 {
+ return
+ }
+ sorted := slices.Clone(c.Options.DisabledTools)
+ slices.Sort(sorted)
+ b.WriteString("[tools]\n")
+ fmt.Fprintf(b, "disabled = %s\n", strings.Join(sorted, ", "))
+ b.WriteString("\n")
+}
+
+func writeOptions(b *strings.Builder, cfg *config.ConfigStore) {
+ c := cfg.Config()
+ if c.Options == nil {
+ return
+ }
+ type kv struct {
+ key string
+ value string
+ }
+ var opts []kv
+
+ opts = append(opts, kv{"data_directory", c.Options.DataDirectory})
+ opts = append(opts, kv{"debug", fmt.Sprintf("%v", c.Options.Debug)})
+ autoLSP := c.Options.AutoLSP == nil || *c.Options.AutoLSP
+ opts = append(opts, kv{"auto_lsp", fmt.Sprintf("%v", autoLSP)})
+ autoSummarize := !c.Options.DisableAutoSummarize
+ opts = append(opts, kv{"auto_summarize", fmt.Sprintf("%v", autoSummarize)})
+
+ slices.SortFunc(opts, func(a, b kv) int { return strings.Compare(a.key, b.key) })
+ b.WriteString("[options]\n")
+ for _, o := range opts {
+ fmt.Fprintf(b, "%s = %s\n", o.key, o.value)
+ }
+ b.WriteString("\n")
+}
+
+func lspStateString(state lsp.ServerState) string {
+ switch state {
+ case lsp.StateUnstarted:
+ return "unstarted"
+ case lsp.StateStarting:
+ return "starting"
+ case lsp.StateReady:
+ return "ready"
+ case lsp.StateError:
+ return "error"
+ case lsp.StateStopped:
+ return "stopped"
+ case lsp.StateDisabled:
+ return "disabled"
+ default:
+ return "unknown"
+ }
+}
@@ -0,0 +1,300 @@
+package tools
+
+import (
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "charm.land/catwalk/pkg/catwalk"
+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/csync"
+ "github.com/charmbracelet/crush/internal/lsp"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCrushInfo_MinimalConfig(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewTestStore(&config.Config{
+ Providers: csync.NewMap[string, config.ProviderConfig](),
+ })
+ output := buildCrushInfo(cfg, nil)
+ require.NotContains(t, output, "[providers]")
+ require.NotContains(t, output, "[lsp]")
+ require.NotContains(t, output, "[mcp]")
+ require.NotContains(t, output, "[permissions]")
+ require.NotContains(t, output, "[tools]")
+}
+
+func TestCrushInfo_ConfigFiles(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewTestStore(
+ &config.Config{Providers: csync.NewMap[string, config.ProviderConfig]()},
+ "/home/user/.config/crush/crush.json",
+ "/project/.crush/crush.json",
+ )
+ output := buildCrushInfo(cfg, nil)
+ require.Contains(t, output, "[config_files]")
+ require.Contains(t, output, "/home/user/.config/crush/crush.json")
+ require.Contains(t, output, "/project/.crush/crush.json")
+}
+
+func TestCrushInfo_Models(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewTestStore(&config.Config{
+ Models: map[config.SelectedModelType]config.SelectedModel{
+ config.SelectedModelTypeLarge: {Model: "claude-sonnet-4-20250514", Provider: "anthropic"},
+ config.SelectedModelTypeSmall: {Model: "claude-haiku-3-20250307", Provider: "anthropic"},
+ },
+ Providers: csync.NewMap[string, config.ProviderConfig](),
+ })
+ output := buildCrushInfo(cfg, nil)
+ require.Contains(t, output, "[model]")
+ require.Contains(t, output, "large = claude-sonnet-4-20250514 (anthropic)")
+ require.Contains(t, output, "small = claude-haiku-3-20250307 (anthropic)")
+}
+
+func TestCrushInfo_Providers(t *testing.T) {
+ t.Parallel()
+
+ providers := csync.NewMap[string, config.ProviderConfig]()
+ providers.Set("openai", config.ProviderConfig{Models: make([]catwalk.Model, 8)})
+ providers.Set("anthropic", config.ProviderConfig{Models: make([]catwalk.Model, 12)})
+
+ cfg := config.NewTestStore(&config.Config{Providers: providers})
+ output := buildCrushInfo(cfg, nil)
+ require.Contains(t, output, "[providers]")
+ anthropicIdx := strings.Index(output, "anthropic = enabled")
+ openaiIdx := strings.Index(output, "openai = enabled")
+ require.Greater(t, anthropicIdx, -1)
+ require.Greater(t, openaiIdx, -1)
+ require.Less(t, anthropicIdx, openaiIdx, "anthropic should appear before openai")
+ require.Contains(t, output, "anthropic = enabled (12 models)")
+ require.Contains(t, output, "openai = enabled (8 models)")
+}
+
+func TestCrushInfo_DisabledProvidersOmitted(t *testing.T) {
+ t.Parallel()
+
+ providers := csync.NewMap[string, config.ProviderConfig]()
+ providers.Set("openai", config.ProviderConfig{Disable: true, Models: make([]catwalk.Model, 8)})
+ providers.Set("anthropic", config.ProviderConfig{Models: make([]catwalk.Model, 12)})
+
+ cfg := config.NewTestStore(&config.Config{Providers: providers})
+ output := buildCrushInfo(cfg, nil)
+ require.Contains(t, output, "anthropic = enabled")
+ require.NotContains(t, output, "openai")
+}
+
+func TestCrushInfo_LSPStates(t *testing.T) {
+ t.Parallel()
+
+ mgr := lsp.NewManager(config.NewTestStore(&config.Config{
+ Providers: csync.NewMap[string, config.ProviderConfig](),
+ }))
+ readyClient := &lsp.Client{}
+ readyClient.SetServerState(lsp.StateReady)
+ mgr.Clients().Set("gopls", readyClient)
+
+ errorClient := &lsp.Client{}
+ errorClient.SetServerState(lsp.StateError)
+ mgr.Clients().Set("pyright", errorClient)
+
+ cfg := config.NewTestStore(&config.Config{Providers: csync.NewMap[string, config.ProviderConfig]()})
+ output := buildCrushInfo(cfg, mgr)
+ require.Contains(t, output, "[lsp]")
+ require.Contains(t, output, "gopls = ready")
+ require.Contains(t, output, "pyright = error")
+ goplsIdx := strings.Index(output, "gopls = ready")
+ pyrightIdx := strings.Index(output, "pyright = error")
+ require.Less(t, goplsIdx, pyrightIdx, "gopls should appear before pyright")
+}
+
+func TestCrushInfo_MCPStates(t *testing.T) {
+ t.Parallel()
+
+ connectedAt := time.Date(2025, 1, 15, 15, 4, 5, 0, time.UTC)
+ states := map[string]mcp.ClientInfo{
+ "github": {
+ Name: "github",
+ State: mcp.StateConnected,
+ Counts: mcp.Counts{Tools: 42, Resources: 7},
+ ConnectedAt: connectedAt,
+ },
+ "filesystem": {
+ Name: "filesystem",
+ State: mcp.StateError,
+ Error: errors.New("connection refused"),
+ },
+ }
+
+ cfg := config.NewTestStore(&config.Config{
+ Providers: csync.NewMap[string, config.ProviderConfig](),
+ })
+
+ var b strings.Builder
+ writeMCP(&b, states, cfg)
+ output := b.String()
+ require.Contains(t, output, "[mcp]")
+ require.Contains(t, output, "filesystem = error: connection refused")
+ require.Contains(t, output, "github = connected (42 tools, 7 resources) since 15:04:05")
+ filesystemIdx := strings.Index(output, "filesystem")
+ githubIdx := strings.Index(output, "github")
+ require.Less(t, filesystemIdx, githubIdx, "filesystem should appear before github")
+}
+
+func TestCrushInfo_YoloMode(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewTestStore(&config.Config{
+ Providers: csync.NewMap[string, config.ProviderConfig](),
+ Permissions: &config.Permissions{},
+ })
+ cfg.Overrides().SkipPermissionRequests = true
+
+ output := buildCrushInfo(cfg, nil)
+ require.Contains(t, output, "[permissions]")
+ require.Contains(t, output, "mode = yolo")
+}
+
+func TestCrushInfo_AllowedTools(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewTestStore(&config.Config{
+ Providers: csync.NewMap[string, config.ProviderConfig](),
+ Permissions: &config.Permissions{AllowedTools: []string{"edit:write", "bash"}},
+ })
+
+ output := buildCrushInfo(cfg, nil)
+ require.Contains(t, output, "[permissions]")
+ require.Contains(t, output, "allowed_tools = bash, edit:write")
+}
+
+func TestCrushInfo_DisabledTools(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewTestStore(&config.Config{
+ Providers: csync.NewMap[string, config.ProviderConfig](),
+ Options: &config.Options{DisabledTools: []string{"sourcegraph", "agentic_fetch"}},
+ })
+
+ output := buildCrushInfo(cfg, nil)
+ require.Contains(t, output, "[tools]")
+ require.Contains(t, output, "disabled = agentic_fetch, sourcegraph")
+}
+
+func TestCrushInfo_Options(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewTestStore(&config.Config{
+ Providers: csync.NewMap[string, config.ProviderConfig](),
+ Options: &config.Options{
+ DataDirectory: "/Users/user/project/.crush",
+ Debug: true,
+ DisableAutoSummarize: true,
+ },
+ })
+
+ output := buildCrushInfo(cfg, nil)
+ require.Contains(t, output, "[options]")
+ require.Contains(t, output, "auto_lsp = true")
+ require.Contains(t, output, "auto_summarize = false")
+ require.Contains(t, output, "data_directory = /Users/user/project/.crush")
+ require.Contains(t, output, "debug = true")
+}
+
+func TestCrushInfo_AutoSummarizeInversion(t *testing.T) {
+ t.Parallel()
+
+ cfgFalse := config.NewTestStore(&config.Config{
+ Providers: csync.NewMap[string, config.ProviderConfig](),
+ Options: &config.Options{DisableAutoSummarize: true},
+ })
+ outputFalse := buildCrushInfo(cfgFalse, nil)
+ require.Contains(t, outputFalse, "auto_summarize = false")
+
+ cfgTrue := config.NewTestStore(&config.Config{
+ Providers: csync.NewMap[string, config.ProviderConfig](),
+ Options: &config.Options{DisableAutoSummarize: false},
+ })
+ outputTrue := buildCrushInfo(cfgTrue, nil)
+ require.Contains(t, outputTrue, "auto_summarize = true")
+}
+
+func TestCrushInfo_NoSecrets(t *testing.T) {
+ t.Parallel()
+
+ providers := csync.NewMap[string, config.ProviderConfig]()
+ providers.Set("openai", config.ProviderConfig{
+ APIKey: "sk-super-secret-key-12345",
+ Models: make([]catwalk.Model, 8),
+ })
+
+ cfg := config.NewTestStore(&config.Config{Providers: providers})
+ output := buildCrushInfo(cfg, nil)
+ require.NotContains(t, output, "sk-super-secret-key-12345")
+ require.NotContains(t, output, "secret")
+ require.Contains(t, output, "openai = enabled (8 models)")
+}
+
+func TestCrushInfo_DeterministicOrdering(t *testing.T) {
+ t.Parallel()
+
+ providers := csync.NewMap[string, config.ProviderConfig]()
+ providers.Set("zebra", config.ProviderConfig{Models: make([]catwalk.Model, 1)})
+ providers.Set("alpha", config.ProviderConfig{Models: make([]catwalk.Model, 2)})
+ providers.Set("middle", config.ProviderConfig{Models: make([]catwalk.Model, 3)})
+
+ states := map[string]mcp.ClientInfo{
+ "z-mcp": {Name: "z-mcp", State: mcp.StateConnected, Counts: mcp.Counts{Tools: 1}},
+ "a-mcp": {Name: "a-mcp", State: mcp.StateConnected, Counts: mcp.Counts{Tools: 2}},
+ }
+
+ cfg := config.NewTestStore(&config.Config{
+ Providers: providers,
+ Options: &config.Options{DisabledTools: []string{"z-tool", "a-tool"}},
+ Permissions: &config.Permissions{
+ AllowedTools: []string{"z-perm", "a-perm"},
+ },
+ })
+ cfg.Overrides().SkipPermissionRequests = true
+
+ // Test MCP ordering via writeMCP directly.
+ var mcpBuf strings.Builder
+ writeMCP(&mcpBuf, states, cfg)
+ mcpOutput := mcpBuf.String()
+ aMcpIdx := strings.Index(mcpOutput, "a-mcp = connected")
+ zMcpIdx := strings.Index(mcpOutput, "z-mcp = connected")
+ require.Less(t, aMcpIdx, zMcpIdx)
+
+ output := buildCrushInfo(cfg, nil)
+
+ alphaIdx := strings.Index(output, "alpha = enabled")
+ middleIdx := strings.Index(output, "middle = enabled")
+ zebraIdx := strings.Index(output, "zebra = enabled")
+ require.Less(t, alphaIdx, middleIdx)
+ require.Less(t, middleIdx, zebraIdx)
+
+ require.Contains(t, output, "disabled = a-tool, z-tool")
+ require.Contains(t, output, "allowed_tools = a-perm, z-perm")
+}
+
+func TestCrushInfo_EmptySectionsOmitted(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.NewTestStore(&config.Config{
+ Providers: csync.NewMap[string, config.ProviderConfig](),
+ Permissions: &config.Permissions{},
+ Options: &config.Options{},
+ })
+
+ output := buildCrushInfo(cfg, nil)
+ require.NotContains(t, output, "[tools]")
+ require.NotContains(t, output, "[permissions]")
+ require.NotContains(t, output, "[lsp]")
+ require.NotContains(t, output, "[mcp]")
+}