@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
JavaScript
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