mirror of
https://github.com/charmbracelet/crush.git
synced 2025-08-02 05:20:46 +03:00
303 lines
7.9 KiB
Go
303 lines
7.9 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/crush/internal/fsext"
|
|
"github.com/charmbracelet/crush/internal/permission"
|
|
)
|
|
|
|
type LSParams struct {
|
|
Path string `json:"path"`
|
|
Ignore []string `json:"ignore"`
|
|
}
|
|
|
|
type LSPermissionsParams struct {
|
|
Path string `json:"path"`
|
|
Ignore []string `json:"ignore"`
|
|
}
|
|
|
|
type TreeNode struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Type string `json:"type"` // "file" or "directory"
|
|
Children []*TreeNode `json:"children,omitempty"`
|
|
}
|
|
|
|
type LSResponseMetadata struct {
|
|
NumberOfFiles int `json:"number_of_files"`
|
|
Truncated bool `json:"truncated"`
|
|
}
|
|
|
|
type lsTool struct {
|
|
workingDir string
|
|
permissions permission.Service
|
|
}
|
|
|
|
const (
|
|
LSToolName = "ls"
|
|
MaxLSFiles = 1000
|
|
lsDescription = `Directory listing tool that shows files and subdirectories in a tree structure, helping you explore and understand the project organization.
|
|
|
|
WHEN TO USE THIS TOOL:
|
|
- Use when you need to explore the structure of a directory
|
|
- Helpful for understanding the organization of a project
|
|
- Good first step when getting familiar with a new codebase
|
|
|
|
HOW TO USE:
|
|
- Provide a path to list (defaults to current working directory)
|
|
- Optionally specify glob patterns to ignore
|
|
- Results are displayed in a tree structure
|
|
|
|
FEATURES:
|
|
- Displays a hierarchical view of files and directories
|
|
- Automatically skips hidden files/directories (starting with '.')
|
|
- Skips common system directories like __pycache__
|
|
- Can filter out files matching specific patterns
|
|
|
|
LIMITATIONS:
|
|
- Results are limited to 1000 files
|
|
- Very large directories will be truncated
|
|
- Does not show file sizes or permissions
|
|
- Cannot recursively list all directories in a large project
|
|
|
|
WINDOWS NOTES:
|
|
- Hidden file detection uses Unix convention (files starting with '.')
|
|
- Windows-specific hidden files (with hidden attribute) are not automatically skipped
|
|
- Common Windows directories like System32, Program Files are not in default ignore list
|
|
- Path separators are handled automatically (both / and \ work)
|
|
|
|
TIPS:
|
|
- Use Glob tool for finding files by name patterns instead of browsing
|
|
- Use Grep tool for searching file contents
|
|
- Combine with other tools for more effective exploration`
|
|
)
|
|
|
|
func NewLsTool(permissions permission.Service, workingDir string) BaseTool {
|
|
return &lsTool{
|
|
workingDir: workingDir,
|
|
permissions: permissions,
|
|
}
|
|
}
|
|
|
|
func (l *lsTool) Name() string {
|
|
return LSToolName
|
|
}
|
|
|
|
func (l *lsTool) Info() ToolInfo {
|
|
return ToolInfo{
|
|
Name: LSToolName,
|
|
Description: lsDescription,
|
|
Parameters: map[string]any{
|
|
"path": map[string]any{
|
|
"type": "string",
|
|
"description": "The path to the directory to list (defaults to current working directory)",
|
|
},
|
|
"ignore": map[string]any{
|
|
"type": "array",
|
|
"description": "List of glob patterns to ignore",
|
|
"items": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
},
|
|
Required: []string{"path"},
|
|
}
|
|
}
|
|
|
|
func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
|
var params LSParams
|
|
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
|
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
|
|
}
|
|
|
|
searchPath := params.Path
|
|
if searchPath == "" {
|
|
searchPath = l.workingDir
|
|
}
|
|
|
|
if searchPath == "~" {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ToolResponse{}, fmt.Errorf("error resolving home directory: %w", err)
|
|
}
|
|
searchPath = homeDir
|
|
}
|
|
|
|
if !filepath.IsAbs(searchPath) {
|
|
searchPath = filepath.Join(l.workingDir, searchPath)
|
|
}
|
|
|
|
// Check if directory is outside working directory and request permission if needed
|
|
absWorkingDir, err := filepath.Abs(l.workingDir)
|
|
if err != nil {
|
|
return ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
|
|
}
|
|
|
|
absSearchPath, err := filepath.Abs(searchPath)
|
|
if err != nil {
|
|
return ToolResponse{}, fmt.Errorf("error resolving search path: %w", err)
|
|
}
|
|
|
|
relPath, err := filepath.Rel(absWorkingDir, absSearchPath)
|
|
if err != nil || strings.HasPrefix(relPath, "..") {
|
|
// Directory is outside working directory, request permission
|
|
sessionID, messageID := GetContextValues(ctx)
|
|
if sessionID == "" || messageID == "" {
|
|
return ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing directories outside working directory")
|
|
}
|
|
|
|
granted := l.permissions.Request(
|
|
permission.CreatePermissionRequest{
|
|
SessionID: sessionID,
|
|
Path: absSearchPath,
|
|
ToolCallID: call.ID,
|
|
ToolName: LSToolName,
|
|
Action: "list",
|
|
Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
|
|
Params: LSPermissionsParams(params),
|
|
},
|
|
)
|
|
|
|
if !granted {
|
|
return ToolResponse{}, permission.ErrorPermissionDenied
|
|
}
|
|
}
|
|
|
|
output, err := ListDirectoryTree(searchPath, params.Ignore)
|
|
if err != nil {
|
|
return ToolResponse{}, err
|
|
}
|
|
|
|
// Get file count for metadata
|
|
files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
|
|
if err != nil {
|
|
return ToolResponse{}, fmt.Errorf("error listing directory for metadata: %w", err)
|
|
}
|
|
|
|
return WithResponseMetadata(
|
|
NewTextResponse(output),
|
|
LSResponseMetadata{
|
|
NumberOfFiles: len(files),
|
|
Truncated: truncated,
|
|
},
|
|
), nil
|
|
}
|
|
|
|
func ListDirectoryTree(searchPath string, ignore []string) (string, error) {
|
|
if _, err := os.Stat(searchPath); os.IsNotExist(err) {
|
|
return "", fmt.Errorf("path does not exist: %s", searchPath)
|
|
}
|
|
|
|
files, truncated, err := fsext.ListDirectory(searchPath, ignore, MaxLSFiles)
|
|
if err != nil {
|
|
return "", fmt.Errorf("error listing directory: %w", err)
|
|
}
|
|
|
|
tree := createFileTree(files)
|
|
output := printTree(tree, searchPath)
|
|
|
|
if truncated {
|
|
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)
|
|
}
|
|
|
|
return output, nil
|
|
}
|
|
|
|
func createFileTree(sortedPaths []string) []*TreeNode {
|
|
root := []*TreeNode{}
|
|
pathMap := make(map[string]*TreeNode)
|
|
|
|
for _, path := range sortedPaths {
|
|
parts := strings.Split(path, string(filepath.Separator))
|
|
currentPath := ""
|
|
var parentPath string
|
|
|
|
var cleanParts []string
|
|
for _, part := range parts {
|
|
if part != "" {
|
|
cleanParts = append(cleanParts, part)
|
|
}
|
|
}
|
|
parts = cleanParts
|
|
|
|
if len(parts) == 0 {
|
|
continue
|
|
}
|
|
|
|
for i, part := range parts {
|
|
if currentPath == "" {
|
|
currentPath = part
|
|
} else {
|
|
currentPath = filepath.Join(currentPath, part)
|
|
}
|
|
|
|
if _, exists := pathMap[currentPath]; exists {
|
|
parentPath = currentPath
|
|
continue
|
|
}
|
|
|
|
isLastPart := i == len(parts)-1
|
|
isDir := !isLastPart || strings.HasSuffix(path, string(filepath.Separator))
|
|
nodeType := "file"
|
|
if isDir {
|
|
nodeType = "directory"
|
|
}
|
|
newNode := &TreeNode{
|
|
Name: part,
|
|
Path: currentPath,
|
|
Type: nodeType,
|
|
Children: []*TreeNode{},
|
|
}
|
|
|
|
pathMap[currentPath] = newNode
|
|
|
|
if i > 0 && parentPath != "" {
|
|
if parent, ok := pathMap[parentPath]; ok {
|
|
parent.Children = append(parent.Children, newNode)
|
|
}
|
|
} else {
|
|
root = append(root, newNode)
|
|
}
|
|
|
|
parentPath = currentPath
|
|
}
|
|
}
|
|
|
|
return root
|
|
}
|
|
|
|
func printTree(tree []*TreeNode, rootPath string) string {
|
|
var result strings.Builder
|
|
|
|
result.WriteString(fmt.Sprintf("- %s%s\n", rootPath, string(filepath.Separator)))
|
|
|
|
for _, node := range tree {
|
|
printNode(&result, node, 1)
|
|
}
|
|
|
|
return result.String()
|
|
}
|
|
|
|
func printNode(builder *strings.Builder, node *TreeNode, level int) {
|
|
indent := strings.Repeat(" ", level)
|
|
|
|
nodeName := node.Name
|
|
if node.Type == "directory" {
|
|
nodeName = nodeName + string(filepath.Separator)
|
|
}
|
|
|
|
fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
|
|
|
|
if node.Type == "directory" && len(node.Children) > 0 {
|
|
for _, child := range node.Children {
|
|
printNode(builder, child, level+1)
|
|
}
|
|
}
|
|
}
|