PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / abilities / core / grep-search.php

grep-search.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.8, at inc/abilities/core/grep-search.php

580 lines 17.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Grep Search Ability
4 *
5 * Unified search across files, database, options, and WordPress content.
6 * Like Claude Code's Grep tool — searches everywhere for a pattern.
7 *
8 * @since 0.0.5
9 * @package zip-ai
10 */
11
12 namespace ZipAI\MCP\Classes\Abilities\Core;
13
14 use ZipAI\MCP\Classes\Abilities\Abstract_Ability;
15 use ZipAI\MCP\Classes\Core\Tool_Types;
16 use ZipAI\MCP\Classes\Core\Response;
17
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 class GrepSearch extends Abstract_Ability {
23
24 /**
25 * Max results per source.
26 */
27 const MAX_RESULTS = 30;
28
29 /**
30 * Allowed file extensions for file search.
31 */
32 const FILE_EXTENSIONS = array( 'php', 'js', 'jsx', 'ts', 'tsx', 'css', 'scss', 'html', 'json', 'txt', 'md', 'xml' );
33
34 /**
35 * Configure the ability.
36 *
37 * @since 0.0.5
38 */
39 public function configure() {
40 $this->id = 'zipai/search-grep';
41 $this->label = 'Grep Search';
42 $this->description = 'Search for a text or regex pattern across the WordPress codebase and database. '
43 . 'Use this FIRST when debugging or exploring — find where code or config lives before editing. '
44 . 'Sources: "files" scans wp-content/ (active theme first, then all themes/plugins, up to 1000 files); '
45 . '"options" searches wp_options by name and value; "posts" searches post title and content; '
46 . '"meta" searches postmeta and usermeta; "database" searches all core tables; "all" runs every source. '
47 . 'Narrow file scope with path (e.g., "plugins/my-plugin") and file_type (e.g., "php", "js"). '
48 . 'Supports both plain-text and regex patterns.';
49 $this->capability = 'manage_options';
50 }
51
52 /**
53 * Get tool type.
54 *
55 * @since 0.0.5
56 * @return string
57 */
58 public function get_tool_type() {
59 return Tool_Types::SEARCH;
60 }
61
62 /**
63 * Get input schema.
64 *
65 * @since 0.0.5
66 * @return array<string,mixed>
67 */
68 public function get_input_schema() {
69 return array(
70 'type' => 'object',
71 'properties' => array(
72 'pattern' => array(
73 'type' => 'string',
74 'description' => 'Text or regex pattern to search for.',
75 ),
76 'source' => array(
77 'type' => 'string',
78 'enum' => array( 'all', 'files', 'database', 'options', 'posts', 'meta' ),
79 'default' => 'all',
80 'description' => 'Where to search. "all" searches everywhere. "files" searches wp-content/ files. '
81 . '"database" searches across all tables. "options" searches wp_options. '
82 . '"posts" searches post content/title. "meta" searches post_meta and user_meta.',
83 ),
84 'path' => array(
85 'type' => 'string',
86 'description' => 'For file search: directory path relative to wp-content/ (e.g., "plugins/my-plugin"). Default searches all of wp-content/.',
87 ),
88 'file_type' => array(
89 'type' => 'string',
90 'description' => 'For file search: filter by extension (e.g., "php", "js", "css"). Default searches all allowed types.',
91 ),
92 ),
93 'required' => array( 'pattern' ),
94 );
95 }
96
97 /**
98 * Execute the search.
99 *
100 * @since 0.0.5
101 * @param array<string,mixed> $input Validated input.
102 * @return array<string,mixed> Response data.
103 */
104 public function execute( $input ) {
105 $pattern = isset( $input['pattern'] ) && is_string( $input['pattern'] ) ? $input['pattern'] : '';
106 $source = isset( $input['source'] ) && is_string( $input['source'] ) ? $input['source'] : 'all';
107 $path = isset( $input['path'] ) && is_string( $input['path'] ) ? $input['path'] : '';
108 $file_type = isset( $input['file_type'] ) && is_string( $input['file_type'] ) ? $input['file_type'] : '';
109
110 if ( empty( $pattern ) ) {
111 return Response::error( 'Search pattern is required.' );
112 }
113
114 if ( strlen( $pattern ) < 2 ) {
115 return Response::error( 'Pattern too short. Minimum 2 characters.' );
116 }
117
118 $results = array();
119 $searched = array();
120
121 if ( in_array( $source, array( 'all', 'files' ), true ) ) {
122 $results['files'] = $this->search_files( $pattern, $path, $file_type );
123 $searched[] = 'files';
124 }
125
126 if ( in_array( $source, array( 'all', 'database' ), true ) ) {
127 $results['database'] = $this->search_database( $pattern );
128 $searched[] = 'database';
129 }
130
131 if ( in_array( $source, array( 'all', 'options' ), true ) ) {
132 $results['options'] = $this->search_options( $pattern );
133 $searched[] = 'options';
134 }
135
136 if ( in_array( $source, array( 'all', 'posts' ), true ) ) {
137 $results['posts'] = $this->search_posts( $pattern );
138 $searched[] = 'posts';
139 }
140
141 if ( in_array( $source, array( 'all', 'meta' ), true ) ) {
142 $results['meta'] = $this->search_meta( $pattern );
143 $searched[] = 'meta';
144 }
145
146 $total_matches = 0;
147 foreach ( $results as $source_results ) {
148 $total_matches += $source_results['count'];
149 }
150
151 return Response::success(
152 sprintf( 'Found %d matches across %s.', $total_matches, implode( ', ', $searched ) ),
153 array(
154 'pattern' => $pattern,
155 'total_matches' => $total_matches,
156 'sources' => $searched,
157 'results' => $results,
158 )
159 );
160 }
161
162 /**
163 * Search files in wp-content/ for a pattern.
164 *
165 * @since 0.0.5
166 * @param string $pattern Text or regex pattern to search for.
167 * @param string $path Directory path relative to wp-content/.
168 * @param string $file_type File extension filter (without leading dot).
169 * @return array{count:int,files_scanned?:int,error?:string,matches:array<int,array{file:string,matches:array<int,array{line:int,text:string}>}>} File match results.
170 */
171 private function search_files( $pattern, $path, $file_type ) {
172 $base_dir = WP_CONTENT_DIR;
173 if ( ! empty( $path ) ) {
174 $search_dir = $base_dir . '/' . ltrim( str_replace( '..', '', $path ), '/' );
175 if ( ! is_dir( $search_dir ) ) {
176 return array(
177 'count' => 0,
178 'error' => "Directory not found: {$path}",
179 'matches' => array(),
180 );
181 }
182 $base_dir = $search_dir;
183 }
184
185 // When searching all of wp-content/, search active theme first, then plugins
186 $search_dirs = array( $base_dir );
187 if ( WP_CONTENT_DIR === $base_dir && empty( $path ) ) {
188 $search_dirs = array(
189 get_stylesheet_directory(), // Active child theme
190 get_template_directory(), // Active parent theme
191 get_theme_root(), // All themes
192 WP_PLUGIN_DIR, // All plugins
193 WPMU_PLUGIN_DIR, // Must-use plugins
194 );
195 $search_dirs = array_unique( array_filter( $search_dirs, 'is_dir' ) );
196 }
197
198 /**
199 * Accumulated file match entries.
200 *
201 * @var array<int,array{file:string,matches:array<int,array{line:int,text:string}>}> $matches
202 */
203 $matches = array();
204 $scanned = 0;
205 $max_files = 1000;
206 /**
207 * Map of already-scanned file paths.
208 *
209 * @var array<string,bool> $seen
210 */
211 $seen = array();
212
213 foreach ( $search_dirs as $dir ) {
214 $this->scan_dir_for_pattern( $dir, $pattern, $file_type, $matches, $scanned, $max_files, $seen );
215 if ( $scanned >= $max_files || count( $matches ) >= self::MAX_RESULTS ) {
216 break;
217 }
218 }
219
220 return array(
221 'count' => count( $matches ),
222 'files_scanned' => $scanned,
223 'matches' => $matches,
224 );
225 }
226
227 /**
228 * Scan a single directory for pattern matches.
229 *
230 * @since 0.0.5
231 * @param string $base_dir Directory to scan.
232 * @param string $pattern Text or regex pattern to search for.
233 * @param string $file_type File extension filter (without leading dot).
234 * @param array<int,array{file:string,matches:array<int,array{line:int,text:string}>}> $matches Accumulated match entries, by reference.
235 * @param int $scanned Running count of scanned files, by reference.
236 * @param int $max_files Maximum number of files to scan.
237 * @param array<string,bool> $seen Map of already-seen file paths, by reference.
238 * @return void
239 */
240 private function scan_dir_for_pattern( $base_dir, $pattern, $file_type, &$matches, &$scanned, $max_files, &$seen ) {
241
242 $extensions = self::FILE_EXTENSIONS;
243 if ( ! empty( $file_type ) ) {
244 $file_type = ltrim( $file_type, '.' );
245 $extensions = array( $file_type );
246 }
247
248 $iterator = new \RecursiveIteratorIterator(
249 new \RecursiveDirectoryIterator( $base_dir, \RecursiveDirectoryIterator::SKIP_DOTS ),
250 \RecursiveIteratorIterator::LEAVES_ONLY
251 );
252
253 foreach ( $iterator as $file ) {
254 if ( $scanned >= $max_files || count( $matches ) >= self::MAX_RESULTS ) {
255 break;
256 }
257
258 if ( ! $file instanceof \SplFileInfo ) {
259 continue;
260 }
261
262 if ( ! $file->isFile() ) {
263 continue;
264 }
265
266 $filepath = $file->getPathname();
267
268 // Skip already seen files (from priority scanning).
269 if ( isset( $seen[ $filepath ] ) ) {
270 continue;
271 }
272 $seen[ $filepath ] = true;
273
274 $ext = strtolower( $file->getExtension() );
275 if ( ! in_array( $ext, $extensions, true ) ) {
276 continue;
277 }
278
279 // Skip vendor/node_modules/build directories.
280 if ( preg_match( '#/(vendor|node_modules|build|dist|\.git)/#', $filepath ) ) {
281 continue;
282 }
283
284 // Skip files larger than 500KB.
285 if ( $file->getSize() > 512000 ) {
286 continue;
287 }
288
289 ++$scanned;
290
291 $content = file_get_contents( $filepath ); // phpcs:ignore WordPress.WP.AlternativeFunctions
292 if ( false === $content ) {
293 continue;
294 }
295
296 // Find matching lines — try regex first, fall back to plain text.
297 $lines = explode( "\n", $content );
298 $matched_lines = array();
299 $is_regex = @preg_match( '/' . $pattern . '/i', '' ) !== false; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- probes whether the user-supplied pattern is a valid regex; a malformed pattern yields false and falls back to plain-text search below.
300
301 foreach ( $lines as $num => $line ) {
302 $found = $is_regex
303 ? @preg_match( '/' . $pattern . '/i', $line ) // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- applies the user-supplied regex per line; validity was already probed above and any match failure is treated as no match.
304 : ( false !== stripos( $line, $pattern ) );
305
306 if ( $found ) {
307 $matched_lines[] = array(
308 'line' => $num + 1,
309 'text' => mb_substr( trim( $line ), 0, 200 ),
310 );
311 if ( count( $matched_lines ) >= 5 ) {
312 break;
313 }
314 }
315 }
316
317 if ( ! empty( $matched_lines ) ) {
318 $relative = str_replace( WP_CONTENT_DIR, basename( WP_CONTENT_DIR ), $filepath );
319 $matches[] = array(
320 'file' => $relative,
321 'matches' => $matched_lines,
322 );
323 }
324 }
325 }
326
327 /**
328 * Search database tables for a pattern.
329 *
330 * @since 0.0.5
331 * @param string $pattern Text or regex pattern to search for.
332 * @return array{count:int,tables:int,matches:array<int,array{table:string,count:int,columns:array<int,string>,sample:array<int,array<string,string|null>>}>} Database match results.
333 */
334 private function search_database( $pattern ) {
335 /**
336 * Narrowed type for `$wpdb`.
337 *
338 * @var \wpdb $wpdb
339 */
340 global $wpdb;
341 $matches = array();
342 $like = '%' . $wpdb->esc_like( $pattern ) . '%';
343
344 // Search key tables with text columns.
345 $searches = array(
346 $wpdb->posts => array( 'post_title', 'post_content', 'post_excerpt', 'post_name' ),
347 $wpdb->postmeta => array( 'meta_key', 'meta_value' ),
348 $wpdb->options => array( 'option_name', 'option_value' ),
349 $wpdb->usermeta => array( 'meta_key', 'meta_value' ),
350 $wpdb->comments => array( 'comment_content', 'comment_author', 'comment_author_url' ),
351 );
352
353 foreach ( $searches as $table => $columns ) {
354 $conditions = array();
355 foreach ( $columns as $col ) {
356 $conditions[] = $wpdb->prepare( "`{$col}` LIKE %s", $like ); // phpcs:ignore WordPress.DB.PreparedSQL
357 }
358 $where = implode( ' OR ', $conditions );
359
360 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
361 $count = $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE {$where}" );
362
363 if ( $count > 0 ) {
364 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
365 $sample = $wpdb->get_results(
366 "SELECT * FROM `{$table}` WHERE {$where} LIMIT 5", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
367 ARRAY_A
368 );
369 if ( ! is_array( $sample ) ) {
370 $sample = array();
371 }
372 // Truncate long values in sample.
373 /**
374 * Narrowed type for `$sample`.
375 *
376 * @var array<int,array<string,string|null>> $sample
377 */
378 $sample = array_map(
379 function ( $row ) {
380 return array_map(
381 function ( $v ) {
382 return is_string( $v ) && strlen( $v ) > 150 ? substr( $v, 0, 150 ) . '...' : $v;
383 },
384 $row
385 );
386 },
387 $sample
388 );
389
390 $matches[] = array(
391 'table' => str_replace( $wpdb->prefix, '{prefix}', $table ),
392 'count' => (int) $count,
393 'columns' => $columns,
394 'sample' => $sample,
395 );
396 }
397 }
398
399 return array(
400 'count' => array_sum( array_column( $matches, 'count' ) ),
401 'tables' => count( $matches ),
402 'matches' => $matches,
403 );
404 }
405
406 /**
407 * Search wp_options for a pattern.
408 *
409 * @since 0.0.5
410 * @param string $pattern Text or regex pattern to search for.
411 * @return array{count:int,options:array<int,array{option_name:string,option_value:string}>} Option match results.
412 */
413 private function search_options( $pattern ) {
414 /**
415 * Narrowed type for `$wpdb`.
416 *
417 * @var \wpdb $wpdb
418 */
419 global $wpdb;
420 $like = '%' . $wpdb->esc_like( $pattern ) . '%';
421
422 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
423 $results = $wpdb->get_results(
424 $wpdb->prepare(
425 'SELECT option_name, LEFT(option_value, 200) as option_value FROM %i WHERE option_name LIKE %s OR option_value LIKE %s LIMIT %d',
426 $wpdb->options,
427 $like,
428 $like,
429 self::MAX_RESULTS
430 ),
431 ARRAY_A
432 );
433 if ( ! is_array( $results ) ) {
434 $results = array();
435 }
436 /**
437 * Narrowed type for `$results`.
438 *
439 * @var array<int,array{option_name:string,option_value:string}> $results
440 */
441 return array(
442 'count' => count( $results ),
443 'options' => $results,
444 );
445 }
446
447 /**
448 * Search post content and titles.
449 *
450 * @since 0.0.5
451 * @param string $pattern Text or regex pattern to search for.
452 * @return array{count:int,matches:array<int,array{ID:int,title:string,type:string,status:string,url:string|false}>} Post match results.
453 */
454 private function search_posts( $pattern ) {
455 $query = new \WP_Query(
456 array(
457 's' => $pattern,
458 'post_type' => 'any',
459 'post_status' => 'any',
460 'posts_per_page' => self::MAX_RESULTS,
461 )
462 );
463
464 /**
465 * Narrowed type for `$posts`.
466 *
467 * @var array<int,\WP_Post> $posts
468 */
469 $posts = $query->posts;
470
471 $matches = array_map(
472 function ( \WP_Post $post ) {
473 return array(
474 'ID' => $post->ID,
475 'title' => $post->post_title,
476 'type' => $post->post_type,
477 'status' => $post->post_status,
478 'url' => get_permalink( $post->ID ),
479 );
480 },
481 $posts
482 );
483
484 return array(
485 'count' => $query->found_posts,
486 'matches' => $matches,
487 );
488 }
489
490 /**
491 * Search post_meta and user_meta.
492 *
493 * @since 0.0.5
494 * @param string $pattern Text or regex pattern to search for.
495 * @return array{count:int,matches:array<string,array{count:int,matches:array<int,array<string,string|null>>}>} Meta match results grouped by post_meta/user_meta.
496 */
497 private function search_meta( $pattern ) {
498 /**
499 * Narrowed type for `$wpdb`.
500 *
501 * @var \wpdb $wpdb
502 */
503 global $wpdb;
504 $like = '%' . $wpdb->esc_like( $pattern ) . '%';
505 $results = array();
506
507 // Post meta.
508 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
509 $post_meta = $wpdb->get_results(
510 $wpdb->prepare(
511 'SELECT pm.meta_id, pm.post_id, pm.meta_key, LEFT(pm.meta_value, 150) as meta_value, p.post_title
512 FROM %i pm
513 LEFT JOIN %i p ON pm.post_id = p.ID
514 WHERE pm.meta_key LIKE %s OR pm.meta_value LIKE %s
515 LIMIT %d',
516 $wpdb->postmeta,
517 $wpdb->posts,
518 $like,
519 $like,
520 self::MAX_RESULTS
521 ),
522 ARRAY_A
523 );
524 if ( ! is_array( $post_meta ) ) {
525 $post_meta = array();
526 }
527 /**
528 * Narrowed type for `$post_meta`.
529 *
530 * @var array<int,array<string,string|null>> $post_meta
531 */
532 if ( ! empty( $post_meta ) ) {
533 $results['post_meta'] = array(
534 'count' => count( $post_meta ),
535 'matches' => $post_meta,
536 );
537 }
538
539 // User meta.
540 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPressVIPMinimum.Variables.RestrictedVariables.user_meta__wpdb__users -- grep tool searches meta values across all users; get_user_meta cannot query by value.
541 $user_meta = $wpdb->get_results(
542 $wpdb->prepare(
543 'SELECT um.umeta_id, um.user_id, um.meta_key, LEFT(um.meta_value, 150) as meta_value, u.user_login
544 FROM %i um
545 LEFT JOIN %i u ON um.user_id = u.ID
546 WHERE um.meta_key LIKE %s OR um.meta_value LIKE %s
547 LIMIT %d',
548 $wpdb->usermeta,
549 $wpdb->users,
550 $like,
551 $like,
552 self::MAX_RESULTS
553 ),
554 ARRAY_A
555 );
556 // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPressVIPMinimum.Variables.RestrictedVariables.user_meta__wpdb__users
557 if ( ! is_array( $user_meta ) ) {
558 $user_meta = array();
559 }
560 /**
561 * Narrowed type for `$user_meta`.
562 *
563 * @var array<int,array<string,string|null>> $user_meta
564 */
565 if ( ! empty( $user_meta ) ) {
566 $results['user_meta'] = array(
567 'count' => count( $user_meta ),
568 'matches' => $user_meta,
569 );
570 }
571
572 $total = ( $results['post_meta']['count'] ?? 0 ) + ( $results['user_meta']['count'] ?? 0 );
573
574 return array(
575 'count' => $total,
576 'matches' => $results,
577 );
578 }
579 }
580