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