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

333 lines 16.2 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import { constants } from 'node:fs'; import { access, lstat, readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { Box, Text } from 'ink'; import React from 'react'; import ToolMessage from '../components/tool-message.js'; import { FILE_READ_CHUNK_SIZE_LINES, FILE_READ_CHUNKING_HINT_THRESHOLD_LINES, FILE_READ_METADATA_THRESHOLD_LINES, MAX_LINE_LENGTH_CHARS, } from '../constants.js'; import { ThemeContext } from '../hooks/useTheme.js'; import { jsonSchema, tool } from '../types/core.js'; import { getCachedFileContent } from '../utils/file-cache.js'; import { getFileType } from '../utils/file-type-detector.js'; import { isValidFilePath, resolveFilePath } from '../utils/path-validation.js'; import { calculateTokens } from '../utils/token-calculator.js'; const executeReadFile = async (args) => { const absPath = resolve(args.path); try { // Handle explicit metadata_only request if (args.metadata_only) { const stats = await lstat(absPath); // Determine file type let type = 'file'; if (stats.isSymbolicLink()) { type = 'symlink'; } else if (stats.isDirectory()) { type = 'directory'; } const lastModified = new Date(stats.mtime).toISOString(); const size = stats.size; let output = `File Information for "${args.path}"\n`; output += `${'='.repeat(50)}\n\n`; output += `Type: ${type}\n`; output += `Size: ${size.toLocaleString()} bytes\n`; output += `Last Modified: ${lastModified}\n`; // For regular files, try to get additional info if (type === 'file') { output += `Readable: yes\n`; // Try to detect encoding and line count try { const cached = await getCachedFileContent(absPath); const lines = cached.lines; const content = cached.content; output += `Lines: ${lines.length.toLocaleString()}\n`; output += `Estimated Tokens: ~${calculateTokens(content).toLocaleString()}\n`; // Detect file type from extension const fileType = getFileType(absPath); output += `File Type: ${fileType}\n`; // Detect likely encoding (simple heuristic) let encoding = 'UTF-8'; try { // Try to read as UTF-8 await readFile(absPath, 'utf-8'); } catch { encoding = 'Binary/Unknown'; } output += `Encoding: ${encoding}\n`; } catch (error) { // If we can't read it, mark as not readable output += `Readable: no\n`; const errorMessage = error instanceof Error ? error.message : 'Unknown error'; output += `Note: Could not read file - ${errorMessage}\n`; } } else if (type === 'directory') { output += `Note: Use list_directory tool to see directory contents\n`; } else if (type === 'symlink') { output += `Note: This is a symbolic link. Size reflects link metadata, not target.\n`; } output += `\n[Use read_file to view file contents]\n`; return output; } const cached = await getCachedFileContent(absPath); const content = cached.content; // Check if file is empty (0 tokens) if (content.length === 0) { throw new Error(`File "${args.path}" exists but is empty (0 tokens)`); } const lines = cached.lines; const totalLines = lines.length; const fileSize = content.length; const estimatedTokens = calculateTokens(content); // Progressive disclosure: metadata first for files >300 lines // Small files can be read directly without ranges if (args.start_line === undefined && args.end_line === undefined && totalLines > FILE_READ_METADATA_THRESHOLD_LINES) { // Return metadata only for medium/large files // Detect file type from extension const fileType = getFileType(absPath); let output = `File: ${args.path}\n`; output += `Type: ${fileType}\n`; output += `Total lines: ${totalLines.toLocaleString()}\n`; output += `Size: ${fileSize.toLocaleString()} bytes\n`; output += `Estimated tokens: ~${estimatedTokens.toLocaleString()}\n\n`; if (totalLines <= FILE_READ_CHUNKING_HINT_THRESHOLD_LINES) { output += `[Medium file - To read specific sections, call read_file with start_line and end_line]\n`; output += `[To read entire file progressively, make multiple calls:]\n`; output += ` - read_file({path: "${args.path}", start_line: 1, end_line: ${FILE_READ_CHUNK_SIZE_LINES}})\n`; output += ` - read_file({path: "${args.path}", start_line: ${FILE_READ_CHUNK_SIZE_LINES + 1}, end_line: ${totalLines}})\n`; } else { output += `[Large file - Choose one approach:]\n`; output += `[1. Targeted read: Use search_files to find code, then read specific ranges]\n`; output += `[2. Progressive read: Read file in chunks (recommended chunk size: 200-300 lines)]\n`; output += ` Example chunks for ${totalLines} lines:\n`; const chunkSize = FILE_READ_CHUNK_SIZE_LINES; const numChunks = Math.ceil(totalLines / chunkSize); for (let i = 0; i < Math.min(numChunks, 3); i++) { const start = i * chunkSize + 1; const end = Math.min((i + 1) * chunkSize, totalLines); output += ` - read_file({path: "${args.path}", start_line: ${start}, end_line: ${end}})\n`; } if (numChunks > 3) { output += ` ... and ${numChunks - 3} more chunks to complete the file\n`; } } return output; } // Line ranges specified - read and return content const startLine = args.start_line ? Math.max(1, args.start_line) : 1; const endLine = args.end_line ? Math.min(totalLines, args.end_line) : totalLines; // Extract the lines to return const linesToReturn = lines.slice(startLine - 1, endLine); // Return content without line numbers for clean content-based editing return linesToReturn.join('\n'); } catch (error) { // Handle file not found and other filesystem errors if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { throw new Error(`File "${args.path}" does not exist`); } // Re-throw other errors (including our empty file error) throw error; } }; const readFileCoreTool = tool({ description: 'Read file contents with line numbers. AUTO-ACCEPTED (no user approval needed). Use this INSTEAD OF bash cat/head/tail/less commands. PROGRESSIVE DISCLOSURE: Files ≤300 lines return content directly. Files >300 lines return metadata first - then call again with start_line/end_line to read specific sections. Use metadata_only=true for file info (size, lines, type) without reading content. Always prefer this over bash for any file reading.', inputSchema: jsonSchema({ type: 'object', properties: { path: { type: 'string', description: 'The path to the file to read.', }, start_line: { type: 'number', description: 'Optional: Line number to start reading from (1-indexed). Required for files >300 lines. Use with end_line to read specific range.', }, end_line: { type: 'number', description: 'Optional: Line number to stop reading at (inclusive). Required for files >300 lines. Use with start_line to read specific range.', }, metadata_only: { type: 'boolean', description: 'Optional: If true, returns only file metadata (size, line count, type, encoding, modification time) without content. Useful for quickly checking file properties.', }, }, required: ['path'], }), // Low risk: read-only operation, never requires approval needsApproval: false, execute: async (args, _options) => { return await executeReadFile(args); }, }); const ReadFileFormatter = React.memo(({ args, fileInfo, }) => { const themeContext = React.useContext(ThemeContext); if (!themeContext) { throw new Error('ReadFileFormatter must be used within a ThemeProvider'); } const { colors } = themeContext; const path = args.path || args.file_path || 'unknown'; const messageContent = (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: colors.tool, children: "\u2692 read_file" }), _jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Path: " }), _jsx(Box, { marginLeft: 1, children: _jsxs(Text, { color: colors.text, children: [path, ' ', fileInfo.isMetadataOnly && (_jsx(Text, { color: colors.info, children: "(metadata only)" }))] }) })] }), fileInfo.isMetadataOnly ? (_jsx(_Fragment, { children: _jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Total lines: " }), _jsx(Text, { color: colors.text, children: fileInfo.totalLines.toLocaleString() })] }) })) : (_jsx(_Fragment, { children: _jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Lines: " }), _jsxs(Text, { color: colors.text, children: [args.start_line || 1, " - ", args.end_line || fileInfo.totalLines] })] }) })), !fileInfo.isMetadataOnly && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Tokens: " }), _jsxs(Text, { color: colors.text, children: ["~", fileInfo.tokens.toLocaleString()] })] }))] })); return _jsx(ToolMessage, { message: messageContent, hideBox: true }); }); const readFileFormatter = async (args, result) => { // If result is an error message, don't try to read the file if (result && result.startsWith('Error:')) { return _jsx(_Fragment, {}); } // Load file info to calculate actual read information let fileInfo = { totalLines: 0, readLines: 0, tokens: 0, isPartialRead: false, isMetadataOnly: false, }; try { const path = args.path || args.file_path; if (path && typeof path === 'string') { const absPath = resolve(path); const cached = await getCachedFileContent(absPath); const content = cached.content; const lines = cached.lines; const totalLines = lines.length; // Detect if this was a metadata-only response const isMetadataOnly = (result?.startsWith('File:') ?? false) && !args.start_line && !args.end_line && totalLines > FILE_READ_METADATA_THRESHOLD_LINES; // Calculate what was actually read const startLine = args.start_line || 1; const endLine = args.end_line || totalLines; const readLines = endLine - startLine + 1; const isPartialRead = startLine > 1 || endLine < totalLines; // Calculate tokens let tokens; if (isMetadataOnly) { // For metadata, show estimated tokens of the FULL FILE tokens = calculateTokens(content); } else { // For content reads, show tokens of what was actually returned tokens = result ? calculateTokens(result) : 0; } fileInfo = { totalLines, readLines, tokens, isPartialRead, isMetadataOnly, }; } } catch { // File doesn't exist or can't be read - keep default fileInfo } return _jsx(ReadFileFormatter, { args: args, fileInfo: fileInfo }); }; const readFileValidator = async (args) => { // Validate path boundary first to prevent directory traversal if (!isValidFilePath(args.path)) { return { valid: false, error: `⚒ Invalid file path: "${args.path}". Path must be relative and within the project directory.`, }; } // Verify the resolved path stays within project boundaries try { const cwd = process.cwd(); resolveFilePath(args.path, cwd); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return { valid: false, error: `⚒ Path validation failed: ${errorMessage}`, }; } const absPath = resolve(args.path); try { await access(absPath, constants.F_OK); // Validate line range parameters if (args.start_line !== undefined && args.start_line < 1) { return { valid: false, error: '⚒ start_line must be >= 1', }; } if (args.start_line !== undefined && args.end_line !== undefined && args.end_line < args.start_line) { return { valid: false, error: '⚒ end_line must be >= start_line', }; } // Check if end_line exceeds file length if (args.end_line !== undefined) { const cached = await getCachedFileContent(absPath); const totalLines = cached.lines.length; if (args.end_line > totalLines) { return { valid: false, error: `⚒ end_line (${args.end_line}) exceeds file length (${totalLines} lines)`, }; } } // Check for minified/binary content (very long lines) // Skip this check for metadata_only requests if (!args.metadata_only) { const cached = await getCachedFileContent(absPath); const startLine = args.start_line ? Math.max(1, args.start_line) : 1; const endLine = args.end_line ? Math.min(cached.lines.length, args.end_line) : cached.lines.length; for (let i = startLine - 1; i < endLine; i++) { const line = cached.lines[i]; if (line && line.length > MAX_LINE_LENGTH_CHARS) { return { valid: false, error: `⚒ File "${args.path}" contains minified or binary content (line ${i + 1} has ${line.length.toLocaleString()} characters). This file cannot be read as it would consume excessive tokens without providing useful information.`, }; } } } return { valid: true }; } catch (error) { if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { return { valid: false, error: `⚒ File "${args.path}" does not exist`, }; } const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return { valid: false, error: `⚒ Cannot access file "${args.path}": ${errorMessage}`, }; } }; export const readFileTool = { name: 'read_file', tool: readFileCoreTool, formatter: readFileFormatter, validator: readFileValidator, readOnly: true, }; //# sourceMappingURL=read-file.js.map