gadget/packages/ai-toolbox/src/system/glob.ts

187 lines
5.6 KiB
TypeScript

// src/system/glob.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// 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<string> {
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<string[]> {
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<void> {
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 + "/");
}
}
}
}
}