UNPKG

@nanocollective/nanocoder

Version:

A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter

303 lines 15.2 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import { execFile } from 'node:child_process'; import path from 'node:path'; import { promisify } from 'node:util'; import { Box, Text } from 'ink'; import React from 'react'; import ToolMessage from '../components/tool-message.js'; import { BUFFER_FIND_FILES_BYTES, BUFFER_GREP_MULTIPLIER, DEFAULT_SEARCH_RESULTS, MAX_SEARCH_RESULTS, } from '../constants.js'; import { ThemeContext } from '../hooks/useTheme.js'; import { jsonSchema, tool } from '../types/core.js'; import { DEFAULT_IGNORE_DIRS, loadGitignore } from '../utils/gitignore-loader.js'; import { isValidFilePath } from '../utils/path-validation.js'; import { calculateTokens } from '../utils/token-calculator.js'; const execFileAsync = promisify(execFile); const GREP_TIMEOUT_MS = 30_000; const MAX_CONTEXT_LINES = 10; /** * Search file contents using grep */ async function searchFileContents(query, cwd, maxResults, caseSensitive, include, searchPath, wholeWord, contextLines) { try { const ig = loadGitignore(cwd); // Build grep arguments array to prevent command injection const grepArgs = [ '-rn', // recursive with line numbers '-E', // extended regex '-I', // skip binary files ]; // Add case sensitivity flag if (!caseSensitive) { grepArgs.push('-i'); } // Add whole word matching if (wholeWord) { grepArgs.push('-w'); } // Add context lines const hasContext = contextLines !== undefined && contextLines > 0; if (hasContext) { const clamped = Math.min(contextLines, MAX_CONTEXT_LINES); grepArgs.push('-C', `${clamped}`); } // Add include patterns if (include) { // Support brace expansion like "*.{ts,tsx}" → multiple --include args const braceMatch = include.match(/^\*\.\{(.+)\}$/); if (braceMatch) { for (const ext of braceMatch[1].split(',')) { grepArgs.push(`--include=*.${ext.trim()}`); } } else { grepArgs.push(`--include=${include}`); } } else { grepArgs.push('--include=*'); } // Dynamically add exclusions from DEFAULT_IGNORE_DIRS for (const dir of DEFAULT_IGNORE_DIRS) { grepArgs.push(`--exclude-dir=${dir}`); } // Add the search query (no escaping needed with array-based args) grepArgs.push(query); // Add search path (scoped directory or cwd) if (searchPath) { grepArgs.push(searchPath); } else { grepArgs.push('.'); } // Execute grep command with array-based arguments and timeout const { stdout } = await execFileAsync('grep', grepArgs, { cwd, maxBuffer: BUFFER_FIND_FILES_BYTES * BUFFER_GREP_MULTIPLIER, timeout: GREP_TIMEOUT_MS, }); const matches = []; const cwdPrefix = path.resolve(cwd) + path.sep; if (hasContext) { // Context mode: split by group separator (BSD grep default: --) const groups = stdout.trim().split('\n--\n'); for (const group of groups) { const lines = group.split('\n').filter(Boolean); // First pass: find file path and line number from a match line let file = ''; let lineNum = 0; for (const l of lines) { const matchLine = l.match(/^\.\/(.+?):(\d+):(.*)$/) || l.match(/^(.+?):(\d+):(.*)$/); if (matchLine) { let filePath = matchLine[1]; if (path.isAbsolute(filePath)) { filePath = filePath.startsWith(cwdPrefix) ? filePath.slice(cwdPrefix.length) : filePath; } file = filePath; lineNum = parseInt(matchLine[2], 10); break; } } if (!file) continue; if (ig.ignores(file)) continue; // Second pass: extract content from all lines using known file path const contentLines = []; for (const l of lines) { const prefix = l.startsWith('./') ? `./${file}` : file; if (l.startsWith(`${prefix}:`)) { const rest = l.slice(prefix.length + 1); const colonIdx = rest.indexOf(':'); const num = rest.slice(0, colonIdx); const text = rest.slice(colonIdx + 1); contentLines.push(`${num}: ${text}`); } else if (l.startsWith(`${prefix}-`)) { const rest = l.slice(prefix.length + 1); const dashIdx = rest.indexOf('-'); const num = rest.slice(0, dashIdx); const text = rest.slice(dashIdx + 1); contentLines.push(`${num}: ${text}`); } } // Higher content limit for context blocks const MAX_CONTEXT_CONTENT_LENGTH = 1500; let content = contentLines.join('\n'); if (content.length > MAX_CONTEXT_CONTENT_LENGTH) { content = content.slice(0, MAX_CONTEXT_CONTENT_LENGTH) + '…'; } matches.push({ file, line: lineNum, content }); if (matches.length >= maxResults) break; } } else { // Standard mode: parse line-by-line const lines = stdout.trim().split('\n').filter(Boolean); for (const line of lines) { // Match both relative (./path) and absolute (/abs/path) grep output const match = line.match(/^\.\/(.+?):(\d+):(.*)$/) || line.match(/^(.+?):(\d+):(.*)$/); if (match) { // Normalize to relative path from cwd let filePath = match[1]; if (path.isAbsolute(filePath)) { filePath = filePath.startsWith(cwdPrefix) ? filePath.slice(cwdPrefix.length) : filePath; } // Skip files ignored by gitignore if (ig.ignores(filePath)) { continue; } // Truncate long lines to prevent token explosion const MAX_CONTENT_LENGTH = 300; let content = match[3].trim(); if (content.length > MAX_CONTENT_LENGTH) { content = content.slice(0, MAX_CONTENT_LENGTH) + '…'; } matches.push({ file: filePath, line: parseInt(match[2], 10), content, }); // Stop once we have enough matches if (matches.length >= maxResults) { break; } } } } return { matches, truncated: matches.length >= maxResults, }; } catch (error) { // grep returns exit code 1 when no matches found if (error instanceof Error && 'code' in error && error.code === 1) { return { matches: [], truncated: false }; } // Handle timeout if (error instanceof Error && 'killed' in error && error.killed) { throw new Error('Search timed out after 30 seconds. Try a more specific query or narrower path.'); } throw error; } } const executeSearchFileContents = async (args) => { // Validate query if (!args.query || !args.query.trim()) { return 'Error: Search query cannot be empty'; } const cwd = process.cwd(); const maxResults = Math.min(args.maxResults || DEFAULT_SEARCH_RESULTS, MAX_SEARCH_RESULTS); const caseSensitive = args.caseSensitive || false; // Validate and resolve search path if provided let searchPath; if (args.path) { if (!isValidFilePath(args.path)) { return `Error: Invalid path "${args.path}"`; } searchPath = path.resolve(cwd, args.path); if (!searchPath.startsWith(path.resolve(cwd))) { return `Error: Path escapes project directory: ${args.path}`; } } try { const { matches, truncated } = await searchFileContents(args.query, cwd, maxResults, caseSensitive, args.include, searchPath, args.wholeWord, args.contextLines); if (matches.length === 0) { return `No matches found for "${args.query}"`; } // Format results with clear file:line format let output = `Found ${matches.length} match${matches.length === 1 ? '' : 'es'}${truncated ? ` (showing first ${maxResults})` : ''}:\n\n`; for (const match of matches) { output += `${match.file}:${match.line}\n`; output += ` ${match.content}\n\n`; } return output.trim(); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Content search failed: ${errorMessage}`); } }; const searchFileContentsCoreTool = tool({ description: 'Search for text or code inside files. AUTO-ACCEPTED (no user approval needed). Use this INSTEAD OF bash grep/rg/ag/ack commands. Supports extended regex (e.g., "foo|bar", "func(tion)?"). Returns file:line with matching content. Use to find: function definitions, variable usage, import statements, TODO comments. Case-insensitive by default (use caseSensitive=true for exact matching). Use include to filter by file type (e.g., "*.ts") and path to scope to a directory (e.g., "src/components"). Use wholeWord=true for exact word boundaries. Use contextLines to see surrounding code.', inputSchema: jsonSchema({ type: 'object', properties: { query: { type: 'string', description: 'Text or code to search for inside files. Supports extended regex (e.g., "foo|bar" for alternation, "func(tion)?" for optional groups). Examples: "handleSubmit", "import React", "TODO|FIXME", "export (interface|type)" (find type exports), "useState\\(" (find React hooks). Case-insensitive by default.', }, maxResults: { type: 'number', description: 'Maximum number of matches to return (default: 30, max: 100)', }, caseSensitive: { type: 'boolean', description: 'Whether to perform case-sensitive search (default: false)', }, include: { type: 'string', description: 'Glob pattern to filter which files are searched (e.g., "*.ts", "*.{ts,tsx}", "*.spec.ts"). Only files matching this pattern will be searched.', }, path: { type: 'string', description: 'Directory to scope the search to (relative path, e.g., "src/components", "source/tools"). Only files within this directory will be searched.', }, wholeWord: { type: 'boolean', description: 'Match whole words only, preventing partial matches (default: false). Useful for finding exact variable/function names.', }, contextLines: { type: 'number', description: 'Number of lines to show before and after each match (default: 0, max: 10). Useful for understanding surrounding code context.', }, }, required: ['query'], }), // Low risk: read-only operation, never requires approval needsApproval: false, execute: async (args, _options) => { return await executeSearchFileContents(args); }, }); const SearchFileContentsFormatter = React.memo(({ args, result }) => { const themeContext = React.useContext(ThemeContext); if (!themeContext) { throw new Error('ThemeContext not found'); } const { colors } = themeContext; // Parse result to get match count let matchCount = 0; if (result && !result.startsWith('Error:')) { const firstLine = result.split('\n')[0]; const matchFound = firstLine.match(/Found (\d+)/); if (matchFound) { matchCount = parseInt(matchFound[1], 10); } } // Calculate tokens const tokens = result ? calculateTokens(result) : 0; const messageContent = (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: colors.tool, children: "\u2692 search_file_contents" }), _jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Query: " }), _jsx(Text, { color: colors.text, children: args.query })] }), args.include && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Include: " }), _jsx(Text, { color: colors.text, children: args.include })] })), args.path && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Path: " }), _jsx(Text, { color: colors.text, children: args.path })] })), args.caseSensitive && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Case sensitive: " }), _jsx(Text, { color: colors.text, children: "yes" })] })), args.wholeWord && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Whole word: " }), _jsx(Text, { color: colors.text, children: "yes" })] })), args.contextLines !== undefined && args.contextLines > 0 && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Context: " }), _jsxs(Text, { color: colors.text, children: ["\u00B1", args.contextLines, " lines"] })] })), _jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Matches: " }), _jsx(Text, { color: colors.text, children: matchCount })] }), tokens > 0 && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Tokens: " }), _jsxs(Text, { color: colors.text, children: ["~", tokens.toLocaleString()] })] }))] })); return _jsx(ToolMessage, { message: messageContent, hideBox: true }); }); const searchFileContentsFormatter = (args, result) => { if (result && result.startsWith('Error:')) { return _jsx(_Fragment, {}); } return _jsx(SearchFileContentsFormatter, { args: args, result: result }); }; export const searchFileContentsTool = { name: 'search_file_contents', tool: searchFileContentsCoreTool, formatter: searchFileContentsFormatter, readOnly: true, }; //# sourceMappingURL=search-file-contents.js.map