1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10
11 "github.com/kujtimiihoxha/termai/internal/config"
12)
13
14type lsTool struct{}
15
16const (
17 LSToolName = "ls"
18 MaxLSFiles = 1000
19)
20
21type LSParams struct {
22 Path string `json:"path"`
23 Ignore []string `json:"ignore"`
24}
25
26type TreeNode struct {
27 Name string `json:"name"`
28 Path string `json:"path"`
29 Type string `json:"type"` // "file" or "directory"
30 Children []*TreeNode `json:"children,omitempty"`
31}
32
33func (l *lsTool) Info() ToolInfo {
34 return ToolInfo{
35 Name: LSToolName,
36 Description: lsDescription(),
37 Parameters: map[string]any{
38 "path": map[string]any{
39 "type": "string",
40 "description": "The path to the directory to list (defaults to current working directory)",
41 },
42 "ignore": map[string]any{
43 "type": "array",
44 "description": "List of glob patterns to ignore",
45 "items": map[string]any{
46 "type": "string",
47 },
48 },
49 },
50 Required: []string{"path"},
51 }
52}
53
54// Run implements Tool.
55func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
56 var params LSParams
57 if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
58 return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
59 }
60
61 // If path is empty, use current working directory
62 searchPath := params.Path
63 if searchPath == "" {
64 searchPath = config.WorkingDirectory()
65 }
66
67 // Ensure the path is absolute
68 if !filepath.IsAbs(searchPath) {
69 searchPath = filepath.Join(config.WorkingDirectory(), searchPath)
70 }
71
72 // Check if the path exists
73 if _, err := os.Stat(searchPath); os.IsNotExist(err) {
74 return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
75 }
76
77 files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles)
78 if err != nil {
79 return NewTextErrorResponse(fmt.Sprintf("error listing directory: %s", err)), nil
80 }
81
82 tree := createFileTree(files)
83 output := printTree(tree, searchPath)
84
85 if truncated {
86 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)
87 }
88
89 return NewTextResponse(output), nil
90}
91
92func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
93 var results []string
94 truncated := false
95
96 err := filepath.Walk(initialPath, func(path string, info os.FileInfo, err error) error {
97 if err != nil {
98 return nil // Skip files we don't have permission to access
99 }
100
101 if shouldSkip(path, ignorePatterns) {
102 if info.IsDir() {
103 return filepath.SkipDir
104 }
105 return nil
106 }
107
108 if path != initialPath {
109 if info.IsDir() {
110 path = path + string(filepath.Separator)
111 }
112 results = append(results, path)
113 }
114
115 if len(results) >= limit {
116 truncated = true
117 return filepath.SkipAll
118 }
119
120 return nil
121 })
122 if err != nil {
123 return nil, truncated, err
124 }
125
126 return results, truncated, nil
127}
128
129func shouldSkip(path string, ignorePatterns []string) bool {
130 base := filepath.Base(path)
131
132 // Skip hidden files and directories
133 if base != "." && strings.HasPrefix(base, ".") {
134 return true
135 }
136
137 // Skip common directories and files
138 commonIgnored := []string{
139 "__pycache__",
140 "node_modules",
141 "dist",
142 "build",
143 "target",
144 "vendor",
145 "bin",
146 "obj",
147 ".git",
148 ".idea",
149 ".vscode",
150 ".DS_Store",
151 "*.pyc",
152 "*.pyo",
153 "*.pyd",
154 "*.so",
155 "*.dll",
156 "*.exe",
157 }
158
159 // Skip __pycache__ directories
160 if strings.Contains(path, filepath.Join("__pycache__", "")) {
161 return true
162 }
163
164 // Check against common ignored patterns
165 for _, ignored := range commonIgnored {
166 if strings.HasSuffix(ignored, "/") {
167 // Directory pattern
168 if strings.Contains(path, filepath.Join(ignored[:len(ignored)-1], "")) {
169 return true
170 }
171 } else if strings.HasPrefix(ignored, "*.") {
172 // File extension pattern
173 if strings.HasSuffix(base, ignored[1:]) {
174 return true
175 }
176 } else {
177 // Exact match
178 if base == ignored {
179 return true
180 }
181 }
182 }
183
184 // Check against ignore patterns
185 for _, pattern := range ignorePatterns {
186 matched, err := filepath.Match(pattern, base)
187 if err == nil && matched {
188 return true
189 }
190 }
191
192 return false
193}
194
195func createFileTree(sortedPaths []string) []*TreeNode {
196 root := []*TreeNode{}
197 pathMap := make(map[string]*TreeNode)
198
199 for _, path := range sortedPaths {
200 parts := strings.Split(path, string(filepath.Separator))
201 currentPath := ""
202 var parentPath string
203
204 var cleanParts []string
205 for _, part := range parts {
206 if part != "" {
207 cleanParts = append(cleanParts, part)
208 }
209 }
210 parts = cleanParts
211
212 if len(parts) == 0 {
213 continue
214 }
215
216 for i, part := range parts {
217 if currentPath == "" {
218 currentPath = part
219 } else {
220 currentPath = filepath.Join(currentPath, part)
221 }
222
223 if _, exists := pathMap[currentPath]; exists {
224 parentPath = currentPath
225 continue
226 }
227
228 isLastPart := i == len(parts)-1
229 isDir := !isLastPart || strings.HasSuffix(path, string(filepath.Separator))
230 nodeType := "file"
231 if isDir {
232 nodeType = "directory"
233 }
234 newNode := &TreeNode{
235 Name: part,
236 Path: currentPath,
237 Type: nodeType,
238 Children: []*TreeNode{},
239 }
240
241 pathMap[currentPath] = newNode
242
243 if i > 0 && parentPath != "" {
244 if parent, ok := pathMap[parentPath]; ok {
245 parent.Children = append(parent.Children, newNode)
246 }
247 } else {
248 root = append(root, newNode)
249 }
250
251 parentPath = currentPath
252 }
253 }
254
255 return root
256}
257
258func printTree(tree []*TreeNode, rootPath string) string {
259 var result strings.Builder
260
261 result.WriteString(fmt.Sprintf("- %s%s\n", rootPath, string(filepath.Separator)))
262
263 for _, node := range tree {
264 printNode(&result, node, 1)
265 }
266
267 return result.String()
268}
269
270func printNode(builder *strings.Builder, node *TreeNode, level int) {
271 indent := strings.Repeat(" ", level)
272
273 nodeName := node.Name
274 if node.Type == "directory" {
275 nodeName += string(filepath.Separator)
276 }
277
278 fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
279
280 if node.Type == "directory" && len(node.Children) > 0 {
281 for _, child := range node.Children {
282 printNode(builder, child, level+1)
283 }
284 }
285}
286
287func lsDescription() string {
288 return `Directory listing tool that shows files and subdirectories in a tree structure, helping you explore and understand the project organization.
289
290WHEN TO USE THIS TOOL:
291- Use when you need to explore the structure of a directory
292- Helpful for understanding the organization of a project
293- Good first step when getting familiar with a new codebase
294
295HOW TO USE:
296- Provide a path to list (defaults to current working directory)
297- Optionally specify glob patterns to ignore
298- Results are displayed in a tree structure
299
300FEATURES:
301- Displays a hierarchical view of files and directories
302- Automatically skips hidden files/directories (starting with '.')
303- Skips common system directories like __pycache__
304- Can filter out files matching specific patterns
305
306LIMITATIONS:
307- Results are limited to 1000 files
308- Very large directories will be truncated
309- Does not show file sizes or permissions
310- Cannot recursively list all directories in a large project
311
312TIPS:
313- Use Glob tool for finding files by name patterns instead of browsing
314- Use Grep tool for searching file contents
315- Combine with other tools for more effective exploration`
316}
317
318func NewLsTool() BaseTool {
319 return &lsTool{}
320}