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 := cmp.Or(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}