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