add support for ripgrep search (#30)

This commit is contained in:
Ismail Pelaseyed authored and GitHub committed 2025-07-23 06:19:23 +02:00
1 parent 6f8f9cf839
commit 5c3ba942ea
7 files changed
+552 -10

No files matched your search

+9 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@vibe-kit/grok-cli",
"version": "0.0.2",
"version": "0.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@vibe-kit/grok-cli",
"version": "0.0.2",
"version": "0.0.4",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
@@ -20,6 +20,7 @@
"ink-markdown": "^1.0.4",
"openai": "^5.10.1",
"react": "^17.0.2",
"ripgrep-node": "^1.0.0",
"tiktoken": "^1.0.21"
},
"bin": {
@@ -3238,6 +3239,12 @@
"node": ">=0.10.0"
}
},
"node_modules/ripgrep-node": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ripgrep-node/-/ripgrep-node-1.0.0.tgz",
"integrity": "sha512-r/DBVFwrgHLpPvUZquwvRLE+UFLwB6NaeQVAetS3zOlUdyH3i20QBMzBMr7VU9702j0OxuJJ88WxRnAnjrD5DQ==",
"license": "GPL-3.0-or-later"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+1
View File
@@ -34,6 +34,7 @@
"ink-markdown": "^1.0.4",
"openai": "^5.10.1",
"react": "^17.0.2",
"ripgrep-node": "^1.0.0",
"tiktoken": "^1.0.21"
},
"devDependencies": {
+26 -4
View File
@@ -1,6 +1,12 @@
import { GrokClient, GrokMessage, GrokToolCall } from "../grok/client";
import { GROK_TOOLS } from "../grok/tools";
import { TextEditorTool, BashTool, TodoTool, ConfirmationTool } from "../tools";
import {
TextEditorTool,
BashTool,
TodoTool,
ConfirmationTool,
SearchTool,
} from "../tools";
import { ToolResult } from "../types";
import { EventEmitter } from "events";
import { createTokenCounter, TokenCounter } from "../utils/token-counter";
@@ -31,6 +37,7 @@ export class GrokAgent extends EventEmitter {
private bash: BashTool;
private todoTool: TodoTool;
private confirmationTool: ConfirmationTool;
private search: SearchTool;
private chatHistory: ChatEntry[] = [];
private messages: GrokMessage[] = [];
private tokenCounter: TokenCounter;
@@ -43,6 +50,7 @@ export class GrokAgent extends EventEmitter {
this.bash = new BashTool();
this.todoTool = new TodoTool();
this.confirmationTool = new ConfirmationTool();
this.search = new SearchTool();
this.tokenCounter = createTokenCounter("grok-4-latest");
// Load custom instructions
@@ -61,6 +69,7 @@ You have access to these tools:
- create_file: Create new files with content (ONLY use this for files that don't exist yet)
- str_replace_editor: Replace text in existing files (ALWAYS use this to edit or update existing files)
- bash: Execute bash commands (use for searching, file discovery, navigation, and system operations)
- search: Unified search tool for finding text content or files (similar to Cursor's search functionality)
- create_todo_list: Create a visual todo list for planning and tracking tasks
- update_todo_list: Update existing todos in your todo list
@@ -74,9 +83,9 @@ IMPORTANT TOOL USAGE RULES:
- Use create_file ONLY when creating entirely new files that don't exist
SEARCHING AND EXPLORATION:
- Use bash with commands like 'find', 'grep', 'rg' (ripgrep), 'ls', etc. for searching files and content
- Examples: 'find . -name "*.js"', 'grep -r "function" src/', 'rg "import.*react"'
- Use bash for directory navigation, file discovery, and content searching
- Use search for fast, powerful text search across files or finding files by name (unified search tool)
- Examples: search for text content like "import.*react", search for files like "component.tsx"
- Use bash with commands like 'find', 'grep', 'rg', 'ls' for complex file operations and navigation
- view_file is best for reading specific files you already know exist
When a user asks you to edit, update, modify, or change an existing file:
@@ -550,6 +559,19 @@ Current working directory: ${process.cwd()}`,
case "update_todo_list":
return await this.todoTool.updateTodoList(args.updates);
case "search":
return await this.search.search(args.query, {
searchType: args.search_type,
includePattern: args.include_pattern,
excludePattern: args.exclude_pattern,
caseSensitive: args.case_sensitive,
wholeWord: args.whole_word,
regex: args.regex,
maxResults: args.max_results,
fileTypes: args.file_types,
includeHidden: args.include_hidden,
});
default:
return {
success: false,
+64 -2
View File
@@ -62,7 +62,8 @@ export const GROK_TOOLS: GrokTool[] = [
},
old_str: {
type: "string",
description: "Text to replace (must match exactly, or will use fuzzy matching for multi-line strings)",
description:
"Text to replace (must match exactly, or will use fuzzy matching for multi-line strings)",
},
new_str: {
type: "string",
@@ -70,7 +71,8 @@ export const GROK_TOOLS: GrokTool[] = [
},
replace_all: {
type: "boolean",
description: "Replace all occurrences (default: false, only replaces first occurrence)",
description:
"Replace all occurrences (default: false, only replaces first occurrence)",
},
},
required: ["path", "old_str", "new_str"],
@@ -94,6 +96,66 @@ export const GROK_TOOLS: GrokTool[] = [
},
},
},
{
type: "function",
function: {
name: "search",
description:
"Unified search tool for finding text content or files (similar to Cursor's search)",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "Text to search for or file name/path pattern",
},
search_type: {
type: "string",
enum: ["text", "files", "both"],
description:
"Type of search: 'text' for content search, 'files' for file names, 'both' for both (default: 'both')",
},
include_pattern: {
type: "string",
description:
"Glob pattern for files to include (e.g. '*.ts', '*.js')",
},
exclude_pattern: {
type: "string",
description:
"Glob pattern for files to exclude (e.g. '*.log', 'node_modules')",
},
case_sensitive: {
type: "boolean",
description:
"Whether search should be case sensitive (default: false)",
},
whole_word: {
type: "boolean",
description: "Whether to match whole words only (default: false)",
},
regex: {
type: "boolean",
description: "Whether query is a regex pattern (default: false)",
},
max_results: {
type: "number",
description: "Maximum number of results to return (default: 50)",
},
file_types: {
type: "array",
items: { type: "string" },
description: "File types to search (e.g. ['js', 'ts', 'py'])",
},
include_hidden: {
type: "boolean",
description: "Whether to include hidden files (default: false)",
},
},
required: ["query"],
},
},
},
{
type: "function",
function: {
+2 -1
View File
@@ -1,4 +1,5 @@
export { TextEditorTool } from "./text-editor";
export { BashTool } from "./bash";
export { TextEditorTool } from "./text-editor";
export { TodoTool } from "./todo-tool";
export { ConfirmationTool } from "./confirmation-tool";
export { SearchTool } from "./search";
+443
View File
@@ -0,0 +1,443 @@
import { spawn } from "child_process";
import { ToolResult } from "../types";
import { ConfirmationService } from "../utils/confirmation-service";
import * as fs from "fs-extra";
import * as path from "path";
export interface SearchResult {
file: string;
line: number;
column: number;
text: string;
match: string;
}
export interface FileSearchResult {
path: string;
name: string;
score: number;
}
export interface UnifiedSearchResult {
type: "text" | "file";
file: string;
line?: number;
column?: number;
text?: string;
match?: string;
score?: number;
}
export class SearchTool {
private confirmationService = ConfirmationService.getInstance();
private currentDirectory: string = process.cwd();
/**
* Unified search method that can search for text content or find files
*/
async search(
query: string,
options: {
searchType?: "text" | "files" | "both";
includePattern?: string;
excludePattern?: string;
caseSensitive?: boolean;
wholeWord?: boolean;
regex?: boolean;
maxResults?: number;
fileTypes?: string[];
excludeFiles?: string[];
includeHidden?: boolean;
} = {}
): Promise<ToolResult> {
try {
const searchType = options.searchType || "both";
const results: UnifiedSearchResult[] = [];
// Search for text content if requested
if (searchType === "text" || searchType === "both") {
const textResults = await this.executeRipgrep(query, options);
results.push(
...textResults.map((r) => ({
type: "text" as const,
file: r.file,
line: r.line,
column: r.column,
text: r.text,
match: r.match,
}))
);
}
// Search for files if requested
if (searchType === "files" || searchType === "both") {
const fileResults = await this.findFilesByPattern(query, options);
results.push(
...fileResults.map((r) => ({
type: "file" as const,
file: r.path,
score: r.score,
}))
);
}
if (results.length === 0) {
return {
success: true,
output: `No results found for "${query}"`,
};
}
const formattedOutput = this.formatUnifiedResults(
results,
query,
searchType
);
return {
success: true,
output: formattedOutput,
};
} catch (error: any) {
return {
success: false,
error: `Search error: ${error.message}`,
};
}
}
/**
* Execute ripgrep command with specified options
*/
private async executeRipgrep(
query: string,
options: {
includePattern?: string;
excludePattern?: string;
caseSensitive?: boolean;
wholeWord?: boolean;
regex?: boolean;
maxResults?: number;
fileTypes?: string[];
excludeFiles?: string[];
}
): Promise<SearchResult[]> {
return new Promise((resolve, reject) => {
const args = [
"--json",
"--with-filename",
"--line-number",
"--column",
"--no-heading",
"--color=never",
];
// Add case sensitivity
if (!options.caseSensitive) {
args.push("--ignore-case");
}
// Add whole word matching
if (options.wholeWord) {
args.push("--word-regexp");
}
// Add regex mode
if (!options.regex) {
args.push("--fixed-strings");
}
// Add max results limit
if (options.maxResults) {
args.push("--max-count", options.maxResults.toString());
}
// Add file type filters
if (options.fileTypes) {
options.fileTypes.forEach((type) => {
args.push("--type", type);
});
}
// Add include pattern
if (options.includePattern) {
args.push("--glob", options.includePattern);
}
// Add exclude pattern
if (options.excludePattern) {
args.push("--glob", `!${options.excludePattern}`);
}
// Add exclude files
if (options.excludeFiles) {
options.excludeFiles.forEach((file) => {
args.push("--glob", `!${file}`);
});
}
// Respect gitignore and common ignore patterns
args.push(
"--no-require-git",
"--follow",
"--glob",
"!.git/**",
"--glob",
"!node_modules/**",
"--glob",
"!.DS_Store",
"--glob",
"!*.log"
);
// Add query and search directory
args.push(query, this.currentDirectory);
const rg = spawn("rg", args);
let output = "";
let errorOutput = "";
rg.stdout.on("data", (data) => {
output += data.toString();
});
rg.stderr.on("data", (data) => {
errorOutput += data.toString();
});
rg.on("close", (code) => {
if (code === 0 || code === 1) {
// 0 = found, 1 = not found
const results = this.parseRipgrepOutput(output);
resolve(results);
} else {
reject(new Error(`Ripgrep failed with code ${code}: ${errorOutput}`));
}
});
rg.on("error", (error) => {
reject(error);
});
});
}
/**
* Parse ripgrep JSON output into SearchResult objects
*/
private parseRipgrepOutput(output: string): SearchResult[] {
const results: SearchResult[] = [];
const lines = output
.trim()
.split("\n")
.filter((line) => line.length > 0);
for (const line of lines) {
try {
const parsed = JSON.parse(line);
if (parsed.type === "match") {
const data = parsed.data;
results.push({
file: data.path.text,
line: data.line_number,
column: data.submatches[0]?.start || 0,
text: data.lines.text.trim(),
match: data.submatches[0]?.match?.text || "",
});
}
} catch (e) {
// Skip invalid JSON lines
continue;
}
}
return results;
}
/**
* Find files by pattern using a simple file walking approach
*/
private async findFilesByPattern(
pattern: string,
options: {
maxResults?: number;
includeHidden?: boolean;
excludePattern?: string;
}
): Promise<FileSearchResult[]> {
const files: FileSearchResult[] = [];
const maxResults = options.maxResults || 50;
const searchPattern = pattern.toLowerCase();
const walkDir = async (dir: string, depth: number = 0): Promise<void> => {
if (depth > 10 || files.length >= maxResults) return; // Prevent infinite recursion and limit results
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (files.length >= maxResults) break;
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(this.currentDirectory, fullPath);
// Skip hidden files unless explicitly included
if (!options.includeHidden && entry.name.startsWith(".")) {
continue;
}
// Skip common directories
if (
entry.isDirectory() &&
[
"node_modules",
".git",
".svn",
".hg",
"dist",
"build",
".next",
".cache",
].includes(entry.name)
) {
continue;
}
// Apply exclude pattern
if (
options.excludePattern &&
relativePath.includes(options.excludePattern)
) {
continue;
}
if (entry.isFile()) {
const score = this.calculateFileScore(
entry.name,
relativePath,
searchPattern
);
if (score > 0) {
files.push({
path: relativePath,
name: entry.name,
score,
});
}
} else if (entry.isDirectory()) {
await walkDir(fullPath, depth + 1);
}
}
} catch (error) {
// Skip directories we can't read
}
};
await walkDir(this.currentDirectory);
// Sort by score (descending) and return top results
return files.sort((a, b) => b.score - a.score).slice(0, maxResults);
}
/**
* Calculate fuzzy match score for file names
*/
private calculateFileScore(
fileName: string,
filePath: string,
pattern: string
): number {
const lowerFileName = fileName.toLowerCase();
const lowerFilePath = filePath.toLowerCase();
// Exact matches get highest score
if (lowerFileName === pattern) return 100;
if (lowerFileName.includes(pattern)) return 80;
// Path matches get medium score
if (lowerFilePath.includes(pattern)) return 60;
// Fuzzy matching - check if all characters of pattern exist in order
let patternIndex = 0;
for (
let i = 0;
i < lowerFileName.length && patternIndex < pattern.length;
i++
) {
if (lowerFileName[i] === pattern[patternIndex]) {
patternIndex++;
}
}
if (patternIndex === pattern.length) {
// All characters found in order - score based on how close they are
return Math.max(10, 40 - (fileName.length - pattern.length));
}
return 0;
}
/**
* Format unified search results for display
*/
private formatUnifiedResults(
results: UnifiedSearchResult[],
query: string,
searchType: string
): string {
if (results.length === 0) {
return `No results found for "${query}"`;
}
let output = `Search results for "${query}":\n`;
// Separate text and file results
const textResults = results.filter((r) => r.type === "text");
const fileResults = results.filter((r) => r.type === "file");
// Show all unique files (from both text matches and file matches)
const allFiles = new Set<string>();
// Add files from text results
textResults.forEach((result) => {
allFiles.add(result.file);
});
// Add files from file search results
fileResults.forEach((result) => {
allFiles.add(result.file);
});
const fileList = Array.from(allFiles);
const displayLimit = 8;
// Show files in compact format
fileList.slice(0, displayLimit).forEach((file) => {
// Count matches in this file for text results
const matchCount = textResults.filter((r) => r.file === file).length;
const matchIndicator = matchCount > 0 ? ` (${matchCount} matches)` : "";
output += ` ${file}${matchIndicator}\n`;
});
// Show "+X more" if there are additional results
if (fileList.length > displayLimit) {
const remaining = fileList.length - displayLimit;
output += ` ... +${remaining} more\n`;
}
return output.trim();
}
/**
* Update current working directory
*/
setCurrentDirectory(directory: string): void {
this.currentDirectory = directory;
}
/**
* Get current working directory
*/
getCurrentDirectory(): string {
return this.currentDirectory;
}
}
+7 -1
View File
@@ -92,7 +92,8 @@ export function ChatHistory({
return "Create";
case "bash":
return "Bash";
case "search":
return "Search";
case "create_todo_list":
return "Created Todo";
case "update_todo_list":
@@ -114,6 +115,11 @@ export function ChatHistory({
return "";
}
// Handle search tool specially - show the query
if (toolCall.function.name === "search") {
return args.query || "unknown";
}
return args.path || args.file_path || args.command || "unknown";
} catch {
return "unknown";