Detailed changes
@@ -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: >-
@@ -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)"
}
@@ -1,8 +1,6 @@
{
"$schema": "https://charm.land/crush.json",
"lsp": {
- "Go": {
- "command": "gopls"
- }
+ "gopls": {}
}
}
@@ -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"`
@@ -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
@@ -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{
{
@@ -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
+}
@@ -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))
+ })
+}
@@ -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
}
@@ -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
- }
-}
@@ -1,3 +1,4 @@
+// Package home provides utilities for dealing with the user's home directory.
package home
import (
@@ -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}),
@@ -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{}
@@ -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)
}
@@ -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 {