mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
411 lines
12 KiB
TypeScript
411 lines
12 KiB
TypeScript
/**
|
|
* Read File Tool - Read files with multi-file, directory, and regex support
|
|
*
|
|
* Features:
|
|
* - Read single or multiple files
|
|
* - Read all files in a directory (with depth control)
|
|
* - Filter files by glob/regex pattern
|
|
* - Content search with regex
|
|
* - Compact output format
|
|
*/
|
|
|
|
import { z } from 'zod';
|
|
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
|
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
|
|
import { resolve, isAbsolute, join, relative, extname } from 'path';
|
|
import { validatePath, getProjectRoot } from '../utils/path-validator.js';
|
|
|
|
// Max content per file (truncate if larger)
|
|
const MAX_CONTENT_LENGTH = 5000;
|
|
// Max files to return
|
|
const MAX_FILES = 50;
|
|
// Max total content length
|
|
const MAX_TOTAL_CONTENT = 50000;
|
|
|
|
// Define Zod schema for validation
|
|
const ParamsSchema = z.object({
|
|
paths: z.union([z.string(), z.array(z.string())]).describe('File path(s) or directory'),
|
|
pattern: z.string().optional().describe('Glob pattern to filter files (e.g., "*.ts", "**/*.js")'),
|
|
contentPattern: z.string().optional().describe('Regex to search within file content'),
|
|
maxDepth: z.number().default(3).describe('Max directory depth to traverse'),
|
|
includeContent: z.boolean().default(true).describe('Include file content in result'),
|
|
maxFiles: z.number().default(MAX_FILES).describe('Max number of files to return'),
|
|
offset: z.number().min(0).optional().describe('Line offset to start reading from (0-based, for single file only)'),
|
|
limit: z.number().min(1).optional().describe('Number of lines to read (for single file only)'),
|
|
});
|
|
|
|
type Params = z.infer<typeof ParamsSchema>;
|
|
|
|
interface FileEntry {
|
|
path: string;
|
|
size: number;
|
|
content?: string;
|
|
truncated?: boolean;
|
|
matches?: string[];
|
|
totalLines?: number;
|
|
lineRange?: { start: number; end: number };
|
|
}
|
|
|
|
interface ReadResult {
|
|
files: FileEntry[];
|
|
totalFiles: number;
|
|
message: string;
|
|
}
|
|
|
|
// Common binary extensions to skip
|
|
const BINARY_EXTENSIONS = new Set([
|
|
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.svg',
|
|
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
'.zip', '.tar', '.gz', '.rar', '.7z',
|
|
'.exe', '.dll', '.so', '.dylib',
|
|
'.mp3', '.mp4', '.wav', '.avi', '.mov',
|
|
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
'.pyc', '.class', '.o', '.obj',
|
|
]);
|
|
|
|
/**
|
|
* Check if file is likely binary
|
|
*/
|
|
function isBinaryFile(filePath: string): boolean {
|
|
const ext = extname(filePath).toLowerCase();
|
|
return BINARY_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
/**
|
|
* Convert glob pattern to regex
|
|
*/
|
|
function globToRegex(pattern: string): RegExp {
|
|
const escaped = pattern
|
|
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
.replace(/\*/g, '.*')
|
|
.replace(/\?/g, '.');
|
|
return new RegExp(`^${escaped}$`, 'i');
|
|
}
|
|
|
|
/**
|
|
* Check if filename matches glob pattern
|
|
*/
|
|
function matchesPattern(filename: string, pattern: string): boolean {
|
|
const regex = globToRegex(pattern);
|
|
return regex.test(filename);
|
|
}
|
|
|
|
/**
|
|
* Recursively collect files from directory
|
|
*/
|
|
function collectFiles(
|
|
dir: string,
|
|
pattern: string | undefined,
|
|
maxDepth: number,
|
|
currentDepth: number = 0
|
|
): string[] {
|
|
if (currentDepth > maxDepth) return [];
|
|
|
|
const files: string[] = [];
|
|
|
|
try {
|
|
const entries = readdirSync(dir, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
// Skip hidden files/dirs and node_modules
|
|
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
|
|
const fullPath = join(dir, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
files.push(...collectFiles(fullPath, pattern, maxDepth, currentDepth + 1));
|
|
} else if (entry.isFile()) {
|
|
if (!pattern || matchesPattern(entry.name, pattern)) {
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Skip directories we can't read
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
interface ReadContentOptions {
|
|
maxLength: number;
|
|
offset?: number;
|
|
limit?: number;
|
|
}
|
|
|
|
interface ReadContentResult {
|
|
content: string;
|
|
truncated: boolean;
|
|
totalLines?: number;
|
|
lineRange?: { start: number; end: number };
|
|
}
|
|
|
|
/**
|
|
* Read file content with truncation and optional line-based pagination
|
|
*/
|
|
function readFileContent(filePath: string, options: ReadContentOptions): ReadContentResult {
|
|
const { maxLength, offset, limit } = options;
|
|
|
|
if (isBinaryFile(filePath)) {
|
|
return { content: '[Binary file]', truncated: false };
|
|
}
|
|
|
|
try {
|
|
const content = readFileSync(filePath, 'utf8');
|
|
const lines = content.split('\n');
|
|
const totalLines = lines.length;
|
|
|
|
// If offset/limit specified, use line-based pagination
|
|
if (offset !== undefined || limit !== undefined) {
|
|
const startLine = Math.min(offset ?? 0, totalLines);
|
|
const endLine = limit !== undefined ? Math.min(startLine + limit, totalLines) : totalLines;
|
|
const selectedLines = lines.slice(startLine, endLine);
|
|
const selectedContent = selectedLines.join('\n');
|
|
|
|
const actualEnd = endLine;
|
|
const hasMore = actualEnd < totalLines;
|
|
|
|
let finalContent = selectedContent;
|
|
if (selectedContent.length > maxLength) {
|
|
finalContent = selectedContent.substring(0, maxLength) + `\n... (+${selectedContent.length - maxLength} chars)`;
|
|
}
|
|
|
|
// Calculate actual line range (handle empty selection)
|
|
const actualLineEnd = selectedLines.length > 0 ? startLine + selectedLines.length - 1 : startLine;
|
|
|
|
return {
|
|
content: finalContent,
|
|
truncated: hasMore || selectedContent.length > maxLength,
|
|
totalLines,
|
|
lineRange: { start: startLine, end: actualLineEnd },
|
|
};
|
|
}
|
|
|
|
// Default behavior: truncate by character length
|
|
if (content.length > maxLength) {
|
|
return {
|
|
content: content.substring(0, maxLength) + `\n... (+${content.length - maxLength} chars)`,
|
|
truncated: true,
|
|
totalLines,
|
|
};
|
|
}
|
|
return { content, truncated: false, totalLines };
|
|
} catch (error) {
|
|
return { content: `[Error: ${(error as Error).message}]`, truncated: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find regex matches in content
|
|
*/
|
|
function findMatches(content: string, pattern: string): string[] {
|
|
try {
|
|
const regex = new RegExp(pattern, 'gm');
|
|
const matches: string[] = [];
|
|
let match;
|
|
|
|
while ((match = regex.exec(content)) !== null && matches.length < 10) {
|
|
// Get line containing match
|
|
const lineStart = content.lastIndexOf('\n', match.index) + 1;
|
|
const lineEnd = content.indexOf('\n', match.index);
|
|
const line = content.substring(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
|
|
matches.push(line.substring(0, 200)); // Truncate long lines
|
|
}
|
|
|
|
return matches;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Tool schema for MCP
|
|
export const schema: ToolSchema = {
|
|
name: 'read_file',
|
|
description: `Read files with multi-file, directory, regex support, and line-based pagination.
|
|
|
|
Usage:
|
|
read_file(paths="file.ts") # Single file (full content)
|
|
read_file(paths="file.ts", offset=100, limit=50) # Lines 100-149 (0-based)
|
|
read_file(paths=["a.ts", "b.ts"]) # Multiple files
|
|
read_file(paths="src/", pattern="*.ts") # Directory with pattern
|
|
read_file(paths="src/", contentPattern="TODO") # Search content
|
|
|
|
Supports both absolute and relative paths. Relative paths are resolved from project root.
|
|
Returns compact file list with optional content. Use offset/limit for large file pagination.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
paths: {
|
|
oneOf: [
|
|
{ type: 'string', description: 'Single file or directory path' },
|
|
{ type: 'array', items: { type: 'string' }, description: 'Array of file paths' }
|
|
],
|
|
description: 'File path(s) or directory to read',
|
|
},
|
|
pattern: {
|
|
type: 'string',
|
|
description: 'Glob pattern to filter files (e.g., "*.ts", "*.{js,ts}")',
|
|
},
|
|
contentPattern: {
|
|
type: 'string',
|
|
description: 'Regex pattern to search within file content',
|
|
},
|
|
maxDepth: {
|
|
type: 'number',
|
|
description: 'Max directory depth to traverse (default: 3)',
|
|
default: 3,
|
|
},
|
|
includeContent: {
|
|
type: 'boolean',
|
|
description: 'Include file content in result (default: true)',
|
|
default: true,
|
|
},
|
|
maxFiles: {
|
|
type: 'number',
|
|
description: `Max number of files to return (default: ${MAX_FILES})`,
|
|
default: MAX_FILES,
|
|
},
|
|
offset: {
|
|
type: 'number',
|
|
description: 'Line offset to start reading from (0-based, for single file only)',
|
|
minimum: 0,
|
|
},
|
|
limit: {
|
|
type: 'number',
|
|
description: 'Number of lines to read (for single file only)',
|
|
minimum: 1,
|
|
},
|
|
},
|
|
required: ['paths'],
|
|
},
|
|
};
|
|
|
|
// Handler function
|
|
export async function handler(params: Record<string, unknown>): Promise<ToolResult<ReadResult>> {
|
|
const parsed = ParamsSchema.safeParse(params);
|
|
if (!parsed.success) {
|
|
return { success: false, error: `Invalid params: ${parsed.error.message}` };
|
|
}
|
|
|
|
const {
|
|
paths,
|
|
pattern,
|
|
contentPattern,
|
|
maxDepth,
|
|
includeContent,
|
|
maxFiles,
|
|
offset,
|
|
limit,
|
|
} = parsed.data;
|
|
|
|
const cwd = getProjectRoot();
|
|
|
|
// Normalize paths to array
|
|
const inputPaths = Array.isArray(paths) ? paths : [paths];
|
|
|
|
// Collect all files to read
|
|
const allFiles: string[] = [];
|
|
|
|
for (const inputPath of inputPaths) {
|
|
const resolvedPath = await validatePath(inputPath);
|
|
|
|
if (!existsSync(resolvedPath)) {
|
|
continue; // Skip non-existent paths
|
|
}
|
|
|
|
const stat = statSync(resolvedPath);
|
|
|
|
if (stat.isDirectory()) {
|
|
// Collect files from directory
|
|
const dirFiles = collectFiles(resolvedPath, pattern, maxDepth);
|
|
allFiles.push(...dirFiles);
|
|
} else if (stat.isFile()) {
|
|
// Add single file (check pattern if provided)
|
|
if (!pattern || matchesPattern(relative(cwd, resolvedPath), pattern)) {
|
|
allFiles.push(resolvedPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Limit files
|
|
const limitedFiles = allFiles.slice(0, maxFiles);
|
|
const totalFiles = allFiles.length;
|
|
|
|
// Process files
|
|
const files: FileEntry[] = [];
|
|
let totalContent = 0;
|
|
|
|
// Only apply offset/limit for single file mode
|
|
const isSingleFile = limitedFiles.length === 1;
|
|
const useLinePagination = isSingleFile && (offset !== undefined || limit !== undefined);
|
|
|
|
for (const filePath of limitedFiles) {
|
|
if (totalContent >= MAX_TOTAL_CONTENT) break;
|
|
|
|
const stat = statSync(filePath);
|
|
const entry: FileEntry = {
|
|
path: relative(cwd, filePath) || filePath,
|
|
size: stat.size,
|
|
};
|
|
|
|
if (includeContent) {
|
|
const remainingSpace = MAX_TOTAL_CONTENT - totalContent;
|
|
const maxLen = Math.min(MAX_CONTENT_LENGTH, remainingSpace);
|
|
|
|
// Pass offset/limit only for single file mode
|
|
const readOptions: ReadContentOptions = { maxLength: maxLen };
|
|
if (useLinePagination) {
|
|
if (offset !== undefined) readOptions.offset = offset;
|
|
if (limit !== undefined) readOptions.limit = limit;
|
|
}
|
|
|
|
const { content, truncated, totalLines, lineRange } = readFileContent(filePath, readOptions);
|
|
|
|
// If contentPattern provided, only include files with matches
|
|
if (contentPattern) {
|
|
const matches = findMatches(content, contentPattern);
|
|
if (matches.length > 0) {
|
|
entry.matches = matches;
|
|
entry.content = content;
|
|
entry.truncated = truncated;
|
|
entry.totalLines = totalLines;
|
|
entry.lineRange = lineRange;
|
|
totalContent += content.length;
|
|
} else {
|
|
continue; // Skip files without matches
|
|
}
|
|
} else {
|
|
entry.content = content;
|
|
entry.truncated = truncated;
|
|
entry.totalLines = totalLines;
|
|
entry.lineRange = lineRange;
|
|
totalContent += content.length;
|
|
}
|
|
}
|
|
|
|
files.push(entry);
|
|
}
|
|
|
|
// Build message
|
|
let message = `Read ${files.length} file(s)`;
|
|
if (totalFiles > maxFiles) {
|
|
message += ` (showing ${maxFiles} of ${totalFiles})`;
|
|
}
|
|
if (useLinePagination && files.length > 0 && files[0].lineRange) {
|
|
const { start, end } = files[0].lineRange;
|
|
message += ` [lines ${start}-${end} of ${files[0].totalLines}]`;
|
|
}
|
|
if (contentPattern) {
|
|
message += ` matching "${contentPattern}"`;
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
result: {
|
|
files,
|
|
totalFiles,
|
|
message,
|
|
},
|
|
};
|
|
}
|