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