diff --git a/internal/agent/tools/crush_info.go b/internal/agent/tools/crush_info.go new file mode 100644 index 0000000000000000000000000000000000000000..1e90c6ba1fa41888493b651be2ade92a3cf04f9f --- /dev/null +++ b/internal/agent/tools/crush_info.go @@ -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" + } +} diff --git a/internal/agent/tools/crush_info.md b/internal/agent/tools/crush_info.md new file mode 100644 index 0000000000000000000000000000000000000000..e4e0d923a824673d6e0c8083d6d03c62244e9d96 --- /dev/null +++ b/internal/agent/tools/crush_info.md @@ -0,0 +1,16 @@ +Get information about Crush's current runtime configuration and service +state. + + +- Shows active model and provider, LSP/MCP server status, permissions mode, + disabled tools, and key options +- Use when diagnosing why something isn't working (missing diagnostics, + provider errors, MCP disconnections) +- No parameters needed — always returns the full current state + + + +- Check [lsp] and [mcp] sections for service health +- Check [providers] to see which providers are enabled and available +- Pair with the crush-config skill to fix configuration issues + diff --git a/internal/agent/tools/crush_info_test.go b/internal/agent/tools/crush_info_test.go new file mode 100644 index 0000000000000000000000000000000000000000..bf9a2f01420b025652435e2099238fe15b069021 --- /dev/null +++ b/internal/agent/tools/crush_info_test.go @@ -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]") +}