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

262 lines 12.1 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { constants, existsSync } from 'node:fs'; import { access, readFile, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { highlight } from 'cli-highlight'; import { Box, Text } from 'ink'; import React from 'react'; import stripAnsi from 'strip-ansi'; import ToolMessage from '../../components/tool-message.js'; import { isNanocoderToolAlwaysAllowed } from '../../config/nanocoder-tools-config.js'; import { DEFAULT_TERMINAL_COLUMNS } from '../../constants.js'; import { getCurrentMode } from '../../context/mode-context.js'; import { ThemeContext } from '../../hooks/useTheme.js'; import { jsonSchema, tool } from '../../types/core.js'; import { getCachedFileContent, invalidateCache } from '../../utils/file-cache.js'; import { normalizeIndentation } from '../../utils/indentation-normalizer.js'; import { isValidFilePath, resolveFilePath } from '../../utils/path-validation.js'; import { getLanguageFromExtension } from '../../utils/programming-language-helper.js'; import { calculateTokens } from '../../utils/token-calculator.js'; import { ensureString } from '../../utils/type-helpers.js'; import { closeDiffInVSCode, isVSCodeConnected, sendFileChangeToVSCode, } from '../../vscode/index.js'; const executeWriteFile = async (args) => { const absPath = resolve(args.path); const fileExists = existsSync(absPath); // Type guard: ensure content is string for write operation // Storage is safe (fs.writeFile ensures string-only), but we need to convert for safety const contentStr = ensureString(args.content); await writeFile(absPath, contentStr, 'utf-8'); // Invalidate cache after write invalidateCache(absPath); // Read back to verify and show actual content const actualContent = await readFile(absPath, 'utf-8'); const lines = actualContent.split('\n'); const lineCount = lines.length; const charCount = actualContent.length; const estimatedTokens = calculateTokens(actualContent); // Generate full file contents to show the model the current file state let fileContext = '\n\nFile contents after write:\n'; for (let i = 0; i < lines.length; i++) { const lineNumStr = String(i + 1).padStart(4, ' '); const line = lines[i] || ''; fileContext += `${lineNumStr}: ${line}\n`; } const action = fileExists ? 'overwritten' : 'written'; return `File ${action} successfully (${lineCount} lines, ${charCount} characters, ~${estimatedTokens} tokens).${fileContext}`; }; const writeFileCoreTool = tool({ description: 'Write content to a file (creates new file or overwrites existing file). Use this for complete file rewrites, generated code, or when most of the file needs to change. For small targeted edits, use string_replace instead.', inputSchema: jsonSchema({ // Note: change to unknown type: 'object', properties: { path: { type: 'string', description: 'The path to the file to write.', }, content: { type: 'string', // Guide LLM to send strings description: 'The complete content to write to the file. Objects/arrays will be converted to JSON strings for storage.', }, }, required: ['path', 'content'], }), // Medium risk: file write operation, requires approval except in auto-accept mode or if configured in nanocoderTools.alwaysAllow needsApproval: () => { // Check if this tool is configured to always be allowed if (isNanocoderToolAlwaysAllowed('write_file')) { return false; } const mode = getCurrentMode(); return mode !== 'auto-accept' && mode !== 'scheduler'; }, execute: async (args, _options) => { return await executeWriteFile(args); }, }); // Truncate a string with ANSI codes to fit terminal width (visual chars) const truncateAnsi = (str, maxWidth) => { const plainText = stripAnsi(str); if (plainText.length <= maxWidth) return str; let visibleCount = 0; const ansiRegex = /\x1b\[[0-9;]*m/g; let result = ''; let lastIndex = 0; let match; while ((match = ansiRegex.exec(str)) !== null) { const textBefore = str.slice(lastIndex, match.index); for (const char of textBefore) { if (visibleCount >= maxWidth - 1) break; result += char; visibleCount++; } if (visibleCount >= maxWidth - 1) break; result += match[0]; lastIndex = match.index + match[0].length; } if (visibleCount < maxWidth - 1) { const remaining = str.slice(lastIndex); for (const char of remaining) { if (visibleCount >= maxWidth - 1) break; result += char; visibleCount++; } } return result + '\x1b[0m…'; }; // Create a component that will re-render when theme changes const WriteFileFormatter = React.memo(({ args }) => { const themeContext = React.useContext(ThemeContext); if (!themeContext) { throw new Error('ThemeContext is required'); } const { colors } = themeContext; const path = args.path || args.file_path || 'unknown'; const newContent = ensureString(args.content); const lineCount = newContent.split('\n').length; const charCount = newContent.length; // Estimate tokens (rough approximation: ~4 characters per token) const estimatedTokens = calculateTokens(newContent); // Normalize indentation for display const lines = newContent.split('\n'); const normalizedLines = normalizeIndentation(lines); // Calculate available width for line content (terminal width - line number prefix - padding) const terminalWidth = process.stdout.columns || DEFAULT_TERMINAL_COLUMNS; const lineNumPrefixWidth = 6; // "1234 " = 5 chars + 1 for safety const availableWidth = Math.max(terminalWidth - lineNumPrefixWidth - 2, 20); const messageContent = (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: colors.tool, children: "\u2692 write_file" }), _jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Path: " }), _jsx(Text, { color: colors.text, children: path })] }), _jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Size: " }), _jsxs(Text, { color: colors.text, children: [lineCount, " lines, ", charCount, " characters (~", estimatedTokens, " tokens)"] })] }), newContent.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: colors.text, children: "File content:" }), normalizedLines.map((line, i) => { const lineNumStr = String(i + 1).padStart(4, ' '); const ext = path.split('.').pop()?.toLowerCase() ?? ''; const language = getLanguageFromExtension(ext); try { const highlighted = highlight(line, { language, theme: 'default' }); const truncated = truncateAnsi(highlighted, availableWidth); return (_jsxs(Box, { children: [_jsxs(Text, { color: colors.secondary, children: [lineNumStr, " "] }), _jsx(Text, { wrap: "truncate-end", children: truncated })] }, i)); } catch { const truncated = line.length > availableWidth ? line.slice(0, availableWidth - 1) + '…' : line; return (_jsxs(Box, { children: [_jsxs(Text, { color: colors.secondary, children: [lineNumStr, " "] }), _jsx(Text, { wrap: "truncate-end", children: truncated })] }, i)); } })] })) : (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.secondary, children: "File will be empty" }) }))] })); return _jsx(ToolMessage, { message: messageContent, hideBox: true }); }); // Track VS Code change IDs for cleanup const vscodeChangeIds = new Map(); const writeFileFormatter = async (args, result) => { const path = args.path || args.file_path || ''; const absPath = resolve(path); // Send diff to VS Code during preview phase (before execution) if (result === undefined && isVSCodeConnected()) { const content = args.content || ''; // Get original content if file exists (use cache if available) let originalContent = ''; if (existsSync(absPath)) { try { const cached = await getCachedFileContent(absPath); originalContent = cached.content; } catch { // File might exist but not be readable } } const changeId = sendFileChangeToVSCode(absPath, originalContent, content, 'write_file', { path, content, }); if (changeId) { vscodeChangeIds.set(absPath, changeId); } } else if (result !== undefined && isVSCodeConnected()) { // Tool was executed (confirmed or rejected), close the diff const changeId = vscodeChangeIds.get(absPath); if (changeId) { closeDiffInVSCode(changeId); vscodeChangeIds.delete(absPath); } } return _jsx(WriteFileFormatter, { args: args }); }; const writeFileValidator = 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); // Check if parent directory exists const parentDir = dirname(absPath); try { await access(parentDir, constants.F_OK); } catch (error) { if (error && typeof error === 'object' && 'code' in error) { if (error.code === 'ENOENT') { return { valid: false, error: `⚒ Parent directory does not exist: "${parentDir}"`, }; } } const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return { valid: false, error: `⚒ Cannot access parent directory "${parentDir}": ${errorMessage}`, }; } // Check if content is valid (not null/undefined) if (args.content === null || args.content === undefined) { return { valid: false, error: `⚒ Invalid content: content cannot be null or undefined.`, }; } // Allow empty strings (intentional file creation) // Only reject null/undefined, which we already checked above // Check for invalid path characters or attempts to write to system directories const invalidPatterns = [ /^\/etc\//i, /^\/sys\//i, /^\/proc\//i, /^\/dev\//i, /^\/boot\//i, /^C:\\Windows\\/i, /^C:\\Program Files\\/i, ]; for (const pattern of invalidPatterns) { if (pattern.test(absPath)) { return { valid: false, error: `⚒ Cannot write files to system directory: "${args.path}"`, }; } } return { valid: true }; }; export const writeFileTool = { name: 'write_file', tool: writeFileCoreTool, formatter: writeFileFormatter, validator: writeFileValidator, }; //# sourceMappingURL=write-file.js.map