diff --git a/.goreleaser.yml b/.goreleaser.yml index 7c477b587c324bbcf66883baf385e7caf984c872..c0da1c50aec71d899b0cffe09be64e3756e92f51 100644 --- a/.goreleaser.yml +++ b/.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: >- diff --git a/README.md b/README.md index e89c651de360e0bcfa0336f4a726b60ea9635d31..a2b07093c1e45162018614411ba7a4300f9ef680 100644 --- a/README.md +++ b/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)" } diff --git a/crush.json b/crush.json index ba4dc18bc63381ad4bdbca5470a1527986c74205..f5daef89add28ad4924c2bb87ca70020af005d67 100644 --- a/crush.json +++ b/crush.json @@ -1,8 +1,6 @@ { "$schema": "https://charm.land/crush.json", "lsp": { - "Go": { - "command": "gopls" - } + "gopls": {} } } diff --git a/internal/config/config.go b/internal/config/config.go index 67378e9ff00356358bfedd403aacd655b763cfc6..8e4b8e5437e31af351b14b7330ab1bf4326b4863 100644 --- a/internal/config/config.go +++ b/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"` diff --git a/internal/config/load.go b/internal/config/load.go index 9ac5411f0b1697ce96453c72a01defe219c19a37..59cef29e24d94d4d74d6be39953133b2e91efdf0 100644 --- a/internal/config/load.go +++ b/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 diff --git a/internal/config/load_test.go b/internal/config/load_test.go index e0ce94f3995fb64cc8f66348723a4e6c62a0ea2b..90276c96ad113f453ed699c8deeb30b4f5fef9d5 100644 --- a/internal/config/load_test.go +++ b/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{ { diff --git a/internal/fsext/lookup.go b/internal/fsext/lookup.go new file mode 100644 index 0000000000000000000000000000000000000000..098426571c69521a5978a2c2e0a4178b51b0aae6 --- /dev/null +++ b/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 +} diff --git a/internal/fsext/lookup_test.go b/internal/fsext/lookup_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b7604331673aad0d65d34e046901bc9eae722195 --- /dev/null +++ b/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)) + }) +} diff --git a/internal/fsext/owner_windows.go b/internal/fsext/owner_windows.go index 107cda009b5fc152cba3200271c7145ff3227a39..41f9091c3e75e8f187984a8e1ddb7a7aa72c9dab 100644 --- a/internal/fsext/owner_windows.go +++ b/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 } diff --git a/internal/fsext/parent.go b/internal/fsext/parent.go deleted file mode 100644 index bd3193610a79cbc80b5bb2c1d75be32a819f34f5..0000000000000000000000000000000000000000 --- a/internal/fsext/parent.go +++ /dev/null @@ -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 - } -} diff --git a/internal/home/home.go b/internal/home/home.go index f2a9b73b922abd8f027ba68655afc68f42a58b09..74ab5594bf19377a86e6e96cae298a91b4858cff 100644 --- a/internal/home/home.go +++ b/internal/home/home.go @@ -1,3 +1,4 @@ +// Package home provides utilities for dealing with the user's home directory. package home import ( diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 095ea0b9a8de9e3a2e7e0565e9c59a1cf6623774..faf67591c692f22540ee368ed75e7a4f8c56d00d 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/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}), diff --git a/internal/llm/tools/grep.go b/internal/llm/tools/grep.go index 30d6e0b16a06c28aa33783f76fcdaa5ccb800915..cbf50360b9355c05797690678a99d1310b19556f 100644 --- a/internal/llm/tools/grep.go +++ b/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{} diff --git a/internal/llm/tools/rg.go b/internal/llm/tools/rg.go index 40ab7f2f520697659e3ef092a7ff3e96b2c3c47c..8809b57c8db30b4ac1ed6c070df5a7218c59e233 100644 --- a/internal/llm/tools/rg.go +++ b/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) } diff --git a/internal/lsp/client.go b/internal/lsp/client.go index db2042efd5dfc84738afeb6895a3e916ea95a1de..16eeebb97989472b8743d1e777eb9cba89b04527 100644 --- a/internal/lsp/client.go +++ b/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 {