199 lines
5.9 KiB
TypeScript
199 lines
5.9 KiB
TypeScript
import { z } from 'zod/v4'
|
|
import type { ValidationResult } from '../../Tool.js'
|
|
import { buildTool, type ToolDef } from '../../Tool.js'
|
|
import { getCwd } from '../../utils/cwd.js'
|
|
import { isENOENT } from '../../utils/errors.js'
|
|
import {
|
|
FILE_NOT_FOUND_CWD_NOTE,
|
|
suggestPathUnderCwd,
|
|
} from '../../utils/file.js'
|
|
import { getFsImplementation } from '../../utils/fsOperations.js'
|
|
import { glob } from '../../utils/glob.js'
|
|
import { lazySchema } from '../../utils/lazySchema.js'
|
|
import { expandPath, toRelativePath } from '../../utils/path.js'
|
|
import { checkReadPermissionForTool } from '../../utils/permissions/filesystem.js'
|
|
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
|
|
import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js'
|
|
import { DESCRIPTION, GLOB_TOOL_NAME } from './prompt.js'
|
|
import {
|
|
getToolUseSummary,
|
|
renderToolResultMessage,
|
|
renderToolUseErrorMessage,
|
|
renderToolUseMessage,
|
|
userFacingName,
|
|
} from './UI.js'
|
|
|
|
const inputSchema = lazySchema(() =>
|
|
z.strictObject({
|
|
pattern: z.string().describe('The glob pattern to match files against'),
|
|
path: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
'The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.',
|
|
),
|
|
}),
|
|
)
|
|
type InputSchema = ReturnType<typeof inputSchema>
|
|
|
|
const outputSchema = lazySchema(() =>
|
|
z.object({
|
|
durationMs: z
|
|
.number()
|
|
.describe('Time taken to execute the search in milliseconds'),
|
|
numFiles: z.number().describe('Total number of files found'),
|
|
filenames: z
|
|
.array(z.string())
|
|
.describe('Array of file paths that match the pattern'),
|
|
truncated: z
|
|
.boolean()
|
|
.describe('Whether results were truncated (limited to 100 files)'),
|
|
}),
|
|
)
|
|
type OutputSchema = ReturnType<typeof outputSchema>
|
|
|
|
export type Output = z.infer<OutputSchema>
|
|
|
|
export const GlobTool = buildTool({
|
|
name: GLOB_TOOL_NAME,
|
|
searchHint: 'find files by name pattern or wildcard',
|
|
maxResultSizeChars: 100_000,
|
|
async description() {
|
|
return DESCRIPTION
|
|
},
|
|
userFacingName,
|
|
getToolUseSummary,
|
|
getActivityDescription(input) {
|
|
const summary = getToolUseSummary(input)
|
|
return summary ? `Finding ${summary}` : 'Finding files'
|
|
},
|
|
get inputSchema(): InputSchema {
|
|
return inputSchema()
|
|
},
|
|
get outputSchema(): OutputSchema {
|
|
return outputSchema()
|
|
},
|
|
isConcurrencySafe() {
|
|
return true
|
|
},
|
|
isReadOnly() {
|
|
return true
|
|
},
|
|
toAutoClassifierInput(input) {
|
|
return input.pattern
|
|
},
|
|
isSearchOrReadCommand() {
|
|
return { isSearch: true, isRead: false }
|
|
},
|
|
getPath({ path }): string {
|
|
return path ? expandPath(path) : getCwd()
|
|
},
|
|
async preparePermissionMatcher({ pattern }) {
|
|
return rulePattern => matchWildcardPattern(rulePattern, pattern)
|
|
},
|
|
async validateInput({ path }): Promise<ValidationResult> {
|
|
// If path is provided, validate that it exists and is a directory
|
|
if (path) {
|
|
const fs = getFsImplementation()
|
|
const absolutePath = expandPath(path)
|
|
|
|
// SECURITY: Skip filesystem operations for UNC paths to prevent NTLM credential leaks.
|
|
if (absolutePath.startsWith('\\\\') || absolutePath.startsWith('//')) {
|
|
return { result: true }
|
|
}
|
|
|
|
let stats
|
|
try {
|
|
stats = await fs.stat(absolutePath)
|
|
} catch (e: unknown) {
|
|
if (isENOENT(e)) {
|
|
const cwdSuggestion = await suggestPathUnderCwd(absolutePath)
|
|
let message = `Directory does not exist: ${path}. ${FILE_NOT_FOUND_CWD_NOTE} ${getCwd()}.`
|
|
if (cwdSuggestion) {
|
|
message += ` Did you mean ${cwdSuggestion}?`
|
|
}
|
|
return {
|
|
result: false,
|
|
message,
|
|
errorCode: 1,
|
|
}
|
|
}
|
|
throw e
|
|
}
|
|
|
|
if (!stats.isDirectory()) {
|
|
return {
|
|
result: false,
|
|
message: `Path is not a directory: ${path}`,
|
|
errorCode: 2,
|
|
}
|
|
}
|
|
}
|
|
|
|
return { result: true }
|
|
},
|
|
async checkPermissions(input, context): Promise<PermissionDecision> {
|
|
const appState = context.getAppState()
|
|
return checkReadPermissionForTool(
|
|
GlobTool,
|
|
input,
|
|
appState.toolPermissionContext,
|
|
)
|
|
},
|
|
async prompt() {
|
|
return DESCRIPTION
|
|
},
|
|
renderToolUseMessage,
|
|
renderToolUseErrorMessage,
|
|
renderToolResultMessage,
|
|
// Reuses Grep's render (UI.tsx:65) — shows filenames.join. durationMs/
|
|
// numFiles are "Found 3 files in 12ms" chrome (under-count, fine).
|
|
extractSearchText({ filenames }) {
|
|
return filenames.join('\n')
|
|
},
|
|
async call(input, { abortController, getAppState, globLimits }) {
|
|
const start = Date.now()
|
|
const appState = getAppState()
|
|
const limit = globLimits?.maxResults ?? 100
|
|
const { files, truncated } = await glob(
|
|
input.pattern,
|
|
GlobTool.getPath(input),
|
|
{ limit, offset: 0 },
|
|
abortController.signal,
|
|
appState.toolPermissionContext,
|
|
)
|
|
// Relativize paths under cwd to save tokens (same as GrepTool)
|
|
const filenames = files.map(toRelativePath)
|
|
const output: Output = {
|
|
filenames,
|
|
durationMs: Date.now() - start,
|
|
numFiles: filenames.length,
|
|
truncated,
|
|
}
|
|
return {
|
|
data: output,
|
|
}
|
|
},
|
|
mapToolResultToToolResultBlockParam(output, toolUseID) {
|
|
if (output.filenames.length === 0) {
|
|
return {
|
|
tool_use_id: toolUseID,
|
|
type: 'tool_result',
|
|
content: 'No files found',
|
|
}
|
|
}
|
|
return {
|
|
tool_use_id: toolUseID,
|
|
type: 'tool_result',
|
|
content: [
|
|
...output.filenames,
|
|
...(output.truncated
|
|
? [
|
|
'(Results are truncated. Consider using a more specific path or pattern.)',
|
|
]
|
|
: []),
|
|
].join('\n'),
|
|
}
|
|
},
|
|
} satisfies ToolDef<InputSchema, Output>)
|