2025-03-25 20:04:36 +08:00
package tools
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
2025-05-13 23:02:39 +08:00
"github.com/sst/opencode/internal/config"
2025-03-25 20:04:36 +08:00
)
type LSParams struct {
Path string ` json:"path" `
Ignore [ ] string ` json:"ignore" `
}
2025-03-28 05:35:48 +08:00
type TreeNode struct {
Name string ` json:"name" `
Path string ` json:"path" `
Type string ` json:"type" ` // "file" or "directory"
Children [ ] * TreeNode ` json:"children,omitempty" `
}
2025-04-17 02:06:23 +08:00
type LSResponseMetadata struct {
2025-04-14 17:24:36 +08:00
NumberOfFiles int ` json:"number_of_files" `
Truncated bool ` json:"truncated" `
}
2025-04-09 01:15:23 +08:00
type lsTool struct { }
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
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 ( ) BaseTool {
return & lsTool { }
}
2025-03-28 05:35:48 +08:00
func ( l * lsTool ) Info ( ) ToolInfo {
return ToolInfo {
Name : LSToolName ,
2025-04-09 01:15:23 +08:00
Description : lsDescription ,
2025-03-28 05:35:48 +08:00
Parameters : map [ string ] any {
"path" : map [ string ] any {
"type" : "string" ,
"description" : "The path to the directory to list (defaults to current working directory)" ,
2025-03-25 20:04:36 +08:00
} ,
2025-03-28 05:35:48 +08:00
"ignore" : map [ string ] any {
"type" : "array" ,
"description" : "List of glob patterns to ignore" ,
"items" : map [ string ] any {
"type" : "string" ,
2025-03-25 20:04:36 +08:00
} ,
} ,
2025-03-28 05:35:48 +08:00
} ,
Required : [ ] string { "path" } ,
}
2025-03-25 20:04:36 +08:00
}
2025-03-28 05:35:48 +08:00
func ( l * lsTool ) Run ( ctx context . Context , call ToolCall ) ( ToolResponse , error ) {
2025-03-25 20:04:36 +08:00
var params LSParams
2025-03-28 05:35:48 +08:00
if err := json . Unmarshal ( [ ] byte ( call . Input ) , & params ) ; err != nil {
return NewTextErrorResponse ( fmt . Sprintf ( "error parsing parameters: %s" , err ) ) , nil
}
searchPath := params . Path
if searchPath == "" {
searchPath = config . WorkingDirectory ( )
}
if ! filepath . IsAbs ( searchPath ) {
searchPath = filepath . Join ( config . WorkingDirectory ( ) , searchPath )
2025-03-25 20:04:36 +08:00
}
2025-03-28 05:35:48 +08:00
if _ , err := os . Stat ( searchPath ) ; os . IsNotExist ( err ) {
return NewTextErrorResponse ( fmt . Sprintf ( "path does not exist: %s" , searchPath ) ) , nil
2025-03-25 20:04:36 +08:00
}
2025-03-28 05:35:48 +08:00
files , truncated , err := listDirectory ( searchPath , params . Ignore , MaxLSFiles )
2025-03-25 20:04:36 +08:00
if err != nil {
2025-04-14 17:24:36 +08:00
return ToolResponse { } , fmt . Errorf ( "error listing directory: %w" , err )
2025-03-25 20:04:36 +08:00
}
tree := createFileTree ( files )
2025-03-28 05:35:48 +08:00
output := printTree ( tree , searchPath )
2025-03-25 20:04:36 +08:00
2025-03-28 05:35:48 +08:00
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 )
2025-03-25 20:04:36 +08:00
}
2025-04-14 17:24:36 +08:00
return WithResponseMetadata (
NewTextResponse ( output ) ,
2025-04-17 02:06:23 +08:00
LSResponseMetadata {
2025-04-14 17:24:36 +08:00
NumberOfFiles : len ( files ) ,
Truncated : truncated ,
} ,
) , nil
2025-03-25 20:04:36 +08:00
}
2025-03-28 05:35:48 +08:00
func listDirectory ( initialPath string , ignorePatterns [ ] string , limit int ) ( [ ] string , bool , error ) {
2025-03-25 20:04:36 +08:00
var results [ ] string
2025-03-28 05:35:48 +08:00
truncated := false
2025-03-25 20:04:36 +08:00
err := filepath . Walk ( initialPath , func ( path string , info os . FileInfo , err error ) error {
if err != nil {
return nil // Skip files we don't have permission to access
}
2025-03-28 05:35:48 +08:00
if shouldSkip ( path , ignorePatterns ) {
2025-03-25 20:04:36 +08:00
if info . IsDir ( ) {
return filepath . SkipDir
}
return nil
}
if path != initialPath {
if info . IsDir ( ) {
path = path + string ( filepath . Separator )
}
2025-03-28 05:35:48 +08:00
results = append ( results , path )
2025-03-25 20:04:36 +08:00
}
2025-03-28 05:35:48 +08:00
if len ( results ) >= limit {
truncated = true
return filepath . SkipAll
2025-03-25 20:04:36 +08:00
}
return nil
} )
2025-03-28 05:35:48 +08:00
if err != nil {
return nil , truncated , err
2025-03-25 20:04:36 +08:00
}
2025-03-28 05:35:48 +08:00
return results , truncated , nil
2025-03-25 20:04:36 +08:00
}
2025-03-28 05:35:48 +08:00
func shouldSkip ( path string , ignorePatterns [ ] string ) bool {
2025-03-25 20:04:36 +08:00
base := filepath . Base ( path )
if base != "." && strings . HasPrefix ( base , "." ) {
return true
}
2025-03-28 05:35:48 +08:00
commonIgnored := [ ] string {
"__pycache__" ,
"node_modules" ,
"dist" ,
"build" ,
"target" ,
"vendor" ,
"bin" ,
"obj" ,
".git" ,
".idea" ,
".vscode" ,
".DS_Store" ,
"*.pyc" ,
"*.pyo" ,
"*.pyd" ,
"*.so" ,
"*.dll" ,
"*.exe" ,
}
2025-03-25 20:04:36 +08:00
if strings . Contains ( path , filepath . Join ( "__pycache__" , "" ) ) {
return true
}
2025-03-28 05:35:48 +08:00
for _ , ignored := range commonIgnored {
if strings . HasSuffix ( ignored , "/" ) {
if strings . Contains ( path , filepath . Join ( ignored [ : len ( ignored ) - 1 ] , "" ) ) {
return true
}
} else if strings . HasPrefix ( ignored , "*." ) {
if strings . HasSuffix ( base , ignored [ 1 : ] ) {
return true
}
} else {
if base == ignored {
return true
}
}
}
2025-03-25 20:04:36 +08:00
2025-03-28 05:35:48 +08:00
for _ , pattern := range ignorePatterns {
matched , err := filepath . Match ( pattern , base )
if err == nil && matched {
return true
}
}
return false
2025-03-25 20:04:36 +08:00
}
2025-03-28 05:35:48 +08:00
func createFileTree ( sortedPaths [ ] string ) [ ] * TreeNode {
root := [ ] * TreeNode { }
pathMap := make ( map [ string ] * TreeNode )
2025-03-25 20:04:36 +08:00
for _ , path := range sortedPaths {
parts := strings . Split ( path , string ( filepath . Separator ) )
currentPath := ""
2025-03-28 05:35:48 +08:00
var parentPath string
2025-03-25 20:04:36 +08:00
2025-03-28 05:35:48 +08:00
var cleanParts [ ] string
for _ , part := range parts {
if part != "" {
cleanParts = append ( cleanParts , part )
2025-03-25 20:04:36 +08:00
}
2025-03-28 05:35:48 +08:00
}
parts = cleanParts
2025-03-25 20:04:36 +08:00
2025-03-28 05:35:48 +08:00
if len ( parts ) == 0 {
continue
}
for i , part := range parts {
2025-03-25 20:04:36 +08:00
if currentPath == "" {
currentPath = part
} else {
currentPath = filepath . Join ( currentPath , part )
}
2025-03-28 05:35:48 +08:00
if _ , exists := pathMap [ currentPath ] ; exists {
parentPath = currentPath
continue
}
2025-03-25 20:04:36 +08:00
isLastPart := i == len ( parts ) - 1
isDir := ! isLastPart || strings . HasSuffix ( path , string ( filepath . Separator ) )
2025-03-28 05:35:48 +08:00
nodeType := "file"
if isDir {
nodeType = "directory"
}
newNode := & TreeNode {
Name : part ,
Path : currentPath ,
Type : nodeType ,
Children : [ ] * TreeNode { } ,
2025-03-25 20:04:36 +08:00
}
2025-03-28 05:35:48 +08:00
pathMap [ currentPath ] = newNode
2025-03-25 20:04:36 +08:00
2025-03-28 05:35:48 +08:00
if i > 0 && parentPath != "" {
if parent , ok := pathMap [ parentPath ] ; ok {
parent . Children = append ( parent . Children , newNode )
2025-03-25 20:04:36 +08:00
}
2025-03-28 05:35:48 +08:00
} else {
root = append ( root , newNode )
2025-03-25 20:04:36 +08:00
}
2025-03-28 05:35:48 +08:00
parentPath = currentPath
2025-03-25 20:04:36 +08:00
}
}
return root
}
2025-03-28 05:35:48 +08:00
func printTree ( tree [ ] * TreeNode , rootPath string ) string {
2025-03-25 20:04:36 +08:00
var result strings . Builder
result . WriteString ( fmt . Sprintf ( "- %s%s\n" , rootPath , string ( filepath . Separator ) ) )
2025-03-28 05:35:48 +08:00
for _ , node := range tree {
printNode ( & result , node , 1 )
}
2025-03-25 20:04:36 +08:00
return result . String ( )
}
2025-03-28 05:35:48 +08:00
func printNode ( builder * strings . Builder , node * TreeNode , level int ) {
indent := strings . Repeat ( " " , level )
2025-03-25 20:04:36 +08:00
2025-03-28 05:35:48 +08:00
nodeName := node . Name
if node . Type == "directory" {
nodeName += string ( filepath . Separator )
}
fmt . Fprintf ( builder , "%s- %s\n" , indent , nodeName )
2025-03-25 20:04:36 +08:00
2025-03-28 05:35:48 +08:00
if node . Type == "directory" && len ( node . Children ) > 0 {
for _ , child := range node . Children {
printNode ( builder , child , level + 1 )
2025-03-25 20:04:36 +08:00
}
}
}