summaryrefslogtreecommitdiff
path: root/packages/cli/src/tools/glob.tool.ts
blob: 4c23c3cb2531e26d214473efd85bf999580f0b0a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
import fs from 'fs';
import path from 'path';
import fg from 'fast-glob';
import { SchemaValidator } from '../utils/schemaValidator.js';
import { BaseTool, ToolResult } from './tools.js';
import { shortenPath, makeRelative } from '../utils/paths.js';

/**
 * Parameters for the GlobTool
 */
export interface GlobToolParams {
  /**
   * The glob pattern to match files against
   */
  pattern: string;

  /**
   * The directory to search in (optional, defaults to current directory)
   */
  path?: string;
}

/**
 * Implementation of the GlobTool that finds files matching patterns,
 * sorted by modification time (newest first).
 */
export class GlobTool extends BaseTool<GlobToolParams, ToolResult> {
  /**
   * The root directory that this tool is grounded in.
   * All file operations will be restricted to this directory.
   */
  private rootDirectory: string;

  /**
   * Creates a new instance of the GlobTool
   * @param rootDirectory Root directory to ground this tool in. All operations will be restricted to this directory.
   */
  constructor(rootDirectory: string) {
    super(
      'glob',
      'FindFiles',
      'Efficiently finds files matching specific glob patterns (e.g., `src/**/*.ts`, `**/*.md`), returning absolute paths sorted by modification time (newest first). Ideal for quickly locating files based on their name or path structure, especially in large codebases.',
      {
        properties: {
          pattern: {
            description:
              "The glob pattern to match against (e.g., '*.py', 'src/**/*.js', 'docs/*.md').",
            type: 'string',
          },
          path: {
            description:
              'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.',
            type: 'string',
          },
        },
        required: ['pattern'],
        type: 'object',
      },
    );

    // Set the root directory
    this.rootDirectory = path.resolve(rootDirectory);
  }

  /**
   * Checks if a path is within the root directory.
   * This is a security measure to prevent the tool from accessing files outside of its designated root.
   * @param pathToCheck The path to check (expects an absolute path)
   * @returns True if the path is within the root directory, false otherwise
   */
  private isWithinRoot(pathToCheck: string): boolean {
    const absolutePathToCheck = path.resolve(pathToCheck);
    const normalizedPath = path.normalize(absolutePathToCheck);
    const normalizedRoot = path.normalize(this.rootDirectory);

    // Ensure the normalizedRoot ends with a path separator for proper prefix comparison
    const rootWithSep = normalizedRoot.endsWith(path.sep)
      ? normalizedRoot
      : normalizedRoot + path.sep;

    // Check if it's the root itself or starts with the root path followed by a separator.
    // This ensures that we don't accidentally allow access to parent directories.
    return (
      normalizedPath === normalizedRoot ||
      normalizedPath.startsWith(rootWithSep)
    );
  }

  /**
   * Validates the parameters for the tool.
   * Ensures that the provided parameters adhere to the expected schema and that the search path is valid and within the tool's root directory.
   * @param params Parameters to validate
   * @returns An error message string if invalid, null otherwise
   */
  validateToolParams(params: GlobToolParams): string | null {
    if (
      this.schema.parameters &&
      !SchemaValidator.validate(
        this.schema.parameters as Record<string, unknown>,
        params,
      )
    ) {
      return "Parameters failed schema validation. Ensure 'pattern' is a string and 'path' (if provided) is a string.";
    }

    // Determine the absolute path to check
    const searchDirAbsolute = params.path ?? this.rootDirectory;

    // Validate path is within root directory
    if (!this.isWithinRoot(searchDirAbsolute)) {
      return `Search path ("${searchDirAbsolute}") resolves outside the tool's root directory ("${this.rootDirectory}").`;
    }

    // Validate path exists and is a directory using the absolute path.
    // These checks prevent the tool from attempting to search in non-existent or non-directory paths, which would lead to errors.
    try {
      if (!fs.existsSync(searchDirAbsolute)) {
        return `Search path does not exist: ${shortenPath(makeRelative(searchDirAbsolute, this.rootDirectory))} (absolute: ${searchDirAbsolute})`;
      }
      if (!fs.statSync(searchDirAbsolute).isDirectory()) {
        return `Search path is not a directory: ${shortenPath(makeRelative(searchDirAbsolute, this.rootDirectory))} (absolute: ${searchDirAbsolute})`;
      }
    } catch (e: unknown) {
      // Catch potential permission errors during sync checks
      return `Error accessing search path: ${e}`;
    }

    // Validate glob pattern (basic non-empty check)
    if (
      !params.pattern ||
      typeof params.pattern !== 'string' ||
      params.pattern.trim() === ''
    ) {
      return "The 'pattern' parameter cannot be empty.";
    }
    // Could add more sophisticated glob pattern validation if needed

    return null; // Parameters are valid
  }

  /**
   * Gets a description of the glob operation.
   * @param params Parameters for the glob operation.
   * @returns A string describing the glob operation.
   */
  getDescription(params: GlobToolParams): string {
    let description = `'${params.pattern}'`;

    if (params.path) {
      const searchDir = params.path || this.rootDirectory;
      const relativePath = makeRelative(searchDir, this.rootDirectory);
      description += ` within ${shortenPath(relativePath)}`;
    }

    return description;
  }

  /**
   * Executes the glob search with the given parameters
   * @param params Parameters for the glob search
   * @returns Result of the glob search
   */
  async execute(params: GlobToolParams): Promise<ToolResult> {
    const validationError = this.validateToolParams(params);
    if (validationError) {
      return {
        llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
        returnDisplay: `**Error:** Failed to execute tool.`,
      };
    }

    try {
      // 1. Resolve the absolute search directory. Validation ensures it exists and is a directory.
      const searchDirAbsolute = params.path ?? this.rootDirectory;

      // 2. Perform Glob Search using fast-glob
      // We use fast-glob because it's performant and supports glob patterns.
      const entries = await fg(params.pattern, {
        cwd: searchDirAbsolute, // Search within this absolute directory
        absolute: true, // Return absolute paths
        onlyFiles: true, // Match only files
        stats: true, // Include file stats object for sorting
        dot: true, // Include files starting with a dot
        ignore: ['**/node_modules/**', '**/.git/**'], // Common sensible default, adjust as needed
        followSymbolicLinks: false, // Avoid potential issues with symlinks unless specifically needed
        suppressErrors: true, // Suppress EACCES errors for individual files (we handle dir access in validation)
      });

      // 3. Handle No Results
      if (!entries || entries.length === 0) {
        return {
          llmContent: `No files found matching pattern "${params.pattern}" within ${searchDirAbsolute}.`,
          returnDisplay: `No files found`,
        };
      }

      // 4. Sort Results by Modification Time (Newest First)
      // Sorting by modification time ensures that the most recently modified files are listed first.
      // This can be useful for quickly identifying the files that have been recently changed.
      // The stats object is guaranteed by the `stats: true` option in the fast-glob configuration.
      entries.sort((a, b) => {
        // Ensure stats exist before accessing mtime (though fg should provide them)
        const mtimeA = a.stats?.mtime?.getTime() ?? 0;
        const mtimeB = b.stats?.mtime?.getTime() ?? 0;
        return mtimeB - mtimeA; // Descending order
      });

      // 5. Format Output
      const sortedAbsolutePaths = entries.map((entry) => entry.path);

      // Convert absolute paths to relative paths (to rootDir) for clearer display
      const sortedRelativePaths = sortedAbsolutePaths.map((absPath) =>
        makeRelative(absPath, this.rootDirectory),
      );

      // Construct the result message
      const fileListDescription = sortedRelativePaths
        .map((p) => `  - ${shortenPath(p)}`)
        .join('\n');
      const fileCount = sortedRelativePaths.length;
      const relativeSearchDir = makeRelative(
        searchDirAbsolute,
        this.rootDirectory,
      );
      const displayPath = shortenPath(
        relativeSearchDir === '.' ? 'root directory' : relativeSearchDir,
      );

      return {
        llmContent: `Found ${fileCount} file(s) matching "${params.pattern}" within ${displayPath}, sorted by modification time (newest first):\n${fileListDescription}`,
        returnDisplay: `Found ${fileCount} matching file(s)`,
      };
    } catch (error) {
      // Catch unexpected errors during glob execution (less likely with suppressErrors=true, but possible)
      const errorMessage =
        error instanceof Error ? error.message : String(error);
      console.error(`GlobTool execute Error: ${errorMessage}`, error);
      return {
        llmContent: `Error during glob search operation: ${errorMessage}`,
        returnDisplay: `**Error:** An unexpected error occurred.`,
      };
    }
  }
}