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

180 lines 8.46 kB
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