ls.go

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