// src/system/glob.ts // Copyright (C) 2026 Rob Colbert // Licensed under the Apache License, Version 2.0 import fs from "node:fs/promises"; import path from "node:path"; import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai"; import { GadgetTool } from "../tool.ts"; import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts"; const MAX_RESULTS = 1000; export class GlobTool extends GadgetTool { get name(): string { return "glob"; } get category(): string { return "system"; } public definition: IToolDefinition = { type: "function", function: { name: this.name, description: "Find project files by name using glob pattern matching. Supports patterns like **/*.ts, *.js, src/**/*.tsx.", parameters: { type: "object", properties: { pattern: { type: "string", description: "Glob pattern to match files (e.g., '**/*.ts', 'src/**/*.tsx', '*.json').", }, root: { type: "string", description: "Root directory to search from, relative to the project root. Defaults to project root.", }, }, required: ["pattern"], }, }, }; public async execute(args: IToolArguments, logger: IAiLogger): Promise { const pattern = typeof args.pattern === "string" ? args.pattern : undefined; if (!pattern || pattern.trim().length === 0) { return toolError({ code: "MISSING_PARAMETER", message: "pattern is required.", parameter: "pattern", recoveryHint: "Provide a glob pattern like '**/*.ts' or 'src/**/*.tsx'.", }); } const projectRoot = getProjectRoot(this.toolbox); if (!projectRoot) { return toolError({ code: "OPERATION_NOT_ALLOWED", message: "No active project workspace is configured for file tools.", recoveryHint: "Run this tool during an active Agent work order.", }); } let root: string; if (args.root !== undefined && typeof args.root === "string") { const resolved = resolveProjectPath(this.toolbox, args.root); if (typeof resolved === "string") return resolved; root = resolved.absolutePath; } else { root = path.resolve(projectRoot); } try { const matches = await this.globMatch(pattern, root, projectRoot); const limited = matches.slice(0, MAX_RESULTS); if (matches.length > MAX_RESULTS) { return [ `Found ${matches.length} files matching "${pattern}" (showing first ${MAX_RESULTS}):`, "", ...limited, "", `... and ${matches.length - MAX_RESULTS} more files`, ].join("\n"); } return [ `Found ${matches.length} file(s) matching "${pattern}":`, "", ...matches, ].join("\n"); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error("failed to glob", { pattern, root, error: errorMessage }); return toolError({ code: "OPERATION_FAILED", message: `Failed to search: ${errorMessage}`, }); } } private async globMatch(pattern: string, root: string, projectRoot: string): Promise { const results: string[] = []; const parts = pattern.split("/"); const globStarIndex = parts.indexOf("**"); let isRecursive: boolean; let searchPattern: string; let resolvedRoot: string; if (globStarIndex !== -1) { isRecursive = true; const beforeParts = parts.slice(0, globStarIndex); const afterParts = parts.slice(globStarIndex + 1); if (beforeParts.length > 0) { const prefixPath = beforeParts.join("/"); resolvedRoot = path.resolve(root, prefixPath); const rel = path.relative(projectRoot, resolvedRoot); if (rel.startsWith("..") || path.isAbsolute(rel)) { return results; } } else { resolvedRoot = root; } searchPattern = afterParts.length > 0 ? afterParts.join("/") : "*"; } else { isRecursive = false; searchPattern = pattern; resolvedRoot = root; } const regexPattern = this.globToRegex(searchPattern); await this.recurseDir(resolvedRoot, regexPattern, isRecursive, results, 0, 20, projectRoot); return results.sort(); } private globToRegex(pattern: string): RegExp { let regexStr = pattern .replace(/[.+^${}()|[\]\\]/g, "\\$&") .replace(/\*/g, ".*") .replace(/\?/g, "."); return new RegExp("^" + regexStr + "$"); } private async recurseDir( dir: string, pattern: RegExp, recursive: boolean, results: string[], depth: number, maxDepth: number, projectRoot: string, ): Promise { if (depth > maxDepth) return; let entries; try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const fullPath = path.join(dir, entry.name); const relativePath = path.relative(projectRoot, fullPath); if (entry.isFile()) { if (pattern.test(entry.name)) { results.push(relativePath); } } else if (entry.isDirectory()) { if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist" || entry.name === "build") { continue; } if (recursive) { await this.recurseDir(fullPath, pattern, recursive, results, depth + 1, maxDepth, projectRoot); } else if (pattern.test(entry.name)) { results.push(relativePath + "/"); } } } } }