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