feat(tools): crush_info tool for readling live config

Christian Rocha created

Change summary

internal/agent/coordinator.go      |  1 +
internal/config/config.go          |  1 +
internal/config/load.go            | 16 ++++++++++++----
internal/config/load_bench_test.go |  6 +++---
internal/config/load_test.go       |  4 ++--
internal/config/store.go           | 18 ++++++++++++++++--
internal/lsp/client.go             |  5 +++++
7 files changed, 40 insertions(+), 11 deletions(-)

Detailed changes

internal/agent/coordinator.go 🔗

@@ -447,6 +447,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 
 	allTools = append(allTools,
 		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Options.Attribution, modelName),
+		tools.NewCrushInfoTool(c.cfg, c.lspManager),
 		tools.NewJobOutputTool(),
 		tools.NewJobKillTool(),
 		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),

internal/config/config.go 🔗

@@ -462,6 +462,7 @@ func allToolNames() []string {
 	return []string{
 		"agent",
 		"bash",
+		"crush_info",
 		"job_output",
 		"job_kill",
 		"download",

internal/config/load.go 🔗

@@ -33,7 +33,7 @@ const defaultCatwalkURL = "https://catwalk.charm.sh"
 func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
 	configPaths := lookupConfigs(workingDir)
 
-	cfg, err := loadFromConfigPaths(configPaths)
+	cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
 	if err != nil {
 		return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
 	}
@@ -45,6 +45,7 @@ func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
 		workingDir:     workingDir,
 		globalDataPath: GlobalConfigData(),
 		workspacePath:  filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName)),
+		loadedPaths:    loadedPaths,
 	}
 
 	if debug {
@@ -60,6 +61,7 @@ func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
 			*cfg = *merged
 			cfg.setDefaults(workingDir, dataDir)
 			store.config = cfg
+			store.loadedPaths = append(store.loadedPaths, store.workspacePath)
 		}
 	}
 
@@ -669,8 +671,9 @@ func lookupConfigs(cwd string) []string {
 	return append(configPaths, foundConfigs...)
 }
 
-func loadFromConfigPaths(configPaths []string) (*Config, error) {
+func loadFromConfigPaths(configPaths []string) (*Config, []string, error) {
 	var configs [][]byte
+	var loaded []string
 
 	for _, path := range configPaths {
 		data, err := os.ReadFile(path)
@@ -678,15 +681,20 @@ func loadFromConfigPaths(configPaths []string) (*Config, error) {
 			if os.IsNotExist(err) {
 				continue
 			}
-			return nil, fmt.Errorf("failed to open config file %s: %w", path, err)
+			return nil, nil, fmt.Errorf("failed to open config file %s: %w", path, err)
 		}
 		if len(data) == 0 {
 			continue
 		}
 		configs = append(configs, data)
+		loaded = append(loaded, path)
 	}
 
-	return loadFromBytes(configs)
+	cfg, err := loadFromBytes(configs)
+	if err != nil {
+		return nil, nil, err
+	}
+	return cfg, loaded, nil
 }
 
 func loadFromBytes(configs [][]byte) (*Config, error) {

internal/config/load_bench_test.go 🔗

@@ -53,7 +53,7 @@ func BenchmarkLoadFromConfigPaths(b *testing.B) {
 
 	b.ReportAllocs()
 	for b.Loop() {
-		_, err := loadFromConfigPaths(configPaths)
+		_, _, err := loadFromConfigPaths(configPaths)
 		if err != nil {
 			b.Fatal(err)
 		}
@@ -78,7 +78,7 @@ func BenchmarkLoadFromConfigPaths_MissingFiles(b *testing.B) {
 
 	b.ReportAllocs()
 	for b.Loop() {
-		_, err := loadFromConfigPaths(configPaths)
+		_, _, err := loadFromConfigPaths(configPaths)
 		if err != nil {
 			b.Fatal(err)
 		}
@@ -95,7 +95,7 @@ func BenchmarkLoadFromConfigPaths_Empty(b *testing.B) {
 
 	b.ReportAllocs()
 	for b.Loop() {
-		_, err := loadFromConfigPaths(configPaths)
+		_, _, err := loadFromConfigPaths(configPaths)
 		if err != nil {
 			b.Fatal(err)
 		}

internal/config/load_test.go 🔗

@@ -490,7 +490,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
 	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
 
-	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "crush_info", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)
@@ -513,7 +513,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
 	cfg.SetupAgents()
 	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
-	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "crush_info", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)

internal/config/store.go 🔗

@@ -32,8 +32,9 @@ type ConfigStore struct {
 	config         *Config
 	workingDir     string
 	resolver       VariableResolver
-	globalDataPath string // ~/.local/share/crush/crush.json
-	workspacePath  string // .crush/crush.json
+	globalDataPath string   // ~/.local/share/crush/crush.json
+	workspacePath  string   // .crush/crush.json
+	loadedPaths    []string // config files that were successfully loaded
 	knownProviders []catwalk.Provider
 	overrides      RuntimeOverrides
 }
@@ -76,6 +77,11 @@ func (s *ConfigStore) Overrides() *RuntimeOverrides {
 	return &s.overrides
 }
 
+// LoadedPaths returns the config file paths that were successfully loaded.
+func (s *ConfigStore) LoadedPaths() []string {
+	return slices.Clone(s.loadedPaths)
+}
+
 // configPath returns the file path for the given scope.
 func (s *ConfigStore) configPath(scope Scope) (string, error) {
 	switch scope {
@@ -337,6 +343,14 @@ func (s *ConfigStore) recordRecentModel(scope Scope, modelType SelectedModelType
 	return nil
 }
 
+// NewTestStore creates a ConfigStore for testing purposes.
+func NewTestStore(cfg *Config, loadedPaths ...string) *ConfigStore {
+	return &ConfigStore{
+		config:      cfg,
+		loadedPaths: loadedPaths,
+	}
+}
+
 // ImportCopilot attempts to import a GitHub Copilot token from disk.
 func (s *ConfigStore) ImportCopilot() (*oauth.Token, bool) {
 	if s.HasConfigField(ScopeGlobal, "providers.copilot.api_key") || s.HasConfigField(ScopeGlobal, "providers.copilot.oauth") {

internal/lsp/client.go 🔗

@@ -286,6 +286,11 @@ func (c *Client) GetName() string {
 	return c.name
 }
 
+// FileTypes returns the file types this LSP client handles
+func (c *Client) FileTypes() []string {
+	return c.fileTypes
+}
+
 // SetDiagnosticsCallback sets the callback function for diagnostic changes
 func (c *Client) SetDiagnosticsCallback(callback func(name string, count int)) {
 	c.onDiagnosticsChanged = callback