add support for ripgrep search (#30)
This commit is contained in:
1 parent
6f8f9cf839
commit
5c3ba942ea
7 files changed
+552
-10
No files matched your search
Generated
+9
-2
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user