ls.go

  1package tools
  2
  3import (
  4	"cmp"
  5	"context"
  6	_ "embed"
  7	"fmt"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11
 12	"charm.land/fantasy"
 13	"github.com/charmbracelet/crush/internal/config"
 14	"github.com/charmbracelet/crush/internal/filepathext"
 15	"github.com/charmbracelet/crush/internal/fsext"
 16	"github.com/charmbracelet/crush/internal/permission"
 17)
 18
 19type LSParams struct {
 20	Path   string   `json:"path,omitempty" description:"The path to the directory to list (defaults to current working directory)"`
 21	Ignore []string `json:"ignore,omitempty" description:"List of glob patterns to ignore"`
 22	Depth  int      `json:"depth,omitempty" description:"The maximum depth to traverse"`
 23}
 24
 25type LSPermissionsParams struct {
 26	Path   string   `json:"path"`
 27	Ignore []string `json:"ignore"`
 28	Depth  int      `json:"depth"`
 29}
 30
 31type NodeType string
 32
 33const (
 34	NodeTypeFile      NodeType = "file"
 35	NodeTypeDirectory NodeType = "directory"
 36)
 37
 38type TreeNode struct {
 39	Name     string      `json:"name"`
 40	Path     string      `json:"path"`
 41	Type     NodeType    `json:"type"`
 42	Children []*TreeNode `json:"children,omitempty"`
 43}
 44
 45type LSResponseMetadata struct {
 46	NumberOfFiles int  `json:"number_of_files"`
 47	Truncated     bool `json:"truncated"`
 48}
 49
 50const (
 51	LSToolName = "ls"
 52	maxLSFiles = 1000
 53)
 54
 55//go:embed ls.md
 56var lsDescription []byte
 57
 58func NewLsTool(permissions permission.Service, workingDir string, lsConfig config.ToolLs) fantasy.AgentTool {
 59	return fantasy.NewAgentTool(
 60		LSToolName,
 61		string(lsDescription),
 62		func(ctx context.Context, params LSParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 63			searchPath, err := fsext.Expand(cmp.Or(params.Path, workingDir))
 64			if err != nil {
 65				return fantasy.NewTextErrorResponse(fmt.Sprintf("error expanding path: %v", err)), nil
 66			}
 67
 68			searchPath = filepathext.SmartJoin(workingDir, searchPath)
 69
 70			// Check if directory is outside working directory and request permission if needed
 71			absWorkingDir, err := filepath.Abs(workingDir)
 72			if err != nil {
 73				return fantasy.NewTextErrorResponse(fmt.Sprintf("error resolving working directory: %v", err)), nil
 74			}
 75
 76			absSearchPath, err := filepath.Abs(searchPath)
 77			if err != nil {
 78				return fantasy.NewTextErrorResponse(fmt.Sprintf("error resolving search path: %v", err)), nil
 79			}
 80
 81			var permResult permission.PermissionResult
 82			relPath, err := filepath.Rel(absWorkingDir, absSearchPath)
 83			if err != nil || strings.HasPrefix(relPath, "..") {
 84				// Directory is outside working directory, request permission
 85				sessionID := GetSessionFromContext(ctx)
 86				if sessionID == "" {
 87					return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing directories outside working directory")
 88				}
 89
 90				permResult, err = permissions.Request(ctx,
 91					permission.CreatePermissionRequest{
 92						SessionID:   sessionID,
 93						Path:        absSearchPath,
 94						ToolCallID:  call.ID,
 95						ToolName:    LSToolName,
 96						Action:      "list",
 97						Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
 98						Params:      LSPermissionsParams(params),
 99					},
100				)
101				if err != nil {
102					return fantasy.ToolResponse{}, err
103				}
104				if !permResult.Granted {
105					if permResult.Message != "" {
106						return fantasy.NewTextErrorResponse("User denied permission." + permission.UserCommentaryTag(permResult.Message)), nil
107					}
108					return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
109				}
110			}
111
112			output, metadata, err := ListDirectoryTree(searchPath, params, lsConfig)
113			if err != nil {
114				return fantasy.NewTextErrorResponse(err.Error()), nil
115			}
116
117			return fantasy.WithResponseMetadata(
118				fantasy.NewTextResponse(permResult.AppendCommentary(output)),
119				metadata,
120			), nil
121		})
122}
123
124func ListDirectoryTree(searchPath string, params LSParams, lsConfig config.ToolLs) (string, LSResponseMetadata, error) {
125	if _, err := os.Stat(searchPath); os.IsNotExist(err) {
126		return "", LSResponseMetadata{}, fmt.Errorf("path does not exist: %s", searchPath)
127	}
128
129	depth, limit := lsConfig.Limits()
130	maxFiles := cmp.Or(limit, maxLSFiles)
131	files, truncated, err := fsext.ListDirectory(
132		searchPath,
133		params.Ignore,
134		cmp.Or(params.Depth, depth),
135		maxFiles,
136	)
137	if err != nil {
138		return "", LSResponseMetadata{}, fmt.Errorf("error listing directory: %w", err)
139	}
140
141	metadata := LSResponseMetadata{
142		NumberOfFiles: len(files),
143		Truncated:     truncated,
144	}
145	tree := createFileTree(files, searchPath)
146
147	var output string
148	if truncated {
149		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 %[1]d files and directories are included below.\n", maxFiles)
150	}
151	if depth > 0 {
152		output = fmt.Sprintf("The directory tree is shown up to a depth of %d. Use a higher depth and a specific path to see more levels.\n", cmp.Or(params.Depth, depth))
153	}
154	return output + "\n" + printTree(tree, searchPath), metadata, nil
155}
156
157func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
158	root := []*TreeNode{}
159	pathMap := make(map[string]*TreeNode)
160
161	for _, path := range sortedPaths {
162		relativePath := strings.TrimPrefix(path, rootPath)
163		parts := strings.Split(relativePath, string(filepath.Separator))
164		currentPath := ""
165		var parentPath string
166
167		var cleanParts []string
168		for _, part := range parts {
169			if part != "" {
170				cleanParts = append(cleanParts, part)
171			}
172		}
173		parts = cleanParts
174
175		if len(parts) == 0 {
176			continue
177		}
178
179		for i, part := range parts {
180			if currentPath == "" {
181				currentPath = part
182			} else {
183				currentPath = filepath.Join(currentPath, part)
184			}
185
186			if _, exists := pathMap[currentPath]; exists {
187				parentPath = currentPath
188				continue
189			}
190
191			isLastPart := i == len(parts)-1
192			isDir := !isLastPart || strings.HasSuffix(relativePath, string(filepath.Separator))
193			nodeType := NodeTypeFile
194			if isDir {
195				nodeType = NodeTypeDirectory
196			}
197			newNode := &TreeNode{
198				Name:     part,
199				Path:     currentPath,
200				Type:     nodeType,
201				Children: []*TreeNode{},
202			}
203
204			pathMap[currentPath] = newNode
205
206			if i > 0 && parentPath != "" {
207				if parent, ok := pathMap[parentPath]; ok {
208					parent.Children = append(parent.Children, newNode)
209				}
210			} else {
211				root = append(root, newNode)
212			}
213
214			parentPath = currentPath
215		}
216	}
217
218	return root
219}
220
221func printTree(tree []*TreeNode, rootPath string) string {
222	var result strings.Builder
223
224	result.WriteString("- ")
225	result.WriteString(filepath.ToSlash(rootPath))
226	if rootPath[len(rootPath)-1] != '/' {
227		result.WriteByte('/')
228	}
229	result.WriteByte('\n')
230
231	for _, node := range tree {
232		printNode(&result, node, 1)
233	}
234
235	return result.String()
236}
237
238func printNode(builder *strings.Builder, node *TreeNode, level int) {
239	indent := strings.Repeat("  ", level)
240
241	nodeName := node.Name
242	if node.Type == NodeTypeDirectory {
243		nodeName = nodeName + "/"
244	}
245
246	fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
247
248	if node.Type == NodeTypeDirectory && len(node.Children) > 0 {
249		for _, child := range node.Children {
250			printNode(builder, child, level+1)
251		}
252	}
253}