|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import fs from 'fs'; |
|
|
import path from 'path'; |
|
|
import { BaseTool, ToolResult } from './tools.js'; |
|
|
import { SchemaValidator } from '../utils/schemaValidator.js'; |
|
|
import { makeRelative, shortenPath } from '../utils/paths.js'; |
|
|
import { Config } from '../config/config.js'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export interface LSToolParams { |
|
|
|
|
|
|
|
|
|
|
|
path: string; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ignore?: string[]; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
respect_git_ignore?: boolean; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export interface FileEntry { |
|
|
|
|
|
|
|
|
|
|
|
name: string; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
path: string; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
isDirectory: boolean; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
size: number; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
modifiedTime: Date; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export class LSTool extends BaseTool<LSToolParams, ToolResult> { |
|
|
static readonly Name = 'list_directory'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
constructor( |
|
|
private rootDirectory: string, |
|
|
private config: Config, |
|
|
) { |
|
|
super( |
|
|
LSTool.Name, |
|
|
'ReadFolder', |
|
|
'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.', |
|
|
{ |
|
|
properties: { |
|
|
path: { |
|
|
description: |
|
|
'The absolute path to the directory to list (must be absolute, not relative)', |
|
|
type: 'string', |
|
|
}, |
|
|
ignore: { |
|
|
description: 'List of glob patterns to ignore', |
|
|
items: { |
|
|
type: 'string', |
|
|
}, |
|
|
type: 'array', |
|
|
}, |
|
|
respect_git_ignore: { |
|
|
description: |
|
|
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.', |
|
|
type: 'boolean', |
|
|
}, |
|
|
}, |
|
|
required: ['path'], |
|
|
type: 'object', |
|
|
}, |
|
|
); |
|
|
|
|
|
|
|
|
this.rootDirectory = path.resolve(rootDirectory); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private isWithinRoot(dirpath: string): boolean { |
|
|
const normalizedPath = path.normalize(dirpath); |
|
|
const normalizedRoot = path.normalize(this.rootDirectory); |
|
|
|
|
|
const rootWithSep = normalizedRoot.endsWith(path.sep) |
|
|
? normalizedRoot |
|
|
: normalizedRoot + path.sep; |
|
|
return ( |
|
|
normalizedPath === normalizedRoot || |
|
|
normalizedPath.startsWith(rootWithSep) |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
validateToolParams(params: LSToolParams): string | null { |
|
|
if ( |
|
|
this.schema.parameters && |
|
|
!SchemaValidator.validate( |
|
|
this.schema.parameters as Record<string, unknown>, |
|
|
params, |
|
|
) |
|
|
) { |
|
|
return 'Parameters failed schema validation.'; |
|
|
} |
|
|
if (!path.isAbsolute(params.path)) { |
|
|
return `Path must be absolute: ${params.path}`; |
|
|
} |
|
|
if (!this.isWithinRoot(params.path)) { |
|
|
return `Path must be within the root directory (${this.rootDirectory}): ${params.path}`; |
|
|
} |
|
|
return null; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private shouldIgnore(filename: string, patterns?: string[]): boolean { |
|
|
if (!patterns || patterns.length === 0) { |
|
|
return false; |
|
|
} |
|
|
for (const pattern of patterns) { |
|
|
|
|
|
const regexPattern = pattern |
|
|
.replace(/[.+^${}()|[\]\\]/g, '\\$&') |
|
|
.replace(/\*/g, '.*') |
|
|
.replace(/\?/g, '.'); |
|
|
const regex = new RegExp(`^${regexPattern}$`); |
|
|
if (regex.test(filename)) { |
|
|
return true; |
|
|
} |
|
|
} |
|
|
return false; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getDescription(params: LSToolParams): string { |
|
|
const relativePath = makeRelative(params.path, this.rootDirectory); |
|
|
return shortenPath(relativePath); |
|
|
} |
|
|
|
|
|
|
|
|
private errorResult(llmContent: string, returnDisplay: string): ToolResult { |
|
|
return { |
|
|
llmContent, |
|
|
|
|
|
returnDisplay: `Error: ${returnDisplay}`, |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async execute( |
|
|
params: LSToolParams, |
|
|
_signal: AbortSignal, |
|
|
): Promise<ToolResult> { |
|
|
const validationError = this.validateToolParams(params); |
|
|
if (validationError) { |
|
|
return this.errorResult( |
|
|
`Error: Invalid parameters provided. Reason: ${validationError}`, |
|
|
`Failed to execute tool.`, |
|
|
); |
|
|
} |
|
|
|
|
|
try { |
|
|
const stats = fs.statSync(params.path); |
|
|
if (!stats) { |
|
|
|
|
|
|
|
|
return this.errorResult( |
|
|
`Error: Directory not found or inaccessible: ${params.path}`, |
|
|
`Directory not found or inaccessible.`, |
|
|
); |
|
|
} |
|
|
if (!stats.isDirectory()) { |
|
|
return this.errorResult( |
|
|
`Error: Path is not a directory: ${params.path}`, |
|
|
`Path is not a directory.`, |
|
|
); |
|
|
} |
|
|
|
|
|
const files = fs.readdirSync(params.path); |
|
|
|
|
|
|
|
|
const respectGitIgnore = |
|
|
params.respect_git_ignore ?? |
|
|
this.config.getFileFilteringRespectGitIgnore(); |
|
|
const fileDiscovery = this.config.getFileService(); |
|
|
|
|
|
const entries: FileEntry[] = []; |
|
|
let gitIgnoredCount = 0; |
|
|
|
|
|
if (files.length === 0) { |
|
|
|
|
|
return { |
|
|
llmContent: `Directory ${params.path} is empty.`, |
|
|
returnDisplay: `Directory is empty.`, |
|
|
}; |
|
|
} |
|
|
|
|
|
for (const file of files) { |
|
|
if (this.shouldIgnore(file, params.ignore)) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
const fullPath = path.join(params.path, file); |
|
|
const relativePath = path.relative(this.rootDirectory, fullPath); |
|
|
|
|
|
|
|
|
if ( |
|
|
respectGitIgnore && |
|
|
fileDiscovery.shouldGitIgnoreFile(relativePath) |
|
|
) { |
|
|
gitIgnoredCount++; |
|
|
continue; |
|
|
} |
|
|
|
|
|
try { |
|
|
const stats = fs.statSync(fullPath); |
|
|
const isDir = stats.isDirectory(); |
|
|
entries.push({ |
|
|
name: file, |
|
|
path: fullPath, |
|
|
isDirectory: isDir, |
|
|
size: isDir ? 0 : stats.size, |
|
|
modifiedTime: stats.mtime, |
|
|
}); |
|
|
} catch (error) { |
|
|
|
|
|
console.error(`Error accessing ${fullPath}: ${error}`); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
entries.sort((a, b) => { |
|
|
if (a.isDirectory && !b.isDirectory) return -1; |
|
|
if (!a.isDirectory && b.isDirectory) return 1; |
|
|
return a.name.localeCompare(b.name); |
|
|
}); |
|
|
|
|
|
|
|
|
const directoryContent = entries |
|
|
.map((entry) => `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`) |
|
|
.join('\n'); |
|
|
|
|
|
let resultMessage = `Directory listing for ${params.path}:\n${directoryContent}`; |
|
|
if (gitIgnoredCount > 0) { |
|
|
resultMessage += `\n\n(${gitIgnoredCount} items were git-ignored)`; |
|
|
} |
|
|
|
|
|
let displayMessage = `Listed ${entries.length} item(s).`; |
|
|
if (gitIgnoredCount > 0) { |
|
|
displayMessage += ` (${gitIgnoredCount} git-ignored)`; |
|
|
} |
|
|
|
|
|
return { |
|
|
llmContent: resultMessage, |
|
|
returnDisplay: displayMessage, |
|
|
}; |
|
|
} catch (error) { |
|
|
const errorMsg = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`; |
|
|
return this.errorResult(errorMsg, 'Failed to list directory.'); |
|
|
} |
|
|
} |
|
|
} |
|
|
|