@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
211 lines • 10.3 kB
JavaScript
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);
/**
* Search file contents using grep
*/
async function searchFileContents(query, cwd, maxResults, caseSensitive, include, searchPath) {
try {
const ig = loadGitignore(cwd);
// Build grep arguments array to prevent command injection
const grepArgs = [
'-rn', // recursive with line numbers
'-E', // extended regex
];
// Add case sensitivity flag
if (!caseSensitive) {
grepArgs.push('-i');
}
// 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
const { stdout } = await execFileAsync('grep', grepArgs, {
cwd,
maxBuffer: BUFFER_FIND_FILES_BYTES * BUFFER_GREP_MULTIPLIER,
});
const matches = [];
const lines = stdout.trim().split('\n').filter(Boolean);
const cwdPrefix = path.resolve(cwd) + path.sep;
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: lines.length >= maxResults || 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 };
}
throw error;
}
}
const executeSearchFileContents = async (args) => {
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);
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").',
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.',
},
},
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" })] })), _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,
};
//# sourceMappingURL=search-file-contents.js.map