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