Merge branch 'main' into server-client

Ayman Bagabas created

Change summary

.goreleaser.yml                 |   4 
README.md                       |  11 
crush.json                      |   4 
internal/config/config.go       |   2 
internal/config/load.go         |  48 ++
internal/config/load_test.go    |  23 +
internal/fsext/lookup.go        | 141 ++++++++++
internal/fsext/lookup_test.go   | 483 +++++++++++++++++++++++++++++++++++
internal/fsext/owner_windows.go |   6 
internal/fsext/parent.go        |  60 ----
internal/home/home.go           |   1 
internal/llm/agent/mcp-tools.go |  28 +
internal/llm/tools/grep.go      |  37 ++
internal/llm/tools/rg.go        |   4 
internal/lsp/client.go          |   3 
15 files changed, 763 insertions(+), 92 deletions(-)

Detailed changes

.goreleaser.yml 🔗

@@ -67,8 +67,12 @@ builds:
         goarch: arm
       - goos: android
         goarch: "386"
+      - goos: windows
+        goarch: arm
     ldflags:
       - -s -w -X github.com/charmbracelet/crush/internal/version.Version={{.Version}}
+    flags:
+      - -trimpath
 
 archives:
   - name_template: >-

README.md 🔗

@@ -180,14 +180,17 @@ That said, you can also set environment variables for preferred providers.
 | `ANTHROPIC_API_KEY`         | Anthropic                                          |
 | `OPENAI_API_KEY`            | OpenAI                                             |
 | `OPENROUTER_API_KEY`        | OpenRouter                                         |
-| `CEREBRAS_API_KEY`          | Cerebras                                           |
 | `GEMINI_API_KEY`            | Google Gemini                                      |
+| `CEREBRAS_API_KEY`          | Cerebras                                           |
+| `HF_TOKEN`                  | Huggingface Inference                              |
 | `VERTEXAI_PROJECT`          | Google Cloud VertexAI (Gemini)                     |
 | `VERTEXAI_LOCATION`         | Google Cloud VertexAI (Gemini)                     |
 | `GROQ_API_KEY`              | Groq                                               |
 | `AWS_ACCESS_KEY_ID`         | AWS Bedrock (Claude)                               |
 | `AWS_SECRET_ACCESS_KEY`     | AWS Bedrock (Claude)                               |
 | `AWS_REGION`                | AWS Bedrock (Claude)                               |
+| `AWS_PROFILE`               | Custom AWS Profile                                 |
+| `AWS_REGION`                | AWS Region                                         |
 | `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI models                                |
 | `AZURE_OPENAI_API_KEY`      | Azure OpenAI models (optional when using Entra ID) |
 | `AZURE_OPENAI_API_VERSION`  | Azure OpenAI models                                |
@@ -270,6 +273,8 @@ using `$(echo $VAR)` syntax.
       "type": "stdio",
       "command": "node",
       "args": ["/path/to/mcp-server.js"],
+      "timeout": 120,
+      "disabled": false,
       "env": {
         "NODE_ENV": "production"
       }
@@ -277,6 +282,8 @@ using `$(echo $VAR)` syntax.
     "github": {
       "type": "http",
       "url": "https://example.com/mcp/",
+      "timeout": 120,
+      "disabled": false,
       "headers": {
         "Authorization": "$(echo Bearer $EXAMPLE_MCP_TOKEN)"
       }
@@ -284,6 +291,8 @@ using `$(echo $VAR)` syntax.
     "streaming-service": {
       "type": "sse",
       "url": "https://example.com/mcp/sse",
+      "timeout": 120,
+      "disabled": false,
       "headers": {
         "API-Key": "$(echo $API_KEY)"
       }

crush.json 🔗

@@ -1,8 +1,6 @@
 {
   "$schema": "https://charm.land/crush.json",
   "lsp": {
-    "Go": {
-      "command": "gopls"
-    }
+    "gopls": {}
   }
 }

internal/config/config.go 🔗

@@ -118,7 +118,7 @@ type MCPConfig struct {
 
 type LSPConfig struct {
 	Disabled    bool              `json:"disabled,omitempty" jsonschema:"description=Whether this LSP server is disabled,default=false"`
-	Command     string            `json:"command" jsonschema:"required,description=Command to execute for the LSP server,example=gopls"`
+	Command     string            `json:"command,omitempty" jsonschema:"required,description=Command to execute for the LSP server,example=gopls"`
 	Args        []string          `json:"args,omitempty" jsonschema:"description=Arguments to pass to the LSP server command"`
 	Env         map[string]string `json:"env,omitempty" jsonschema:"description=Environment variables to set to the LSP server command"`
 	FileTypes   []string          `json:"filetypes,omitempty" jsonschema:"description=File types this LSP server handles,example=go,example=mod,example=rs,example=c,example=js,example=ts"`

internal/config/load.go 🔗

@@ -41,13 +41,8 @@ func LoadReader(fd io.Reader) (*Config, error) {
 
 // Load loads the configuration from the default paths.
 func Load(workingDir, dataDir string, debug bool) (*Config, error) {
-	// uses default config paths
-	configPaths := []string{
-		globalConfig(),
-		GlobalConfigData(),
-		filepath.Join(workingDir, fmt.Sprintf("%s.json", appName)),
-		filepath.Join(workingDir, fmt.Sprintf(".%s.json", appName)),
-	}
+	configPaths := lookupConfigs(workingDir)
+
 	cfg, err := loadFromConfigPaths(configPaths)
 	if err != nil {
 		return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
@@ -316,7 +311,7 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 	if dataDir != "" {
 		c.Options.DataDirectory = dataDir
 	} else if c.Options.DataDirectory == "" {
-		if path, ok := fsext.SearchParent(workingDir, defaultDataDirectory); ok {
+		if path, ok := fsext.LookupClosest(workingDir, defaultDataDirectory); ok {
 			c.Options.DataDirectory = path
 		} else {
 			c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
@@ -356,10 +351,13 @@ func (c *Config) applyLSPDefaults() {
 
 	// Apply defaults to each LSP configuration
 	for name, cfg := range c.LSP {
-		// Try to get defaults from powernap based on command name
-		base, ok := configManager.GetServer(cfg.Command)
+		// Try to get defaults from powernap based on name or command name.
+		base, ok := configManager.GetServer(name)
 		if !ok {
-			continue
+			base, ok = configManager.GetServer(cfg.Command)
+			if !ok {
+				continue
+			}
 		}
 		if cfg.Options == nil {
 			cfg.Options = base.Settings
@@ -373,6 +371,12 @@ func (c *Config) applyLSPDefaults() {
 		if len(cfg.RootMarkers) == 0 {
 			cfg.RootMarkers = base.RootMarkers
 		}
+		if len(cfg.Args) == 0 {
+			cfg.Args = base.Args
+		}
+		if len(cfg.Env) == 0 {
+			cfg.Env = base.Environment
+		}
 		// Update the config in the map
 		c.LSP[name] = cfg
 	}
@@ -514,6 +518,28 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
 	return nil
 }
 
+// lookupConfigs searches config files recursively from CWD up to FS root
+func lookupConfigs(cwd string) []string {
+	// prepend default config paths
+	configPaths := []string{
+		globalConfig(),
+		GlobalConfigData(),
+	}
+
+	configNames := []string{appName + ".json", "." + appName + ".json"}
+
+	foundConfigs, err := fsext.Lookup(cwd, configNames...)
+	if err != nil {
+		// returns at least default configs
+		return configPaths
+	}
+
+	// reverse order so last config has more priority
+	slices.Reverse(foundConfigs)
+
+	return append(configPaths, foundConfigs...)
+}
+
 func loadFromConfigPaths(configPaths []string) (*Config, error) {
 	var configs []io.Reader
 

internal/config/load_test.go 🔗

@@ -492,6 +492,29 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
 	assert.Equal(t, []string{"glob", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
 }
 
+func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
+	cfg := &Config{
+		Options: &Options{
+			DisabledTools: []string{
+				"glob",
+				"grep",
+				"ls",
+				"sourcegraph",
+				"view",
+			},
+		},
+	}
+
+	cfg.SetupAgents()
+	coderAgent, ok := cfg.Agents["coder"]
+	require.True(t, ok)
+	assert.Equal(t, []string{"bash", "download", "edit", "multiedit", "fetch", "write"}, coderAgent.AllowedTools)
+
+	taskAgent, ok := cfg.Agents["task"]
+	require.True(t, ok)
+	assert.Equal(t, []string{}, taskAgent.AllowedTools)
+}
+
 func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
 	knownProviders := []catwalk.Provider{
 		{

internal/fsext/lookup.go 🔗

@@ -0,0 +1,141 @@
+package fsext
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/charmbracelet/crush/internal/home"
+)
+
+// Lookup searches for a target files or directories starting from dir
+// and walking up the directory tree until filesystem root is reached.
+// It also checks the ownership of files to ensure that the search does
+// not cross ownership boundaries. It skips ownership mismatches without
+// errors.
+// Returns full paths to fount targets.
+// The search includes the starting directory itself.
+func Lookup(dir string, targets ...string) ([]string, error) {
+	if len(targets) == 0 {
+		return nil, nil
+	}
+
+	var found []string
+
+	err := traverseUp(dir, func(cwd string, owner int) error {
+		for _, target := range targets {
+			fpath := filepath.Join(cwd, target)
+			err := probeEnt(fpath, owner)
+
+			// skip to the next file on permission denied
+			if errors.Is(err, os.ErrNotExist) ||
+				errors.Is(err, os.ErrPermission) {
+				continue
+			}
+
+			if err != nil {
+				return fmt.Errorf("error probing file %s: %w", fpath, err)
+			}
+
+			found = append(found, fpath)
+		}
+
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return found, nil
+}
+
+// LookupClosest searches for a target file or directory starting from dir
+// and walking up the directory tree until found or root or home is reached.
+// It also checks the ownership of files to ensure that the search does
+// not cross ownership boundaries.
+// Returns the full path to the target if found, empty string and false otherwise.
+// The search includes the starting directory itself.
+func LookupClosest(dir, target string) (string, bool) {
+	var found string
+
+	err := traverseUp(dir, func(cwd string, owner int) error {
+		fpath := filepath.Join(cwd, target)
+
+		err := probeEnt(fpath, owner)
+		if errors.Is(err, os.ErrNotExist) {
+			return nil
+		}
+
+		if err != nil {
+			return fmt.Errorf("error probing file %s: %w", fpath, err)
+		}
+
+		if cwd == home.Dir() {
+			return filepath.SkipAll
+		}
+
+		found = fpath
+		return filepath.SkipAll
+	})
+
+	return found, err == nil && found != ""
+}
+
+// traverseUp walks up from given directory up until filesystem root reached.
+// It passes absolute path of current directory and staring directory owner ID
+// to callback function. It is up to user to check ownership.
+func traverseUp(dir string, walkFn func(dir string, owner int) error) error {
+	cwd, err := filepath.Abs(dir)
+	if err != nil {
+		return fmt.Errorf("cannot convert CWD to absolute path: %w", err)
+	}
+
+	owner, err := Owner(dir)
+	if err != nil {
+		return fmt.Errorf("cannot get ownership: %w", err)
+	}
+
+	for {
+		err := walkFn(cwd, owner)
+		if err == nil || errors.Is(err, filepath.SkipDir) {
+			parent := filepath.Dir(cwd)
+			if parent == cwd {
+				return nil
+			}
+
+			cwd = parent
+			continue
+		}
+
+		if errors.Is(err, filepath.SkipAll) {
+			return nil
+		}
+
+		return err
+	}
+}
+
+// probeEnt checks if entity at given path exists and belongs to given owner
+func probeEnt(fspath string, owner int) error {
+	_, err := os.Stat(fspath)
+	if err != nil {
+		return fmt.Errorf("cannot stat %s: %w", fspath, err)
+	}
+
+	// special case for ownership check bypass
+	if owner == -1 {
+		return nil
+	}
+
+	fowner, err := Owner(fspath)
+	if err != nil {
+		return fmt.Errorf("cannot get ownership for %s: %w", fspath, err)
+	}
+
+	if fowner != owner {
+		return os.ErrPermission
+	}
+
+	return nil
+}

internal/fsext/lookup_test.go 🔗

@@ -0,0 +1,483 @@
+package fsext
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/charmbracelet/crush/internal/home"
+	"github.com/stretchr/testify/require"
+)
+
+func TestLookupClosest(t *testing.T) {
+	tempDir := t.TempDir()
+
+	// Change to temp directory
+	oldWd, _ := os.Getwd()
+	err := os.Chdir(tempDir)
+	require.NoError(t, err)
+
+	t.Cleanup(func() {
+		os.Chdir(oldWd)
+	})
+
+	t.Run("target found in starting directory", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		// Create target file in current directory
+		targetFile := filepath.Join(testDir, "target.txt")
+		err := os.WriteFile(targetFile, []byte("test"), 0o644)
+		require.NoError(t, err)
+
+		foundPath, found := LookupClosest(testDir, "target.txt")
+		require.True(t, found)
+		require.Equal(t, targetFile, foundPath)
+	})
+
+	t.Run("target found in parent directory", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		// Create subdirectory
+		subDir := filepath.Join(testDir, "subdir")
+		err := os.Mkdir(subDir, 0o755)
+		require.NoError(t, err)
+
+		// Create target file in parent directory
+		targetFile := filepath.Join(testDir, "target.txt")
+		err = os.WriteFile(targetFile, []byte("test"), 0o644)
+		require.NoError(t, err)
+
+		foundPath, found := LookupClosest(subDir, "target.txt")
+		require.True(t, found)
+		require.Equal(t, targetFile, foundPath)
+	})
+
+	t.Run("target found in grandparent directory", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		// Create nested subdirectories
+		subDir := filepath.Join(testDir, "subdir")
+		err := os.Mkdir(subDir, 0o755)
+		require.NoError(t, err)
+
+		subSubDir := filepath.Join(subDir, "subsubdir")
+		err = os.Mkdir(subSubDir, 0o755)
+		require.NoError(t, err)
+
+		// Create target file in grandparent directory
+		targetFile := filepath.Join(testDir, "target.txt")
+		err = os.WriteFile(targetFile, []byte("test"), 0o644)
+		require.NoError(t, err)
+
+		foundPath, found := LookupClosest(subSubDir, "target.txt")
+		require.True(t, found)
+		require.Equal(t, targetFile, foundPath)
+	})
+
+	t.Run("target not found", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		foundPath, found := LookupClosest(testDir, "nonexistent.txt")
+		require.False(t, found)
+		require.Empty(t, foundPath)
+	})
+
+	t.Run("target directory found", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		// Create target directory in current directory
+		targetDir := filepath.Join(testDir, "targetdir")
+		err := os.Mkdir(targetDir, 0o755)
+		require.NoError(t, err)
+
+		foundPath, found := LookupClosest(testDir, "targetdir")
+		require.True(t, found)
+		require.Equal(t, targetDir, foundPath)
+	})
+
+	t.Run("stops at home directory", func(t *testing.T) {
+		// This test is limited as we can't easily create files above home directory
+		// but we can test the behavior by searching from home directory itself
+		homeDir := home.Dir()
+
+		// Search for a file that doesn't exist from home directory
+		foundPath, found := LookupClosest(homeDir, "nonexistent_file_12345.txt")
+		require.False(t, found)
+		require.Empty(t, foundPath)
+	})
+
+	t.Run("invalid starting directory", func(t *testing.T) {
+		foundPath, found := LookupClosest("/invalid/path/that/does/not/exist", "target.txt")
+		require.False(t, found)
+		require.Empty(t, foundPath)
+	})
+
+	t.Run("relative path handling", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		// Change to test directory
+		oldWd, _ := os.Getwd()
+		err := os.Chdir(testDir)
+		require.NoError(t, err)
+		defer os.Chdir(oldWd)
+
+		// Create target file in current directory
+		err = os.WriteFile("target.txt", []byte("test"), 0o644)
+		require.NoError(t, err)
+
+		// Search using relative path
+		foundPath, found := LookupClosest(".", "target.txt")
+		require.True(t, found)
+
+		// Resolve symlinks to handle macOS /private/var vs /var discrepancy
+		expectedPath, err := filepath.EvalSymlinks(filepath.Join(testDir, "target.txt"))
+		require.NoError(t, err)
+		actualPath, err := filepath.EvalSymlinks(foundPath)
+		require.NoError(t, err)
+		require.Equal(t, expectedPath, actualPath)
+	})
+}
+
+func TestLookupClosestWithOwnership(t *testing.T) {
+	// Note: Testing ownership boundaries is difficult in a cross-platform way
+	// without creating complex directory structures with different owners.
+	// This test focuses on the basic functionality when ownership checks pass.
+
+	tempDir := t.TempDir()
+
+	// Change to temp directory
+	oldWd, _ := os.Getwd()
+	err := os.Chdir(tempDir)
+	require.NoError(t, err)
+
+	t.Cleanup(func() {
+		os.Chdir(oldWd)
+	})
+
+	t.Run("search respects same ownership", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		// Create subdirectory structure
+		subDir := filepath.Join(testDir, "subdir")
+		err := os.Mkdir(subDir, 0o755)
+		require.NoError(t, err)
+
+		// Create target file in parent directory
+		targetFile := filepath.Join(testDir, "target.txt")
+		err = os.WriteFile(targetFile, []byte("test"), 0o644)
+		require.NoError(t, err)
+
+		// Search should find the target assuming same ownership
+		foundPath, found := LookupClosest(subDir, "target.txt")
+		require.True(t, found)
+		require.Equal(t, targetFile, foundPath)
+	})
+}
+
+func TestLookup(t *testing.T) {
+	tempDir := t.TempDir()
+
+	// Change to temp directory
+	oldWd, _ := os.Getwd()
+	err := os.Chdir(tempDir)
+	require.NoError(t, err)
+
+	t.Cleanup(func() {
+		os.Chdir(oldWd)
+	})
+
+	t.Run("no targets returns empty slice", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		found, err := Lookup(testDir)
+		require.NoError(t, err)
+		require.Empty(t, found)
+	})
+
+	t.Run("single target found in starting directory", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		// Create target file in current directory
+		targetFile := filepath.Join(testDir, "target.txt")
+		err := os.WriteFile(targetFile, []byte("test"), 0o644)
+		require.NoError(t, err)
+
+		found, err := Lookup(testDir, "target.txt")
+		require.NoError(t, err)
+		require.Len(t, found, 1)
+		require.Equal(t, targetFile, found[0])
+	})
+
+	t.Run("multiple targets found in starting directory", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		// Create multiple target files in current directory
+		targetFile1 := filepath.Join(testDir, "target1.txt")
+		targetFile2 := filepath.Join(testDir, "target2.txt")
+		targetFile3 := filepath.Join(testDir, "target3.txt")
+
+		err := os.WriteFile(targetFile1, []byte("test1"), 0o644)
+		require.NoError(t, err)
+		err = os.WriteFile(targetFile2, []byte("test2"), 0o644)
+		require.NoError(t, err)
+		err = os.WriteFile(targetFile3, []byte("test3"), 0o644)
+		require.NoError(t, err)
+
+		found, err := Lookup(testDir, "target1.txt", "target2.txt", "target3.txt")
+		require.NoError(t, err)
+		require.Len(t, found, 3)
+		require.Contains(t, found, targetFile1)
+		require.Contains(t, found, targetFile2)
+		require.Contains(t, found, targetFile3)
+	})
+
+	t.Run("targets found in parent directories", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		// Create subdirectory
+		subDir := filepath.Join(testDir, "subdir")
+		err := os.Mkdir(subDir, 0o755)
+		require.NoError(t, err)
+
+		// Create target files in parent directory
+		targetFile1 := filepath.Join(testDir, "target1.txt")
+		targetFile2 := filepath.Join(testDir, "target2.txt")
+		err = os.WriteFile(targetFile1, []byte("test1"), 0o644)
+		require.NoError(t, err)
+		err = os.WriteFile(targetFile2, []byte("test2"), 0o644)
+		require.NoError(t, err)
+
+		found, err := Lookup(subDir, "target1.txt", "target2.txt")
+		require.NoError(t, err)
+		require.Len(t, found, 2)
+		require.Contains(t, found, targetFile1)
+		require.Contains(t, found, targetFile2)
+	})
+
+	t.Run("targets found across multiple directory levels", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		// Create nested subdirectories
+		subDir := filepath.Join(testDir, "subdir")
+		err := os.Mkdir(subDir, 0o755)
+		require.NoError(t, err)
+
+		subSubDir := filepath.Join(subDir, "subsubdir")
+		err = os.Mkdir(subSubDir, 0o755)
+		require.NoError(t, err)
+
+		// Create target files at different levels
+		targetFile1 := filepath.Join(testDir, "target1.txt")
+		targetFile2 := filepath.Join(subDir, "target2.txt")
+		targetFile3 := filepath.Join(subSubDir, "target3.txt")
+
+		err = os.WriteFile(targetFile1, []byte("test1"), 0o644)
+		require.NoError(t, err)
+		err = os.WriteFile(targetFile2, []byte("test2"), 0o644)
+		require.NoError(t, err)
+		err = os.WriteFile(targetFile3, []byte("test3"), 0o644)
+		require.NoError(t, err)
+
+		found, err := Lookup(subSubDir, "target1.txt", "target2.txt", "target3.txt")
+		require.NoError(t, err)
+		require.Len(t, found, 3)
+		require.Contains(t, found, targetFile1)
+		require.Contains(t, found, targetFile2)
+		require.Contains(t, found, targetFile3)
+	})
+
+	t.Run("some targets not found", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		// Create only some target files
+		targetFile1 := filepath.Join(testDir, "target1.txt")
+		targetFile2 := filepath.Join(testDir, "target2.txt")
+
+		err := os.WriteFile(targetFile1, []byte("test1"), 0o644)
+		require.NoError(t, err)
+		err = os.WriteFile(targetFile2, []byte("test2"), 0o644)
+		require.NoError(t, err)
+
+		// Search for existing and non-existing targets
+		found, err := Lookup(testDir, "target1.txt", "nonexistent.txt", "target2.txt", "another_nonexistent.txt")
+		require.NoError(t, err)
+		require.Len(t, found, 2)
+		require.Contains(t, found, targetFile1)
+		require.Contains(t, found, targetFile2)
+	})
+
+	t.Run("no targets found", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		found, err := Lookup(testDir, "nonexistent1.txt", "nonexistent2.txt", "nonexistent3.txt")
+		require.NoError(t, err)
+		require.Empty(t, found)
+	})
+
+	t.Run("target directories found", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		// Create target directories
+		targetDir1 := filepath.Join(testDir, "targetdir1")
+		targetDir2 := filepath.Join(testDir, "targetdir2")
+		err := os.Mkdir(targetDir1, 0o755)
+		require.NoError(t, err)
+		err = os.Mkdir(targetDir2, 0o755)
+		require.NoError(t, err)
+
+		found, err := Lookup(testDir, "targetdir1", "targetdir2")
+		require.NoError(t, err)
+		require.Len(t, found, 2)
+		require.Contains(t, found, targetDir1)
+		require.Contains(t, found, targetDir2)
+	})
+
+	t.Run("mixed files and directories", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		// Create target files and directories
+		targetFile := filepath.Join(testDir, "target.txt")
+		targetDir := filepath.Join(testDir, "targetdir")
+		err := os.WriteFile(targetFile, []byte("test"), 0o644)
+		require.NoError(t, err)
+		err = os.Mkdir(targetDir, 0o755)
+		require.NoError(t, err)
+
+		found, err := Lookup(testDir, "target.txt", "targetdir")
+		require.NoError(t, err)
+		require.Len(t, found, 2)
+		require.Contains(t, found, targetFile)
+		require.Contains(t, found, targetDir)
+	})
+
+	t.Run("invalid starting directory", func(t *testing.T) {
+		found, err := Lookup("/invalid/path/that/does/not/exist", "target.txt")
+		require.Error(t, err)
+		require.Empty(t, found)
+	})
+
+	t.Run("relative path handling", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		// Change to test directory
+		oldWd, _ := os.Getwd()
+		err := os.Chdir(testDir)
+		require.NoError(t, err)
+
+		t.Cleanup(func() {
+			os.Chdir(oldWd)
+		})
+
+		// Create target files in current directory
+		err = os.WriteFile("target1.txt", []byte("test1"), 0o644)
+		require.NoError(t, err)
+		err = os.WriteFile("target2.txt", []byte("test2"), 0o644)
+		require.NoError(t, err)
+
+		// Search using relative path
+		found, err := Lookup(".", "target1.txt", "target2.txt")
+		require.NoError(t, err)
+		require.Len(t, found, 2)
+
+		// Resolve symlinks to handle macOS /private/var vs /var discrepancy
+		expectedPath1, err := filepath.EvalSymlinks(filepath.Join(testDir, "target1.txt"))
+		require.NoError(t, err)
+		expectedPath2, err := filepath.EvalSymlinks(filepath.Join(testDir, "target2.txt"))
+		require.NoError(t, err)
+
+		// Check that found paths match expected paths (order may vary)
+		foundEvalSymlinks := make([]string, len(found))
+		for i, path := range found {
+			evalPath, err := filepath.EvalSymlinks(path)
+			require.NoError(t, err)
+			foundEvalSymlinks[i] = evalPath
+		}
+
+		require.Contains(t, foundEvalSymlinks, expectedPath1)
+		require.Contains(t, foundEvalSymlinks, expectedPath2)
+	})
+}
+
+func TestProbeEnt(t *testing.T) {
+	t.Run("existing file with correct owner", func(t *testing.T) {
+		tempDir := t.TempDir()
+
+		// Create test file
+		testFile := filepath.Join(tempDir, "test.txt")
+		err := os.WriteFile(testFile, []byte("test"), 0o644)
+		require.NoError(t, err)
+
+		// Get owner of temp directory
+		owner, err := Owner(tempDir)
+		require.NoError(t, err)
+
+		// Test probeEnt with correct owner
+		err = probeEnt(testFile, owner)
+		require.NoError(t, err)
+	})
+
+	t.Run("existing directory with correct owner", func(t *testing.T) {
+		tempDir := t.TempDir()
+
+		// Create test directory
+		testDir := filepath.Join(tempDir, "testdir")
+		err := os.Mkdir(testDir, 0o755)
+		require.NoError(t, err)
+
+		// Get owner of temp directory
+		owner, err := Owner(tempDir)
+		require.NoError(t, err)
+
+		// Test probeEnt with correct owner
+		err = probeEnt(testDir, owner)
+		require.NoError(t, err)
+	})
+
+	t.Run("nonexistent file", func(t *testing.T) {
+		tempDir := t.TempDir()
+
+		nonexistentFile := filepath.Join(tempDir, "nonexistent.txt")
+		owner, err := Owner(tempDir)
+		require.NoError(t, err)
+
+		err = probeEnt(nonexistentFile, owner)
+		require.Error(t, err)
+		require.True(t, errors.Is(err, os.ErrNotExist))
+	})
+
+	t.Run("nonexistent file in nonexistent directory", func(t *testing.T) {
+		nonexistentFile := "/this/directory/does/not/exists/nonexistent.txt"
+
+		err := probeEnt(nonexistentFile, -1)
+		require.Error(t, err)
+		require.True(t, errors.Is(err, os.ErrNotExist))
+	})
+
+	t.Run("ownership bypass with -1", func(t *testing.T) {
+		tempDir := t.TempDir()
+
+		// Create test file
+		testFile := filepath.Join(tempDir, "test.txt")
+		err := os.WriteFile(testFile, []byte("test"), 0o644)
+		require.NoError(t, err)
+
+		// Test probeEnt with -1 (bypass ownership check)
+		err = probeEnt(testFile, -1)
+		require.NoError(t, err)
+	})
+
+	t.Run("ownership mismatch returns permission error", func(t *testing.T) {
+		tempDir := t.TempDir()
+
+		// Create test file
+		testFile := filepath.Join(tempDir, "test.txt")
+		err := os.WriteFile(testFile, []byte("test"), 0o644)
+		require.NoError(t, err)
+
+		// Test probeEnt with different owner (use 9999 which is unlikely to be the actual owner)
+		err = probeEnt(testFile, 9999)
+		require.Error(t, err)
+		require.True(t, errors.Is(err, os.ErrPermission))
+	})
+}

internal/fsext/owner_windows.go 🔗

@@ -2,8 +2,14 @@
 
 package fsext
 
+import "os"
+
 // Owner retrieves the user ID of the owner of the file or directory at the
 // specified path.
 func Owner(path string) (int, error) {
+	_, err := os.Stat(path)
+	if err != nil {
+		return 0, err
+	}
 	return -1, nil
 }

internal/fsext/parent.go 🔗

@@ -1,60 +0,0 @@
-package fsext
-
-import (
-	"errors"
-	"os"
-	"path/filepath"
-
-	"github.com/charmbracelet/crush/internal/home"
-)
-
-// SearchParent searches for a target file or directory starting from dir
-// and walking up the directory tree until found or root or home is reached.
-// It also checks the ownership of directories to ensure that the search does
-// not cross ownership boundaries.
-// Returns the full path to the target if found, empty string and false otherwise.
-// The search includes the starting directory itself.
-func SearchParent(dir, target string) (string, bool) {
-	absDir, err := filepath.Abs(dir)
-	if err != nil {
-		return "", false
-	}
-
-	path := filepath.Join(absDir, target)
-	if _, err := os.Stat(path); err == nil {
-		return path, true
-	} else if !errors.Is(err, os.ErrNotExist) {
-		return "", false
-	}
-
-	previousParent := absDir
-	previousOwner, err := Owner(previousParent)
-	if err != nil {
-		return "", false
-	}
-
-	for {
-		parent := filepath.Dir(previousParent)
-		if parent == previousParent || parent == home.Dir() {
-			return "", false
-		}
-
-		parentOwner, err := Owner(parent)
-		if err != nil {
-			return "", false
-		}
-		if parentOwner != previousOwner {
-			return "", false
-		}
-
-		path := filepath.Join(parent, target)
-		if _, err := os.Stat(path); err == nil {
-			return path, true
-		} else if !errors.Is(err, os.ErrNotExist) {
-			return "", false
-		}
-
-		previousParent = parent
-		previousOwner = parentOwner
-	}
-}

internal/home/home.go 🔗

@@ -1,3 +1,4 @@
+// Package home provides utilities for dealing with the user's home directory.
 package home
 
 import (

internal/llm/agent/mcp-tools.go 🔗

@@ -15,6 +15,7 @@ import (
 
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/llm/tools"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
@@ -180,13 +181,14 @@ func getOrRenewClient(ctx context.Context, name string) (*client.Client, error)
 	m := config.Get().MCP[name]
 	state, _ := mcpStates.Get(name)
 
-	pingCtx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
+	timeout := mcpTimeout(m)
+	pingCtx, cancel := context.WithTimeout(ctx, timeout)
 	defer cancel()
 	err := c.Ping(pingCtx)
 	if err == nil {
 		return c, nil
 	}
-	updateMCPState(name, MCPStateError, err, nil, state.ToolCount)
+	updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, state.ToolCount)
 
 	c, err = createAndInitializeClient(ctx, name, m)
 	if err != nil {
@@ -362,17 +364,22 @@ func createAndInitializeClient(ctx context.Context, name string, m config.MCPCon
 		slog.Error("error creating mcp client", "error", err, "name", name)
 		return nil, err
 	}
+
+	timeout := mcpTimeout(m)
+	initCtx, cancel := context.WithTimeout(ctx, timeout)
+	defer cancel()
+
 	// Only call Start() for non-stdio clients, as stdio clients auto-start
 	if m.Type != config.MCPStdio {
-		if err := c.Start(ctx); err != nil {
-			updateMCPState(name, MCPStateError, err, nil, 0)
+		if err := c.Start(initCtx); err != nil {
+			updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
 			slog.Error("error starting mcp client", "error", err, "name", name)
 			_ = c.Close()
 			return nil, err
 		}
 	}
-	if _, err := c.Initialize(ctx, mcpInitRequest); err != nil {
-		updateMCPState(name, MCPStateError, err, nil, 0)
+	if _, err := c.Initialize(initCtx, mcpInitRequest); err != nil {
+		updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
 		slog.Error("error initializing mcp client", "error", err, "name", name)
 		_ = c.Close()
 		return nil, err
@@ -382,6 +389,13 @@ func createAndInitializeClient(ctx context.Context, name string, m config.MCPCon
 	return c, nil
 }
 
+func maybeTimeoutErr(err error, timeout time.Duration) error {
+	if errors.Is(err, context.DeadlineExceeded) {
+		return fmt.Errorf("timed out after %s", timeout)
+	}
+	return err
+}
+
 func createMcpClient(name string, m config.MCPConfig) (*client.Client, error) {
 	switch m.Type {
 	case config.MCPStdio:
@@ -389,7 +403,7 @@ func createMcpClient(name string, m config.MCPConfig) (*client.Client, error) {
 			return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
 		}
 		return client.NewStdioMCPClientWithOptions(
-			m.Command,
+			home.Long(m.Command),
 			m.ResolvedEnv(),
 			m.Args,
 			transport.WithCommandLogger(mcpLogger{name: name}),

internal/llm/tools/grep.go 🔗

@@ -259,18 +259,16 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr
 			continue
 		}
 
-		// Parse ripgrep output format: file:line:content
-		parts := strings.SplitN(line, ":", 3)
-		if len(parts) < 3 {
+		// Parse ripgrep output using null separation
+		filePath, lineNumStr, lineText, ok := parseRipgrepLine(line)
+		if !ok {
 			continue
 		}
 
-		filePath := parts[0]
-		lineNum, err := strconv.Atoi(parts[1])
+		lineNum, err := strconv.Atoi(lineNumStr)
 		if err != nil {
 			continue
 		}
-		lineText := parts[2]
 
 		fileInfo, err := os.Stat(filePath)
 		if err != nil {
@@ -288,6 +286,33 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr
 	return matches, nil
 }
 
+// parseRipgrepLine parses ripgrep output with null separation to handle Windows paths
+func parseRipgrepLine(line string) (filePath, lineNum, lineText string, ok bool) {
+	// Split on null byte first to separate filename from rest
+	parts := strings.SplitN(line, "\x00", 2)
+	if len(parts) != 2 {
+		return "", "", "", false
+	}
+
+	filePath = parts[0]
+	remainder := parts[1]
+
+	// Now split the remainder on first colon: "linenum:content"
+	colonIndex := strings.Index(remainder, ":")
+	if colonIndex == -1 {
+		return "", "", "", false
+	}
+
+	lineNumStr := remainder[:colonIndex]
+	lineText = remainder[colonIndex+1:]
+
+	if _, err := strconv.Atoi(lineNumStr); err != nil {
+		return "", "", "", false
+	}
+
+	return filePath, lineNumStr, lineText, true
+}
+
 func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
 	matches := []grepMatch{}
 

internal/llm/tools/rg.go 🔗

@@ -42,8 +42,8 @@ func getRgSearchCmd(ctx context.Context, pattern, path, include string) *exec.Cm
 	if name == "" {
 		return nil
 	}
-	// Use -n to show line numbers and include the matched line
-	args := []string{"-H", "-n", pattern}
+	// Use -n to show line numbers, -0 for null separation to handle Windows paths
+	args := []string{"-H", "-n", "-0", pattern}
 	if include != "" {
 		args = append(args, "--glob", include)
 	}

internal/lsp/client.go 🔗

@@ -15,6 +15,7 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/home"
 	powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 	"github.com/charmbracelet/x/powernap/pkg/transport"
@@ -55,7 +56,7 @@ func New(ctx context.Context, name string, config config.LSPConfig) (*Client, er
 
 	// Create powernap client config
 	clientConfig := powernap.ClientConfig{
-		Command: config.Command,
+		Command: home.Long(config.Command),
 		Args:    config.Args,
 		RootURI: rootURI,
 		Environment: func() map[string]string {