ls.go

  1package tools
  2
  3import (
  4	"context"
  5	"fmt"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9
 10	"github.com/charmbracelet/crush/internal/ai"
 11	"github.com/charmbracelet/crush/internal/fsext"
 12)
 13
 14const (
 15	MaxLSFiles = 1000
 16	LSToolName = "ls"
 17)
 18
 19type LSParams struct {
 20	Path   string   `json:"path" description:"The path to the directory to list (defaults to current working directory)"`
 21	Ignore []string `json:"ignore,omitempty" description:"List of glob patterns to ignore"`
 22}
 23
 24type LSPermissionsParams struct {
 25	Path   string   `json:"path"`
 26	Ignore []string `json:"ignore"`
 27}
 28
 29type TreeNode struct {
 30	Name     string      `json:"name"`
 31	Path     string      `json:"path"`
 32	Type     string      `json:"type"`
 33	Children []*TreeNode `json:"children,omitempty"`
 34}
 35
 36type LSResponseMetadata struct {
 37	NumberOfFiles int  `json:"number_of_files"`
 38	Truncated     bool `json:"truncated"`
 39}
 40
 41func NewLSTool(permissionAsk PermissionAsk, workingDir string) ai.AgentTool {
 42	return ai.NewTypedToolFunc(
 43		LSToolName,
 44		`Directory listing tool that shows files and subdirectories in a tree structure, helping you explore and understand the project organization.
 45
 46WHEN TO USE THIS TOOL:
 47- Use when you need to explore the structure of a directory
 48- Helpful for understanding the organization of a project
 49- Good first step when getting familiar with a new codebase
 50
 51HOW TO USE:
 52- Provide a path to list (defaults to current working directory)
 53- Optionally specify glob patterns to ignore
 54- Results are displayed in a tree structure
 55
 56FEATURES:
 57- Displays a hierarchical view of files and directories
 58- Automatically skips hidden files/directories (starting with '.')
 59- Skips common system directories like __pycache__
 60- Can filter out files matching specific patterns
 61
 62LIMITATIONS:
 63- Results are limited to 1000 files
 64- Very large directories will be truncated
 65- Does not show file sizes or permissions
 66- Cannot recursively list all directories in a large project
 67
 68WINDOWS NOTES:
 69- Hidden file detection uses Unix convention (files starting with '.')
 70- Windows-specific hidden files (with hidden attribute) are not automatically skipped
 71- Common Windows directories like System32, Program Files are not in default ignore list
 72- Path separators are handled automatically (both / and \ work)
 73
 74TIPS:
 75- Use Glob tool for finding files by name patterns instead of browsing
 76- Use Grep tool for searching file contents
 77- Combine with other tools for more effective exploration`,
 78		func(ctx context.Context, params LSParams, call ai.ToolCall) (ai.ToolResponse, error) {
 79			searchPath := params.Path
 80			if searchPath == "" {
 81				searchPath = workingDir
 82			}
 83
 84			var err error
 85			searchPath, err = fsext.Expand(searchPath)
 86			if err != nil {
 87				return ai.ToolResponse{}, fmt.Errorf("error expanding path: %w", err)
 88			}
 89
 90			if !filepath.IsAbs(searchPath) {
 91				searchPath = filepath.Join(workingDir, searchPath)
 92			}
 93
 94			// Check if directory is outside working directory and request permission if needed
 95			absWorkingDir, err := filepath.Abs(workingDir)
 96			if err != nil {
 97				return ai.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
 98			}
 99
100			absSearchPath, err := filepath.Abs(searchPath)
101			if err != nil {
102				return ai.ToolResponse{}, fmt.Errorf("error resolving search path: %w", err)
103			}
104
105			relPath, err := filepath.Rel(absWorkingDir, absSearchPath)
106			if err != nil || strings.HasPrefix(relPath, "..") {
107				granted := permissionAsk(Permission{
108					ToolCallID:  call.ID,
109					ToolName:    LSToolName,
110					Path:        absSearchPath,
111					Action:      "list",
112					Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
113					Params:      LSPermissionsParams(params),
114				})
115
116				if !granted {
117					return ai.ToolResponse{}, ErrorPermissionDenied
118				}
119			}
120			output, err := ListDirectoryTree(searchPath, params.Ignore)
121			if err != nil {
122				return ai.ToolResponse{}, err
123			}
124
125			// Get file count for metadata
126			files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
127			if err != nil {
128				return ai.ToolResponse{}, fmt.Errorf("error listing directory for metadata: %w", err)
129			}
130
131			return ai.WithResponseMetadata(
132				ai.NewTextResponse(output),
133				LSResponseMetadata{
134					NumberOfFiles: len(files),
135					Truncated:     truncated,
136				},
137			), nil
138		},
139	)
140}
141
142func ListDirectoryTree(searchPath string, ignore []string) (string, error) {
143	if _, err := os.Stat(searchPath); os.IsNotExist(err) {
144		return "", fmt.Errorf("path does not exist: %s", searchPath)
145	}
146
147	files, truncated, err := fsext.ListDirectory(searchPath, ignore, MaxLSFiles)
148	if err != nil {
149		return "", fmt.Errorf("error listing directory: %w", err)
150	}
151
152	tree := createFileTree(files, searchPath)
153	output := printTree(tree, searchPath)
154
155	if truncated {
156		output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output)
157	}
158
159	return output, nil
160}
161
162func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
163	root := []*TreeNode{}
164	pathMap := make(map[string]*TreeNode)
165
166	for _, path := range sortedPaths {
167		relativePath := strings.TrimPrefix(path, rootPath)
168		parts := strings.Split(relativePath, string(filepath.Separator))
169		currentPath := ""
170		var parentPath string
171
172		var cleanParts []string
173		for _, part := range parts {
174			if part != "" {
175				cleanParts = append(cleanParts, part)
176			}
177		}
178		parts = cleanParts
179
180		if len(parts) == 0 {
181			continue
182		}
183
184		for i, part := range parts {
185			if currentPath == "" {
186				currentPath = part
187			} else {
188				currentPath = filepath.Join(currentPath, part)
189			}
190
191			if _, exists := pathMap[currentPath]; exists {
192				parentPath = currentPath
193				continue
194			}
195
196			isLastPart := i == len(parts)-1
197			isDir := !isLastPart || strings.HasSuffix(relativePath, string(filepath.Separator))
198			nodeType := "file"
199			if isDir {
200				nodeType = "directory"
201			}
202			newNode := &TreeNode{
203				Name:     part,
204				Path:     currentPath,
205				Type:     nodeType,
206				Children: []*TreeNode{},
207			}
208
209			pathMap[currentPath] = newNode
210
211			if i > 0 && parentPath != "" {
212				if parent, ok := pathMap[parentPath]; ok {
213					parent.Children = append(parent.Children, newNode)
214				}
215			} else {
216				root = append(root, newNode)
217			}
218
219			parentPath = currentPath
220		}
221	}
222
223	return root
224}
225
226func printTree(tree []*TreeNode, rootPath string) string {
227	var result strings.Builder
228
229	result.WriteString("- ")
230	result.WriteString(rootPath)
231	if rootPath[len(rootPath)-1] != '/' {
232		result.WriteByte(filepath.Separator)
233	}
234	result.WriteByte('\n')
235
236	for _, node := range tree {
237		printNode(&result, node, 1)
238	}
239
240	return result.String()
241}
242
243func printNode(builder *strings.Builder, node *TreeNode, level int) {
244	indent := strings.Repeat("  ", level)
245
246	nodeName := node.Name
247	if node.Type == "directory" {
248		nodeName = nodeName + string(filepath.Separator)
249	}
250
251	fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
252
253	if node.Type == "directory" && len(node.Children) > 0 {
254		for _, child := range node.Children {
255			printNode(builder, child, level+1)
256		}
257	}
258}