/** * Glob tool — find files by name pattern, newest first (audit D5-4). * Mirrors the Claude SDK Glob's contract; shares the walk + pattern * translation with Grep. */ import fs from 'fs'; import path from 'path'; import type { PiTool } from './types.js'; import { safeResolve, displayPath } from './path-safety.js'; import { globToRegExp } from './grep.js'; const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'dist-bloby', 'build', '.cache', '.next', 'coverage', '.venv', '__pycache__']); const MAX_RESULTS = 100; const MAX_FILES_SCANNED = 20_000; export const globTool: PiTool = { name: 'Glob', description: 'Find files by name pattern (e.g. "**/*.ts", "src/**/config.*"). Returns matching paths sorted newest-first.', inputSchema: { type: 'object', properties: { pattern: { type: 'string', description: 'Glob pattern to match file paths against.' }, path: { type: 'string', description: 'Directory to search in (default: workspace root).' }, }, required: ['pattern'], }, async run(input, ctx) { const pattern = typeof input?.pattern === 'string' ? input.pattern.trim() : ''; if (!pattern) return { output: 'pattern is required.', isError: true }; let root: string; try { root = safeResolve(ctx.cwd, typeof input?.path === 'string' && input.path.trim() ? input.path : '.'); } catch (err: any) { return { output: err.message, isError: true }; } const re = globToRegExp(pattern); const found: { p: string; mtime: number }[] = []; const stack = [root]; let scanned = 0; let truncatedScan = false; while (stack.length > 0) { if (ctx.signal?.aborted) break; const dir = stack.pop()!; let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } for (const e of entries) { const full = path.join(dir, e.name); if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) stack.push(full); continue; } if (!e.isFile()) continue; if (++scanned > MAX_FILES_SCANNED) { truncatedScan = true; break; } const rel = path.relative(root, full).split(path.sep).join('/'); if (re.test(rel) || re.test(e.name)) { let mtime = 0; try { mtime = fs.statSync(full).mtimeMs; } catch {} found.push({ p: displayPath(ctx.cwd, full), mtime }); } } if (truncatedScan) break; } if (found.length === 0) { return { output: 'No files matched.' + (truncatedScan ? ` [Scan stopped at ${MAX_FILES_SCANNED} files — narrow the path]` : '') }; } found.sort((a, b) => b.mtime - a.mtime); const shown = found.slice(0, MAX_RESULTS); const tail = found.length > MAX_RESULTS || truncatedScan ? `\n\n[${found.length} matches — showing the ${shown.length} most recent]` : ''; return { output: shown.map((f) => f.p).join('\n') + tail }; }, };