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}