@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
180 lines • 8.46 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { execFile } from 'node:child_process';
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, DEFAULT_FIND_FILES_RESULTS, MAX_FIND_FILES_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 { calculateTokens } from '../utils/token-calculator.js';
const execFileAsync = promisify(execFile);
/**
* Find files matching a glob pattern using find command
*/
async function findFilesByPattern(pattern, cwd, maxResults) {
try {
const ig = loadGitignore(cwd);
// Build find arguments array to prevent command injection
const findArgs = ['.'];
if (pattern.includes('{') && pattern.includes('}')) {
// Handle brace expansion like *.{ts,tsx}
const braceMatch = pattern.match(/\{([^}]+)\}/);
if (braceMatch) {
const extensions = braceMatch[1].split(',');
// Build: ( -name "*.ext1" -o -name "*.ext2" )
findArgs.push('(');
for (let i = 0; i < extensions.length; i++) {
if (i > 0) {
findArgs.push('-o');
}
findArgs.push('-name', `*.${extensions[i].trim()}`);
}
findArgs.push(')');
}
}
else if (pattern.startsWith('**/')) {
// Pattern like **/*.ts - search everywhere
const namePattern = pattern.replace('**/', '');
findArgs.push('-name', namePattern);
}
else if (pattern.includes('/**')) {
// Pattern like scripts/** or scripts/**/*.ts - search within a directory
const parts = pattern.split('/**');
const pathPrefix = `./${parts[0]}`;
const namePattern = parts[1] ? parts[1].replace(/^\//, '') : '*';
// Replace the starting '.' with pathPrefix
findArgs[0] = pathPrefix;
if (namePattern !== '*' && namePattern !== '') {
findArgs.push('-name', namePattern);
}
}
else if (pattern.includes('/') && pattern.includes('*')) {
// Pattern like source/tools/*.ts - has both path and wildcard
// Split into directory path and filename pattern
const lastSlashIndex = pattern.lastIndexOf('/');
const dirPath = pattern.substring(0, lastSlashIndex);
const filePattern = pattern.substring(lastSlashIndex + 1);
// Start search from the directory
findArgs[0] = `./${dirPath}`;
// Only descend one level (maxdepth 1) to match the specific directory
findArgs.push('-maxdepth', '1', '-name', filePattern);
}
else if (pattern.includes('*')) {
// Simple pattern like *.ts
findArgs.push('-name', pattern);
}
else {
// Exact path or directory name
findArgs.push('-name', pattern);
}
// Add exclusions - dynamically generated from DEFAULT_IGNORE_DIRS
const exclusions = DEFAULT_IGNORE_DIRS.map(dir => `*/${dir}/*`);
for (const exclusion of exclusions) {
findArgs.push('-not', '-path', exclusion);
}
// Execute find command with array-based arguments
const { stdout } = await execFileAsync('find', findArgs, {
cwd,
maxBuffer: BUFFER_FIND_FILES_BYTES,
});
const allPaths = stdout
.trim()
.split('\n')
.filter(Boolean)
.map(line => line.replace(/^\.\//, ''))
.filter(path => path && path !== '.');
// Filter using gitignore and limit results
const paths = [];
for (const path of allPaths) {
if (!ig.ignores(path)) {
paths.push(path);
if (paths.length >= maxResults) {
break;
}
}
}
return {
files: paths,
truncated: allPaths.length >= maxResults || paths.length >= maxResults,
};
}
catch (error) {
if (error instanceof Error && 'code' in error && error.code === 1) {
return { files: [], truncated: false };
}
throw error;
}
}
const executeFindFiles = async (args) => {
const cwd = process.cwd();
const maxResults = Math.min(args.maxResults || DEFAULT_FIND_FILES_RESULTS, MAX_FIND_FILES_RESULTS);
try {
const { files, truncated } = await findFilesByPattern(args.pattern, cwd, maxResults);
if (files.length === 0) {
return `No files or directories found matching pattern "${args.pattern}"`;
}
let output = `Found ${files.length} match${files.length === 1 ? '' : 'es'}${truncated ? ` (showing first ${maxResults})` : ''}:\n\n`;
output += files.join('\n');
return output;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`File search failed: ${errorMessage}`);
}
};
const findFilesCoreTool = tool({
description: 'Find files and directories by path pattern. AUTO-ACCEPTED (no user approval needed). Use this INSTEAD OF bash find/locate/ls commands for file discovery. Examples: "*.tsx" (all .tsx files), "src/**/*.ts" (recursive in src/), "*.{ts,tsx}" (multiple extensions), "package.json" (exact file), "*config*" (files containing "config"), "source/tools/*.ts" (specific directory). Excludes node_modules, .git, dist, build automatically.',
inputSchema: jsonSchema({
type: 'object',
properties: {
pattern: {
type: 'string',
description: 'Glob pattern to match file and directory paths. Examples: "*.tsx" (all .tsx files), "src/**/*.ts" (recursive in src/), "*.{ts,tsx}" (multiple extensions), "package.json" (exact file), "*config*" (files containing "config"), "source/tools/*.ts" (specific directory)',
},
maxResults: {
type: 'number',
description: 'Maximum number of results to return (default: 50, max: 100)',
},
},
required: ['pattern'],
}),
// Low risk: read-only operation, never requires approval
needsApproval: false,
execute: async (args, _options) => {
return await executeFindFiles(args);
},
});
const FindFilesFormatter = React.memo(({ args, result }) => {
const themeContext = React.useContext(ThemeContext);
if (!themeContext) {
throw new Error('ThemeContext not found');
}
const { colors } = themeContext;
// Parse result to get file count
let fileCount = 0;
if (result && !result.startsWith('Error:')) {
const firstLine = result.split('\n')[0];
const matchFound = firstLine.match(/Found (\d+)/);
if (matchFound) {
fileCount = 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 find_files" }), _jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Pattern: " }), _jsx(Text, { color: colors.text, children: args.pattern })] }), _jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Results: " }), _jsx(Text, { color: colors.text, children: fileCount })] }), 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 findFilesFormatter = (args, result) => {
if (result && result.startsWith('Error:')) {
return _jsx(_Fragment, {});
}
return _jsx(FindFilesFormatter, { args: args, result: result });
};
export const findFilesTool = {
name: 'find_files',
tool: findFilesCoreTool,
formatter: findFilesFormatter,
};
//# sourceMappingURL=find-files.js.map