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

215 lines 10.2 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import { lstat, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { Box, Text } from 'ink'; import React from 'react'; import ToolMessage from '../components/tool-message.js'; import { ThemeContext } from '../hooks/useTheme.js'; import { jsonSchema, tool } from '../types/core.js'; import { loadGitignore } from '../utils/gitignore-loader.js'; import { isValidFilePath, resolveFilePath } from '../utils/path-validation.js'; import { calculateTokens } from '../utils/token-calculator.js'; const executeListDirectory = async (args) => { const dirPath = args.path || '.'; const recursive = args.recursive ?? false; const maxDepth = args.maxDepth ?? 3; const tree = args.tree ?? false; const showHiddenFiles = args.showHiddenFiles ?? false; // Validate path if (!isValidFilePath(dirPath)) { throw new Error(`⚒ Invalid path. Path must be relative and within the project directory.`); } const cwd = process.cwd(); const resolvedPath = resolveFilePath(dirPath, cwd); const ig = loadGitignore(cwd); try { const entries = []; const walkDirectory = async (currentPath, relativeTo, depth) => { if (depth > maxDepth) return; try { const items = await readdir(currentPath, { withFileTypes: true }); for (const item of items) { // Skip hidden files unless showHiddenFiles is true if (!showHiddenFiles && item.name.startsWith('.') && !dirPath.startsWith('.')) { continue; } // Check if this item should be ignored using gitignore patterns const itemPath = relativeTo ? join(relativeTo, item.name) : item.name; if (ig.ignores(itemPath)) { continue; } let type = 'file'; if (item.isSymbolicLink()) { type = 'symlink'; } else if (item.isDirectory()) { type = 'directory'; } const fullPath = join(currentPath, item.name); const relativePath = join(relativeTo, item.name); // Only get stats for files (to get size) let size; if (type === 'file') { try { const stats = await lstat(fullPath); size = stats.size; } catch { // Skip files we can't stat size = undefined; } } entries.push({ name: item.name, relativePath, type, size, }); // Recurse into directories if enabled if (recursive && item.isDirectory() && depth < maxDepth) { await walkDirectory(fullPath, relativePath, depth + 1); } } } catch (error) { if (error instanceof Error && 'code' in error && error.code === 'EACCES') { // Skip directories we can't read return; } throw error; } }; await walkDirectory(resolvedPath, '', 0); if (entries.length === 0) { return `Directory "${dirPath}" is empty`; } // Sort directories first, then files, alphabetically entries.sort((a, b) => { if (a.type === 'directory' && b.type !== 'directory') return -1; if (a.type !== 'directory' && b.type === 'directory') return 1; return a.relativePath.localeCompare(b.relativePath); }); // Format output let output = `Directory contents for "${dirPath}":\n\n`; if (tree) { // Tree format: flat paths, one per line for (const entry of entries) { output += `${entry.relativePath}\n`; } } else { // Standard format with icons for (const entry of entries) { const icon = entry.type === 'directory' ? '📁 ' : entry.type === 'symlink' ? '🔗 ' : '📄 '; const displayPath = recursive ? entry.relativePath : entry.name; const sizeStr = entry.size ? ` (${entry.size.toLocaleString()} bytes)` : ''; output += `${icon}${displayPath}${sizeStr}\n`; } } if (recursive && entries.length > 0) { output += `\n[Recursive: showing entries up to depth ${maxDepth}]`; } if (tree) { output += `\n[Tree format: flat paths]`; } return output; } catch (error) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { throw new Error(`Directory "${dirPath}" does not exist`); } const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to list directory: ${errorMessage}`); } }; const listDirectoryCoreTool = tool({ description: 'List directory contents with file sizes. AUTO-ACCEPTED (no user approval needed). Use this INSTEAD OF bash ls/ls -la/ls -R commands. Use recursive=true with maxDepth for nested exploration. Use tree=true for flat paths (easier to parse). Best for: exploring unknown directories, seeing file sizes, understanding project structure. For finding specific files by pattern, use find_files instead.', inputSchema: jsonSchema({ type: 'object', properties: { path: { type: 'string', description: 'Directory path to list (default: "." current directory). Examples: ".", "src", "source/tools"', }, recursive: { type: 'boolean', description: 'If true, recursively list subdirectories (default: false)', }, maxDepth: { type: 'number', description: 'Maximum recursion depth when recursive=true (default: 3, min: 1, max: 10)', }, tree: { type: 'boolean', description: 'If true, show flat paths output (one per line) instead of formatted tree. Great for LLM to see project structure.', }, showHiddenFiles: { type: 'boolean', description: 'If true, include hidden files and directories (default: false). Use with caution to avoid exposing sensitive files like .env.', }, }, required: [], }), // Low risk: read-only operation, never requires approval needsApproval: false, execute: async (args, _options) => { return await executeListDirectory(args); }, }); const ListDirectoryFormatter = React.memo(({ args, result, tokens }) => { const themeContext = React.useContext(ThemeContext); if (!themeContext) { throw new Error('ThemeContext not found'); } const { colors } = themeContext; // Parse result to extract entry count let entryCount = 0; if (result && !result.startsWith('Error:') && !result.includes('is empty')) { const lines = result.split('\n'); for (const line of lines) { // Count lines with emojis (standard format) or paths (tree format) if (line.match(/^[📁🔗📄]/) || (args.tree && line.trim() && !line.startsWith('[') && !line.startsWith('Directory'))) { entryCount++; } } } const messageContent = (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: colors.tool, children: "\u2692 list_directory" }), _jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Path: " }), _jsx(Text, { color: colors.text, children: args.path || '.' })] }), entryCount > 0 && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Entries: " }), _jsx(Text, { color: colors.text, children: entryCount })] })), args.recursive && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Recursive: " }), _jsxs(Text, { color: colors.text, children: ["yes (max depth: ", args.maxDepth ?? 3, ")"] })] })), args.tree && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Format: " }), _jsx(Text, { color: colors.text, children: "tree" })] })), args.showHiddenFiles && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Hidden files: " }), _jsx(Text, { color: colors.text, children: "shown" })] })), tokens !== undefined && 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 listDirectoryFormatter = (args, result) => { if (result && result.startsWith('Error:')) { return _jsx(_Fragment, {}); } // Calculate tokens from the result let tokens = 0; if (result) { tokens = calculateTokens(result); } return _jsx(ListDirectoryFormatter, { args: args, result: result, tokens: tokens }); }; export const listDirectoryTool = { name: 'list_directory', tool: listDirectoryCoreTool, formatter: listDirectoryFormatter, readOnly: true, }; //# sourceMappingURL=list-directory.js.map