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.NewAgentTool(
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 sessionID, messageID := GetContextValues(ctx)
109 if sessionID == "" || messageID == "" {
110 return ai.ToolResponse{}, fmt.Errorf("session ID and message ID are required for the ls tool")
111 }
112 granted := permissions.Request(
113 permission.CreatePermissionRequest{
114 SessionID: sessionID,
115 ToolCallID: call.ID,
116 ToolName: LSToolName,
117 Path: absSearchPath,
118 Action: "list",
119 Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
120 Params: LSPermissionsParams(params),
121 })
122
123 if !granted {
124 return ai.ToolResponse{}, permission.ErrorPermissionDenied
125 }
126 }
127 output, err := ListDirectoryTree(searchPath, params.Ignore)
128 if err != nil {
129 return ai.ToolResponse{}, err
130 }
131
132 // Get file count for metadata
133 files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
134 if err != nil {
135 return ai.ToolResponse{}, fmt.Errorf("error listing directory for metadata: %w", err)
136 }
137
138 return ai.WithResponseMetadata(
139 ai.NewTextResponse(output),
140 LSResponseMetadata{
141 NumberOfFiles: len(files),
142 Truncated: truncated,
143 },
144 ), nil
145 },
146 )
147}
148
149func ListDirectoryTree(searchPath string, ignore []string) (string, error) {
150 if _, err := os.Stat(searchPath); os.IsNotExist(err) {
151 return "", fmt.Errorf("path does not exist: %s", searchPath)
152 }
153
154 files, truncated, err := fsext.ListDirectory(searchPath, ignore, MaxLSFiles)
155 if err != nil {
156 return "", fmt.Errorf("error listing directory: %w", err)
157 }
158
159 tree := createFileTree(files, searchPath)
160 output := printTree(tree, searchPath)
161
162 if truncated {
163 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)
164 }
165
166 return output, nil
167}
168
169func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
170 root := []*TreeNode{}
171 pathMap := make(map[string]*TreeNode)
172
173 for _, path := range sortedPaths {
174 relativePath := strings.TrimPrefix(path, rootPath)
175 parts := strings.Split(relativePath, string(filepath.Separator))
176 currentPath := ""
177 var parentPath string
178
179 var cleanParts []string
180 for _, part := range parts {
181 if part != "" {
182 cleanParts = append(cleanParts, part)
183 }
184 }
185 parts = cleanParts
186
187 if len(parts) == 0 {
188 continue
189 }
190
191 for i, part := range parts {
192 if currentPath == "" {
193 currentPath = part
194 } else {
195 currentPath = filepath.Join(currentPath, part)
196 }
197
198 if _, exists := pathMap[currentPath]; exists {
199 parentPath = currentPath
200 continue
201 }
202
203 isLastPart := i == len(parts)-1
204 isDir := !isLastPart || strings.HasSuffix(relativePath, string(filepath.Separator))
205 nodeType := "file"
206 if isDir {
207 nodeType = "directory"
208 }
209 newNode := &TreeNode{
210 Name: part,
211 Path: currentPath,
212 Type: nodeType,
213 Children: []*TreeNode{},
214 }
215
216 pathMap[currentPath] = newNode
217
218 if i > 0 && parentPath != "" {
219 if parent, ok := pathMap[parentPath]; ok {
220 parent.Children = append(parent.Children, newNode)
221 }
222 } else {
223 root = append(root, newNode)
224 }
225
226 parentPath = currentPath
227 }
228 }
229
230 return root
231}
232
233func printTree(tree []*TreeNode, rootPath string) string {
234 var result strings.Builder
235
236 result.WriteString("- ")
237 result.WriteString(rootPath)
238 if rootPath[len(rootPath)-1] != '/' {
239 result.WriteByte(filepath.Separator)
240 }
241 result.WriteByte('\n')
242
243 for _, node := range tree {
244 printNode(&result, node, 1)
245 }
246
247 return result.String()
248}
249
250func printNode(builder *strings.Builder, node *TreeNode, level int) {
251 indent := strings.Repeat(" ", level)
252
253 nodeName := node.Name
254 if node.Type == "directory" {
255 nodeName = nodeName + string(filepath.Separator)
256 }
257
258 fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
259
260 if node.Type == "directory" && len(node.Children) > 0 {
261 for _, child := range node.Children {
262 printNode(builder, child, level+1)
263 }
264 }
265}