ls.go

  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), &params); 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}