mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-23 00:35:51 +00:00
feat: 工具层及 mcp 大重构 (#252)
* feat: 第一版大重构 * fix: 修复类型问题 * chore: 更新版本到 1.3.2 * Add brave as alternative WebSearchTool * fix: 修正顺序 * fix: 修复对穷鬼模式的 auto dream 和 session memory 越过 * feat: 穷鬼模式去除 session-summary * feat: 创建 builtin-tools 包,搬运所有工具实现 将 src/tools/ 下的全部 60 个工具目录迁移至 packages/builtin-tools/src/tools/, 内部导入路径已更新为 src/ alias 模式。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 更新 src/ 中所有工具引用至 builtin-tools 包,删除 src/tools/ - src/tools.ts 及 178 个 src/ 文件的 import 路径从 ./tools/ 改为 builtin-tools/tools/ - 删除 src/tools/ 整个目录(已迁移至 packages/builtin-tools/) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: 添加 builtin-tools 路径别名至 tsconfig,更新 bun.lock - tsconfig.json 新增 builtin-tools/* 和 builtin-tools 路径映射 - 新增 packages/builtin-tools/src 至 include Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 为 builtin-tools、mcp-client、agent-tools 添加 @claude-code-best 作用域前缀 所有包名及 import 路径统一添加 @claude-code-best/ 前缀: - builtin-tools → @claude-code-best/builtin-tools - mcp-client → @claude-code-best/mcp-client - agent-tools → @claude-code-best/agent-tools Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复 node 环境没有 bun 的问题 --------- Co-authored-by: Eric-Guo <eric.guocz@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
577
packages/builtin-tools/src/tools/GrepTool/GrepTool.ts
Normal file
577
packages/builtin-tools/src/tools/GrepTool/GrepTool.ts
Normal file
@@ -0,0 +1,577 @@
|
||||
import { z } from 'zod/v4'
|
||||
import type { ValidationResult } from 'src/Tool.js'
|
||||
import { buildTool, type ToolDef } from 'src/Tool.js'
|
||||
import { getCwd } from 'src/utils/cwd.js'
|
||||
import { isENOENT } from 'src/utils/errors.js'
|
||||
import {
|
||||
FILE_NOT_FOUND_CWD_NOTE,
|
||||
suggestPathUnderCwd,
|
||||
} from 'src/utils/file.js'
|
||||
import { getFsImplementation } from 'src/utils/fsOperations.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { expandPath, toRelativePath } from 'src/utils/path.js'
|
||||
import {
|
||||
checkReadPermissionForTool,
|
||||
getFileReadIgnorePatterns,
|
||||
normalizePatternsToPath,
|
||||
} from 'src/utils/permissions/filesystem.js'
|
||||
import type { PermissionDecision } from 'src/utils/permissions/PermissionResult.js'
|
||||
import { matchWildcardPattern } from 'src/utils/permissions/shellRuleMatching.js'
|
||||
import { getGlobExclusionsForPluginCache } from 'src/utils/plugins/orphanedPluginFilter.js'
|
||||
import { ripGrep } from 'src/utils/ripgrep.js'
|
||||
import { semanticBoolean } from 'src/utils/semanticBoolean.js'
|
||||
import { semanticNumber } from 'src/utils/semanticNumber.js'
|
||||
import { plural } from 'src/utils/stringUtils.js'
|
||||
import { GREP_TOOL_NAME, getDescription } from './prompt.js'
|
||||
import {
|
||||
getToolUseSummary,
|
||||
renderToolResultMessage,
|
||||
renderToolUseErrorMessage,
|
||||
renderToolUseMessage,
|
||||
} from './UI.js'
|
||||
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
pattern: z
|
||||
.string()
|
||||
.describe(
|
||||
'The regular expression pattern to search for in file contents',
|
||||
),
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'File or directory to search in (rg PATH). Defaults to current working directory.',
|
||||
),
|
||||
glob: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}") - maps to rg --glob',
|
||||
),
|
||||
output_mode: z
|
||||
.enum(['content', 'files_with_matches', 'count'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Output mode: "content" shows matching lines (supports -A/-B/-C context, -n line numbers, head_limit), "files_with_matches" shows file paths (supports head_limit), "count" shows match counts (supports head_limit). Defaults to "files_with_matches".',
|
||||
),
|
||||
'-B': semanticNumber(z.number().optional()).describe(
|
||||
'Number of lines to show before each match (rg -B). Requires output_mode: "content", ignored otherwise.',
|
||||
),
|
||||
'-A': semanticNumber(z.number().optional()).describe(
|
||||
'Number of lines to show after each match (rg -A). Requires output_mode: "content", ignored otherwise.',
|
||||
),
|
||||
'-C': semanticNumber(z.number().optional()).describe('Alias for context.'),
|
||||
context: semanticNumber(z.number().optional()).describe(
|
||||
'Number of lines to show before and after each match (rg -C). Requires output_mode: "content", ignored otherwise.',
|
||||
),
|
||||
'-n': semanticBoolean(z.boolean().optional()).describe(
|
||||
'Show line numbers in output (rg -n). Requires output_mode: "content", ignored otherwise. Defaults to true.',
|
||||
),
|
||||
'-i': semanticBoolean(z.boolean().optional()).describe(
|
||||
'Case insensitive search (rg -i)',
|
||||
),
|
||||
type: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'File type to search (rg --type). Common types: js, py, rust, go, java, etc. More efficient than include for standard file types.',
|
||||
),
|
||||
head_limit: semanticNumber(z.number().optional()).describe(
|
||||
'Limit output to first N lines/entries, equivalent to "| head -N". Works across all output modes: content (limits output lines), files_with_matches (limits file paths), count (limits count entries). Defaults to 250 when unspecified. Pass 0 for unlimited (use sparingly — large result sets waste context).',
|
||||
),
|
||||
offset: semanticNumber(z.number().optional()).describe(
|
||||
'Skip first N lines/entries before applying head_limit, equivalent to "| tail -n +N | head -N". Works across all output modes. Defaults to 0.',
|
||||
),
|
||||
multiline: semanticBoolean(z.boolean().optional()).describe(
|
||||
'Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false.',
|
||||
),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
|
||||
// Version control system directories to exclude from searches
|
||||
// These are excluded automatically because they create noise in search results
|
||||
const VCS_DIRECTORIES_TO_EXCLUDE = [
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg',
|
||||
'.bzr',
|
||||
'.jj',
|
||||
'.sl',
|
||||
] as const
|
||||
|
||||
// Default cap on grep results when head_limit is unspecified. Unbounded content-mode
|
||||
// greps can fill up to the 20KB persist threshold (~6-24K tokens/grep-heavy session).
|
||||
// 250 is generous enough for exploratory searches while preventing context bloat.
|
||||
// Pass head_limit=0 explicitly for unlimited.
|
||||
const DEFAULT_HEAD_LIMIT = 250
|
||||
|
||||
function applyHeadLimit<T>(
|
||||
items: T[],
|
||||
limit: number | undefined,
|
||||
offset: number = 0,
|
||||
): { items: T[]; appliedLimit: number | undefined } {
|
||||
// Explicit 0 = unlimited escape hatch
|
||||
if (limit === 0) {
|
||||
return { items: items.slice(offset), appliedLimit: undefined }
|
||||
}
|
||||
const effectiveLimit = limit ?? DEFAULT_HEAD_LIMIT
|
||||
const sliced = items.slice(offset, offset + effectiveLimit)
|
||||
// Only report appliedLimit when truncation actually occurred, so the model
|
||||
// knows there may be more results and can paginate with offset.
|
||||
const wasTruncated = items.length - offset > effectiveLimit
|
||||
return {
|
||||
items: sliced,
|
||||
appliedLimit: wasTruncated ? effectiveLimit : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Format limit/offset information for display in tool results.
|
||||
// appliedLimit is only set when truncation actually occurred (see applyHeadLimit),
|
||||
// so it may be undefined even when appliedOffset is set — build parts conditionally
|
||||
// to avoid "limit: undefined" appearing in user-visible output.
|
||||
function formatLimitInfo(
|
||||
appliedLimit: number | undefined,
|
||||
appliedOffset: number | undefined,
|
||||
): string {
|
||||
const parts: string[] = []
|
||||
if (appliedLimit !== undefined) parts.push(`limit: ${appliedLimit}`)
|
||||
if (appliedOffset) parts.push(`offset: ${appliedOffset}`)
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
mode: z.enum(['content', 'files_with_matches', 'count']).optional(),
|
||||
numFiles: z.number(),
|
||||
filenames: z.array(z.string()),
|
||||
content: z.string().optional(),
|
||||
numLines: z.number().optional(), // For content mode
|
||||
numMatches: z.number().optional(), // For count mode
|
||||
appliedLimit: z.number().optional(), // The limit that was applied (if any)
|
||||
appliedOffset: z.number().optional(), // The offset that was applied
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
|
||||
type Output = z.infer<OutputSchema>
|
||||
|
||||
export const GrepTool = buildTool({
|
||||
name: GREP_TOOL_NAME,
|
||||
searchHint: 'search file contents with regex (ripgrep)',
|
||||
// 20K chars - tool result persistence threshold
|
||||
maxResultSizeChars: 20_000,
|
||||
strict: true,
|
||||
async description() {
|
||||
return getDescription()
|
||||
},
|
||||
userFacingName() {
|
||||
return 'Search'
|
||||
},
|
||||
getToolUseSummary,
|
||||
getActivityDescription(input) {
|
||||
const summary = getToolUseSummary(input)
|
||||
return summary ? `Searching for ${summary}` : 'Searching'
|
||||
},
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema()
|
||||
},
|
||||
get outputSchema(): OutputSchema {
|
||||
return outputSchema()
|
||||
},
|
||||
isConcurrencySafe() {
|
||||
return true
|
||||
},
|
||||
isReadOnly() {
|
||||
return true
|
||||
},
|
||||
toAutoClassifierInput(input) {
|
||||
return input.path ? `${input.pattern} in ${input.path}` : input.pattern
|
||||
},
|
||||
isSearchOrReadCommand() {
|
||||
return { isSearch: true, isRead: false }
|
||||
},
|
||||
getPath({ path }): string {
|
||||
return path || getCwd()
|
||||
},
|
||||
async preparePermissionMatcher({ pattern }) {
|
||||
return rulePattern => matchWildcardPattern(rulePattern, pattern)
|
||||
},
|
||||
async validateInput({ path }): Promise<ValidationResult> {
|
||||
// If path is provided, validate that it exists
|
||||
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 }
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.stat(absolutePath)
|
||||
} catch (e: unknown) {
|
||||
if (isENOENT(e)) {
|
||||
const cwdSuggestion = await suggestPathUnderCwd(absolutePath)
|
||||
let message = `Path 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
|
||||
}
|
||||
}
|
||||
|
||||
return { result: true }
|
||||
},
|
||||
async checkPermissions(input, context): Promise<PermissionDecision> {
|
||||
const appState = context.getAppState()
|
||||
return checkReadPermissionForTool(
|
||||
GrepTool,
|
||||
input,
|
||||
appState.toolPermissionContext,
|
||||
)
|
||||
},
|
||||
async prompt() {
|
||||
return getDescription()
|
||||
},
|
||||
renderToolUseMessage,
|
||||
renderToolUseErrorMessage,
|
||||
renderToolResultMessage,
|
||||
// SearchResultSummary shows content (mode=content) or filenames.join.
|
||||
// numFiles/numLines/numMatches are chrome ("Found 3 files") — fine to
|
||||
// skip (under-count, not phantom). Glob reuses this via UI.tsx:65.
|
||||
extractSearchText({ mode, content, filenames }) {
|
||||
if (mode === 'content' && content) return content
|
||||
return filenames.join('\n')
|
||||
},
|
||||
mapToolResultToToolResultBlockParam(
|
||||
{
|
||||
mode = 'files_with_matches',
|
||||
numFiles,
|
||||
filenames,
|
||||
content,
|
||||
numLines: _numLines,
|
||||
numMatches,
|
||||
appliedLimit,
|
||||
appliedOffset,
|
||||
},
|
||||
toolUseID,
|
||||
) {
|
||||
if (mode === 'content') {
|
||||
const limitInfo = formatLimitInfo(appliedLimit, appliedOffset)
|
||||
const resultContent = content || 'No matches found'
|
||||
const finalContent = limitInfo
|
||||
? `${resultContent}\n\n[Showing results with pagination = ${limitInfo}]`
|
||||
: resultContent
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: finalContent,
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'count') {
|
||||
const limitInfo = formatLimitInfo(appliedLimit, appliedOffset)
|
||||
const rawContent = content || 'No matches found'
|
||||
const matches = numMatches ?? 0
|
||||
const files = numFiles ?? 0
|
||||
const summary = `\n\nFound ${matches} total ${matches === 1 ? 'occurrence' : 'occurrences'} across ${files} ${files === 1 ? 'file' : 'files'}.${limitInfo ? ` with pagination = ${limitInfo}` : ''}`
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: rawContent + summary,
|
||||
}
|
||||
}
|
||||
|
||||
// files_with_matches mode
|
||||
const limitInfo = formatLimitInfo(appliedLimit, appliedOffset)
|
||||
if (numFiles === 0) {
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: 'No files found',
|
||||
}
|
||||
}
|
||||
// head_limit has already been applied in call() method, so just show all filenames
|
||||
const result = `Found ${numFiles} ${plural(numFiles, 'file')}${limitInfo ? ` ${limitInfo}` : ''}\n${filenames.join('\n')}`
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: result,
|
||||
}
|
||||
},
|
||||
async call(
|
||||
{
|
||||
pattern,
|
||||
path,
|
||||
glob,
|
||||
type,
|
||||
output_mode = 'files_with_matches',
|
||||
'-B': context_before,
|
||||
'-A': context_after,
|
||||
'-C': context_c,
|
||||
context,
|
||||
'-n': show_line_numbers = true,
|
||||
'-i': case_insensitive = false,
|
||||
head_limit,
|
||||
offset = 0,
|
||||
multiline = false,
|
||||
},
|
||||
{ abortController, getAppState },
|
||||
) {
|
||||
const absolutePath = path ? expandPath(path) : getCwd()
|
||||
const args = ['--hidden']
|
||||
|
||||
// Exclude VCS directories to avoid noise from version control metadata
|
||||
for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) {
|
||||
args.push('--glob', `!${dir}`)
|
||||
}
|
||||
|
||||
// Limit line length to prevent base64/minified content from cluttering output
|
||||
args.push('--max-columns', '500')
|
||||
|
||||
// Only apply multiline flags when explicitly requested
|
||||
if (multiline) {
|
||||
args.push('-U', '--multiline-dotall')
|
||||
}
|
||||
|
||||
// Add optional flags
|
||||
if (case_insensitive) {
|
||||
args.push('-i')
|
||||
}
|
||||
|
||||
// Add output mode flags
|
||||
if (output_mode === 'files_with_matches') {
|
||||
args.push('-l')
|
||||
} else if (output_mode === 'count') {
|
||||
args.push('-c')
|
||||
}
|
||||
|
||||
// Add line numbers if requested
|
||||
if (show_line_numbers && output_mode === 'content') {
|
||||
args.push('-n')
|
||||
}
|
||||
|
||||
// Add context flags (-C/context takes precedence over context_before/context_after)
|
||||
if (output_mode === 'content') {
|
||||
if (context !== undefined) {
|
||||
args.push('-C', context.toString())
|
||||
} else if (context_c !== undefined) {
|
||||
args.push('-C', context_c.toString())
|
||||
} else {
|
||||
if (context_before !== undefined) {
|
||||
args.push('-B', context_before.toString())
|
||||
}
|
||||
if (context_after !== undefined) {
|
||||
args.push('-A', context_after.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If pattern starts with dash, use -e flag to specify it as a pattern
|
||||
// This prevents ripgrep from interpreting it as a command-line option
|
||||
if (pattern.startsWith('-')) {
|
||||
args.push('-e', pattern)
|
||||
} else {
|
||||
args.push(pattern)
|
||||
}
|
||||
|
||||
// Add type filter if specified
|
||||
if (type) {
|
||||
args.push('--type', type)
|
||||
}
|
||||
|
||||
if (glob) {
|
||||
// Split on commas and spaces, but preserve patterns with braces
|
||||
const globPatterns: string[] = []
|
||||
const rawPatterns = glob.split(/\s+/)
|
||||
|
||||
for (const rawPattern of rawPatterns) {
|
||||
// If pattern contains braces, don't split further
|
||||
if (rawPattern.includes('{') && rawPattern.includes('}')) {
|
||||
globPatterns.push(rawPattern)
|
||||
} else {
|
||||
// Split on commas for patterns without braces
|
||||
globPatterns.push(...rawPattern.split(',').filter(Boolean))
|
||||
}
|
||||
}
|
||||
|
||||
for (const globPattern of globPatterns.filter(Boolean)) {
|
||||
args.push('--glob', globPattern)
|
||||
}
|
||||
}
|
||||
|
||||
// Add ignore patterns
|
||||
const appState = getAppState()
|
||||
const ignorePatterns = normalizePatternsToPath(
|
||||
getFileReadIgnorePatterns(appState.toolPermissionContext),
|
||||
getCwd(),
|
||||
)
|
||||
for (const ignorePattern of ignorePatterns) {
|
||||
// Note: ripgrep only applies gitignore patterns relative to the working directory
|
||||
// So for non-absolute paths, we need to prefix them with '**'
|
||||
// See: https://github.com/BurntSushi/ripgrep/discussions/2156#discussioncomment-2316335
|
||||
//
|
||||
// We also need to negate the pattern with `!` to exclude it
|
||||
const rgIgnorePattern = ignorePattern.startsWith('/')
|
||||
? `!${ignorePattern}`
|
||||
: `!**/${ignorePattern}`
|
||||
args.push('--glob', rgIgnorePattern)
|
||||
}
|
||||
|
||||
// Exclude orphaned plugin version directories
|
||||
for (const exclusion of await getGlobExclusionsForPluginCache(
|
||||
absolutePath,
|
||||
)) {
|
||||
args.push('--glob', exclusion)
|
||||
}
|
||||
|
||||
// WSL has severe performance penalty for file reads (3-5x slower on WSL2)
|
||||
// The timeout is handled by ripgrep itself via execFile timeout option
|
||||
// We don't use AbortController for timeout to avoid interrupting the agent loop
|
||||
// If ripgrep times out, it throws RipgrepTimeoutError which propagates up
|
||||
// so Claude knows the search didn't complete (rather than thinking there were no matches)
|
||||
const results = await ripGrep(args, absolutePath, abortController.signal)
|
||||
|
||||
if (output_mode === 'content') {
|
||||
// For content mode, results are the actual content lines
|
||||
// Convert absolute paths to relative paths to save tokens
|
||||
|
||||
// Apply head_limit first — relativize is per-line work, so
|
||||
// avoid processing lines that will be discarded (broad patterns can
|
||||
// return 10k+ lines with head_limit keeping only ~30-100).
|
||||
const { items: limitedResults, appliedLimit } = applyHeadLimit(
|
||||
results,
|
||||
head_limit,
|
||||
offset,
|
||||
)
|
||||
|
||||
const finalLines = limitedResults.map(line => {
|
||||
// Lines have format: /absolute/path:line_content or /absolute/path:num:content
|
||||
const colonIndex = line.indexOf(':')
|
||||
if (colonIndex > 0) {
|
||||
const filePath = line.substring(0, colonIndex)
|
||||
const rest = line.substring(colonIndex)
|
||||
return toRelativePath(filePath) + rest
|
||||
}
|
||||
return line
|
||||
})
|
||||
const output = {
|
||||
mode: 'content' as const,
|
||||
numFiles: 0, // Not applicable for content mode
|
||||
filenames: [],
|
||||
content: finalLines.join('\n'),
|
||||
numLines: finalLines.length,
|
||||
...(appliedLimit !== undefined && { appliedLimit }),
|
||||
...(offset > 0 && { appliedOffset: offset }),
|
||||
}
|
||||
return { data: output }
|
||||
}
|
||||
|
||||
if (output_mode === 'count') {
|
||||
// For count mode, pass through raw ripgrep output (filename:count format)
|
||||
// Apply head_limit first to avoid relativizing entries that will be discarded.
|
||||
const { items: limitedResults, appliedLimit } = applyHeadLimit(
|
||||
results,
|
||||
head_limit,
|
||||
offset,
|
||||
)
|
||||
|
||||
// Convert absolute paths to relative paths to save tokens
|
||||
const finalCountLines = limitedResults.map(line => {
|
||||
// Lines have format: /absolute/path:count
|
||||
const colonIndex = line.lastIndexOf(':')
|
||||
if (colonIndex > 0) {
|
||||
const filePath = line.substring(0, colonIndex)
|
||||
const count = line.substring(colonIndex)
|
||||
return toRelativePath(filePath) + count
|
||||
}
|
||||
return line
|
||||
})
|
||||
|
||||
// Parse count output to extract total matches and file count
|
||||
let totalMatches = 0
|
||||
let fileCount = 0
|
||||
for (const line of finalCountLines) {
|
||||
const colonIndex = line.lastIndexOf(':')
|
||||
if (colonIndex > 0) {
|
||||
const countStr = line.substring(colonIndex + 1)
|
||||
const count = parseInt(countStr, 10)
|
||||
if (!isNaN(count)) {
|
||||
totalMatches += count
|
||||
fileCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
mode: 'count' as const,
|
||||
numFiles: fileCount,
|
||||
filenames: [],
|
||||
content: finalCountLines.join('\n'),
|
||||
numMatches: totalMatches,
|
||||
...(appliedLimit !== undefined && { appliedLimit }),
|
||||
...(offset > 0 && { appliedOffset: offset }),
|
||||
}
|
||||
return { data: output }
|
||||
}
|
||||
|
||||
// For files_with_matches mode (default)
|
||||
// Use allSettled so a single ENOENT (file deleted between ripgrep's scan
|
||||
// and this stat) does not reject the whole batch. Failed stats sort as mtime 0.
|
||||
const stats = await Promise.allSettled(
|
||||
results.map(_ => getFsImplementation().stat(_)),
|
||||
)
|
||||
const sortedMatches = results
|
||||
// Sort by modification time
|
||||
.map((_, i) => {
|
||||
const r = stats[i]!
|
||||
return [
|
||||
_,
|
||||
r.status === 'fulfilled' ? (r.value.mtimeMs ?? 0) : 0,
|
||||
] as const
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
// In tests, we always want to sort by filename, so that results are deterministic
|
||||
return a[0].localeCompare(b[0])
|
||||
}
|
||||
const timeComparison = b[1] - a[1]
|
||||
if (timeComparison === 0) {
|
||||
// Sort by filename as a tiebreaker
|
||||
return a[0].localeCompare(b[0])
|
||||
}
|
||||
return timeComparison
|
||||
})
|
||||
.map(_ => _[0])
|
||||
|
||||
// Apply head_limit to sorted file list (like "| head -N")
|
||||
const { items: finalMatches, appliedLimit } = applyHeadLimit(
|
||||
sortedMatches,
|
||||
head_limit,
|
||||
offset,
|
||||
)
|
||||
|
||||
// Convert absolute paths to relative paths to save tokens
|
||||
const relativeMatches = finalMatches.map(toRelativePath)
|
||||
|
||||
const output = {
|
||||
mode: 'files_with_matches' as const,
|
||||
filenames: relativeMatches,
|
||||
numFiles: relativeMatches.length,
|
||||
...(appliedLimit !== undefined && { appliedLimit }),
|
||||
...(offset > 0 && { appliedOffset: offset }),
|
||||
}
|
||||
|
||||
return {
|
||||
data: output,
|
||||
}
|
||||
},
|
||||
} satisfies ToolDef<InputSchema, Output>)
|
||||
190
packages/builtin-tools/src/tools/GrepTool/UI.tsx
Normal file
190
packages/builtin-tools/src/tools/GrepTool/UI.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
|
||||
import React from 'react'
|
||||
import { CtrlOToExpand } from 'src/components/CtrlOToExpand.js'
|
||||
import { FallbackToolUseErrorMessage } from 'src/components/FallbackToolUseErrorMessage.js'
|
||||
import { MessageResponse } from 'src/components/MessageResponse.js'
|
||||
import { TOOL_SUMMARY_MAX_LENGTH } from 'src/constants/toolLimits.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { ToolProgressData } from 'src/Tool.js'
|
||||
import type { ProgressMessage } from 'src/types/message.js'
|
||||
import { FILE_NOT_FOUND_CWD_NOTE, getDisplayPath } from 'src/utils/file.js'
|
||||
import { truncate } from 'src/utils/format.js'
|
||||
import { extractTag } from 'src/utils/messages.js'
|
||||
|
||||
// Reusable component for search result summaries
|
||||
function SearchResultSummary({
|
||||
count,
|
||||
countLabel,
|
||||
secondaryCount,
|
||||
secondaryLabel,
|
||||
content,
|
||||
verbose,
|
||||
}: {
|
||||
count: number
|
||||
countLabel: string
|
||||
secondaryCount?: number
|
||||
secondaryLabel?: string
|
||||
content?: string
|
||||
verbose: boolean
|
||||
}): React.ReactNode {
|
||||
const primaryText = (
|
||||
<Text>
|
||||
Found <Text bold>{count} </Text>
|
||||
{count === 0 || count > 1 ? countLabel : countLabel.slice(0, -1)}
|
||||
</Text>
|
||||
)
|
||||
|
||||
const secondaryText =
|
||||
secondaryCount !== undefined && secondaryLabel ? (
|
||||
<Text>
|
||||
{' '}
|
||||
across <Text bold>{secondaryCount} </Text>
|
||||
{secondaryCount === 0 || secondaryCount > 1
|
||||
? secondaryLabel
|
||||
: secondaryLabel.slice(0, -1)}
|
||||
</Text>
|
||||
) : null
|
||||
|
||||
if (verbose) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
<Text>
|
||||
<Text dimColor> ⎿ </Text>
|
||||
{primaryText}
|
||||
{secondaryText}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={5}>
|
||||
<Text>{content}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageResponse height={1}>
|
||||
<Text>
|
||||
{primaryText}
|
||||
{secondaryText} {count > 0 && <CtrlOToExpand />}
|
||||
</Text>
|
||||
</MessageResponse>
|
||||
)
|
||||
}
|
||||
|
||||
type Output = {
|
||||
mode?: 'content' | 'files_with_matches' | 'count'
|
||||
numFiles: number
|
||||
filenames: string[]
|
||||
content?: string
|
||||
numLines?: number // For content mode
|
||||
numMatches?: number // For count mode
|
||||
}
|
||||
|
||||
export function renderToolUseMessage(
|
||||
{ pattern, path }: Partial<{ pattern: string; path?: string }>,
|
||||
{ verbose }: { verbose: boolean },
|
||||
): React.ReactNode {
|
||||
if (!pattern) {
|
||||
return null
|
||||
}
|
||||
const parts = [`pattern: "${pattern}"`]
|
||||
|
||||
if (path) {
|
||||
parts.push(`path: "${verbose ? path : getDisplayPath(path)}"`)
|
||||
}
|
||||
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
export function renderToolUseErrorMessage(
|
||||
result: ToolResultBlockParam['content'],
|
||||
{ verbose }: { verbose: boolean },
|
||||
): React.ReactNode {
|
||||
if (
|
||||
!verbose &&
|
||||
typeof result === 'string' &&
|
||||
extractTag(result, 'tool_use_error')
|
||||
) {
|
||||
const errorMessage = extractTag(result, 'tool_use_error')
|
||||
if (errorMessage?.includes(FILE_NOT_FOUND_CWD_NOTE)) {
|
||||
return (
|
||||
<MessageResponse>
|
||||
<Text color="error">File not found</Text>
|
||||
</MessageResponse>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<MessageResponse>
|
||||
<Text color="error">Error searching files</Text>
|
||||
</MessageResponse>
|
||||
)
|
||||
}
|
||||
return <FallbackToolUseErrorMessage result={result} verbose={verbose} />
|
||||
}
|
||||
|
||||
export function renderToolResultMessage(
|
||||
{
|
||||
mode = 'files_with_matches',
|
||||
filenames,
|
||||
numFiles,
|
||||
content,
|
||||
numLines,
|
||||
numMatches,
|
||||
}: Output,
|
||||
_progressMessagesForMessage: ProgressMessage<ToolProgressData>[],
|
||||
{ verbose }: { verbose: boolean },
|
||||
): React.ReactNode {
|
||||
if (mode === 'content') {
|
||||
return (
|
||||
<SearchResultSummary
|
||||
count={numLines ?? 0}
|
||||
countLabel="lines"
|
||||
content={content}
|
||||
verbose={verbose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'count') {
|
||||
return (
|
||||
<SearchResultSummary
|
||||
count={numMatches ?? 0}
|
||||
countLabel="matches"
|
||||
secondaryCount={numFiles}
|
||||
secondaryLabel="files"
|
||||
content={content}
|
||||
verbose={verbose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// files_with_matches mode
|
||||
const fileListContent = filenames.map(filename => filename).join('\n')
|
||||
return (
|
||||
<SearchResultSummary
|
||||
count={numFiles}
|
||||
countLabel="files"
|
||||
content={fileListContent}
|
||||
verbose={verbose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function getToolUseSummary(
|
||||
input:
|
||||
| Partial<{
|
||||
pattern: string
|
||||
path?: string
|
||||
glob?: string
|
||||
type?: string
|
||||
output_mode?: 'content' | 'files_with_matches' | 'count'
|
||||
head_limit?: number
|
||||
}>
|
||||
| undefined,
|
||||
): string | null {
|
||||
if (!input?.pattern) {
|
||||
return null
|
||||
}
|
||||
return truncate(input.pattern, TOOL_SUMMARY_MAX_LENGTH)
|
||||
}
|
||||
18
packages/builtin-tools/src/tools/GrepTool/prompt.ts
Normal file
18
packages/builtin-tools/src/tools/GrepTool/prompt.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { AGENT_TOOL_NAME } from '../AgentTool/constants.js'
|
||||
import { BASH_TOOL_NAME } from '../BashTool/toolName.js'
|
||||
|
||||
export const GREP_TOOL_NAME = 'Grep'
|
||||
|
||||
export function getDescription(): string {
|
||||
return `A powerful search tool built on ripgrep
|
||||
|
||||
Usage:
|
||||
- ALWAYS use ${GREP_TOOL_NAME} for search tasks. NEVER invoke \`grep\` or \`rg\` as a ${BASH_TOOL_NAME} command. The ${GREP_TOOL_NAME} tool has been optimized for correct permissions and access.
|
||||
- Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")
|
||||
- Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")
|
||||
- Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts
|
||||
- Use ${AGENT_TOOL_NAME} tool for open-ended searches requiring multiple rounds
|
||||
- Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use \`interface\\{\\}\` to find \`interface{}\` in Go code)
|
||||
- Multiline matching: By default patterns match within single lines only. For cross-line patterns like \`struct \\{[\\s\\S]*?field\`, use \`multiline: true\`
|
||||
`
|
||||
}
|
||||
Reference in New Issue
Block a user