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}