187 lines
5.6 KiB
TypeScript
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 + "/");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|