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/fsext"
 12	"github.com/charmbracelet/crush/internal/permission"
 13)
 14
 15type LSParams struct {
 16	Path   string   `json:"path"`
 17	Ignore []string `json:"ignore"`
 18}
 19
 20type LSPermissionsParams struct {
 21	Path   string   `json:"path"`
 22	Ignore []string `json:"ignore"`
 23}
 24
 25type TreeNode struct {
 26	Name     string      `json:"name"`
 27	Path     string      `json:"path"`
 28	Type     string      `json:"type"` // "file" or "directory"
 29	Children []*TreeNode `json:"children,omitempty"`
 30}
 31
 32type LSResponseMetadata struct {
 33	NumberOfFiles int  `json:"number_of_files"`
 34	Truncated     bool `json:"truncated"`
 35}
 36
 37type lsTool struct {
 38	workingDir  string
 39	permissions permission.Service
 40}
 41
 42const (
 43	LSToolName    = "ls"
 44	MaxLSFiles    = 1000
 45	lsDescription = `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)
 80
 81func NewLsTool(permissions permission.Service, workingDir string) BaseTool {
 82	return &lsTool{
 83		workingDir:  workingDir,
 84		permissions: permissions,
 85	}
 86}
 87
 88func (l *lsTool) Name() string {
 89	return LSToolName
 90}
 91
 92func (l *lsTool) Info() ToolInfo {
 93	return ToolInfo{
 94		Name:        LSToolName,
 95		Description: lsDescription,
 96		Parameters: map[string]any{
 97			"path": map[string]any{
 98				"type":        "string",
 99				"description": "The path to the directory to list (defaults to current working directory)",
100			},
101			"ignore": map[string]any{
102				"type":        "array",
103				"description": "List of glob patterns to ignore",
104				"items": map[string]any{
105					"type": "string",
106				},
107			},
108		},
109		Required: []string{"path"},
110	}
111}
112
113func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
114	var params LSParams
115	if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
116		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
117	}
118
119	searchPath := params.Path
120	if searchPath == "" {
121		searchPath = l.workingDir
122	}
123
124	var err error
125	searchPath, err = fsext.Expand(searchPath)
126	if err != nil {
127		return ToolResponse{}, fmt.Errorf("error expanding path: %w", err)
128	}
129
130	if !filepath.IsAbs(searchPath) {
131		searchPath = filepath.Join(l.workingDir, searchPath)
132	}
133
134	// Check if directory is outside working directory and request permission if needed
135	absWorkingDir, err := filepath.Abs(l.workingDir)
136	if err != nil {
137		return ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
138	}
139
140	absSearchPath, err := filepath.Abs(searchPath)
141	if err != nil {
142		return ToolResponse{}, fmt.Errorf("error resolving search path: %w", err)
143	}
144
145	relPath, err := filepath.Rel(absWorkingDir, absSearchPath)
146	if err != nil || strings.HasPrefix(relPath, "..") {
147		// Directory is outside working directory, request permission
148		sessionID, messageID := GetContextValues(ctx)
149		if sessionID == "" || messageID == "" {
150			return ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing directories outside working directory")
151		}
152
153		granted := l.permissions.Request(
154			permission.CreatePermissionRequest{
155				SessionID:   sessionID,
156				Path:        absSearchPath,
157				ToolCallID:  call.ID,
158				ToolName:    LSToolName,
159				Action:      "list",
160				Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
161				Params:      LSPermissionsParams(params),
162			},
163		)
164
165		if !granted {
166			return ToolResponse{}, permission.ErrorPermissionDenied
167		}
168	}
169
170	output, err := ListDirectoryTree(searchPath, params.Ignore)
171	if err != nil {
172		return ToolResponse{}, err
173	}
174
175	// Get file count for metadata
176	files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
177	if err != nil {
178		return ToolResponse{}, fmt.Errorf("error listing directory for metadata: %w", err)
179	}
180
181	return WithResponseMetadata(
182		NewTextResponse(output),
183		LSResponseMetadata{
184			NumberOfFiles: len(files),
185			Truncated:     truncated,
186		},
187	), nil
188}
189
190func ListDirectoryTree(searchPath string, ignore []string) (string, error) {
191	if _, err := os.Stat(searchPath); os.IsNotExist(err) {
192		return "", fmt.Errorf("path does not exist: %s", searchPath)
193	}
194
195	files, truncated, err := fsext.ListDirectory(searchPath, ignore, MaxLSFiles)
196	if err != nil {
197		return "", fmt.Errorf("error listing directory: %w", err)
198	}
199
200	tree := createFileTree(files, searchPath)
201	output := printTree(tree, searchPath)
202
203	if truncated {
204		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)
205	}
206
207	return output, nil
208}
209
210func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
211	root := []*TreeNode{}
212	pathMap := make(map[string]*TreeNode)
213
214	for _, path := range sortedPaths {
215		relativePath := strings.TrimPrefix(path, rootPath)
216		parts := strings.Split(relativePath, string(filepath.Separator))
217		currentPath := ""
218		var parentPath string
219
220		var cleanParts []string
221		for _, part := range parts {
222			if part != "" {
223				cleanParts = append(cleanParts, part)
224			}
225		}
226		parts = cleanParts
227
228		if len(parts) == 0 {
229			continue
230		}
231
232		for i, part := range parts {
233			if currentPath == "" {
234				currentPath = part
235			} else {
236				currentPath = filepath.Join(currentPath, part)
237			}
238
239			if _, exists := pathMap[currentPath]; exists {
240				parentPath = currentPath
241				continue
242			}
243
244			isLastPart := i == len(parts)-1
245			isDir := !isLastPart || strings.HasSuffix(relativePath, string(filepath.Separator))
246			nodeType := "file"
247			if isDir {
248				nodeType = "directory"
249			}
250			newNode := &TreeNode{
251				Name:     part,
252				Path:     currentPath,
253				Type:     nodeType,
254				Children: []*TreeNode{},
255			}
256
257			pathMap[currentPath] = newNode
258
259			if i > 0 && parentPath != "" {
260				if parent, ok := pathMap[parentPath]; ok {
261					parent.Children = append(parent.Children, newNode)
262				}
263			} else {
264				root = append(root, newNode)
265			}
266
267			parentPath = currentPath
268		}
269	}
270
271	return root
272}
273
274func printTree(tree []*TreeNode, rootPath string) string {
275	var result strings.Builder
276
277	result.WriteString("- ")
278	result.WriteString(rootPath)
279	if rootPath[len(rootPath)-1] != '/' {
280		result.WriteByte(filepath.Separator)
281	}
282	result.WriteByte('\n')
283
284	for _, node := range tree {
285		printNode(&result, node, 1)
286	}
287
288	return result.String()
289}
290
291func printNode(builder *strings.Builder, node *TreeNode, level int) {
292	indent := strings.Repeat("  ", level)
293
294	nodeName := node.Name
295	if node.Type == "directory" {
296		nodeName = nodeName + string(filepath.Separator)
297	}
298
299	fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
300
301	if node.Type == "directory" && len(node.Children) > 0 {
302		for _, child := range node.Children {
303			printNode(builder, child, level+1)
304		}
305	}
306}