@michaelnkomo/cli
Version:
BroCode CLI - AI coding assistant with @ file tagging and multi-language support
248 lines (247 loc) • 8.72 kB
JavaScript
/**
* File Tagging System for Context Inclusion
* Handles @ symbol detection and file/directory autocomplete
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import { glob } from 'glob';
export class FileTaggingSystem {
options;
fileCache = new Map();
contentCache = new Map();
constructor(options = {}) {
this.options = {
workingDirectory: process.cwd(),
maxFileSize: 1024 * 1024, // 1MB
excludePatterns: [
'node_modules/**',
'.git/**',
'dist/**',
'build/**',
'*.log',
'.env*',
'*.min.js',
'*.map'
],
includeHidden: false,
...options
};
}
/**
* Discover all files and directories in the working directory
*/
async discoverFiles() {
const cacheKey = this.options.workingDirectory;
if (this.fileCache.has(cacheKey)) {
return this.fileCache.get(cacheKey);
}
try {
const files = [];
// Use glob to find all files, respecting gitignore and exclude patterns
const globPattern = this.options.includeHidden ? '**/*' : '**/[!.]*';
const foundPaths = await glob(globPattern, {
cwd: this.options.workingDirectory,
ignore: this.options.excludePatterns,
dot: this.options.includeHidden,
nodir: false, // Include directories
});
for (const relativePath of foundPaths) {
const fullPath = path.join(this.options.workingDirectory, relativePath);
try {
const stats = await fs.stat(fullPath);
// Skip files that are too large
if (stats.isFile() && stats.size > this.options.maxFileSize) {
continue;
}
const fileItem = {
name: path.basename(relativePath),
path: relativePath,
type: stats.isDirectory() ? 'directory' : 'file',
size: stats.isFile() ? stats.size : undefined,
extension: stats.isFile() ? path.extname(relativePath).slice(1) : undefined
};
files.push(fileItem);
}
catch (error) {
// Skip files we can't access
continue;
}
}
// Sort files: directories first, then files, alphabetically
files.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
this.fileCache.set(cacheKey, files);
return files;
}
catch (error) {
console.error('Error discovering files:', error);
return [];
}
}
/**
* Filter files based on user input
*/
async filterFiles(query) {
const allFiles = await this.discoverFiles();
if (!query || query.length === 0) {
return allFiles.slice(0, 20); // Return first 20 items
}
const lowerQuery = query.toLowerCase();
return allFiles
.filter(file => file.name.toLowerCase().includes(lowerQuery) ||
file.path.toLowerCase().includes(lowerQuery))
.slice(0, 20); // Limit results for performance
}
/**
* Read file content for context inclusion
*/
async readFileContent(filePath) {
const fullPath = path.resolve(this.options.workingDirectory, filePath);
// Check cache first
if (this.contentCache.has(fullPath)) {
return this.contentCache.get(fullPath);
}
try {
const stats = await fs.stat(fullPath);
if (stats.isDirectory()) {
// For directories, return a listing
const dirContents = await this.getDirectoryListing(fullPath);
this.contentCache.set(fullPath, dirContents);
return dirContents;
}
if (!stats.isFile()) {
return null;
}
// Check file size
if (stats.size > this.options.maxFileSize) {
return `File too large (${this.formatFileSize(stats.size)}). Showing first 1000 characters:\n\n${(await fs.readFile(fullPath, 'utf-8')).slice(0, 1000)}...`;
}
// Check if it's a binary file
if (this.isBinaryFile(filePath)) {
return `Binary file: ${filePath} (${this.formatFileSize(stats.size)})`;
}
const content = await fs.readFile(fullPath, 'utf-8');
this.contentCache.set(fullPath, content);
return content;
}
catch (error) {
return `Error reading file: ${error.message}`;
}
}
/**
* Get directory listing as a formatted string
*/
async getDirectoryListing(dirPath) {
try {
const items = await fs.readdir(dirPath, { withFileTypes: true });
const listing = items
.map(item => {
const type = item.isDirectory() ? '📁' : '📄';
return `${type} ${item.name}`;
})
.join('\n');
return `Directory listing for: ${path.relative(this.options.workingDirectory, dirPath)}\n\n${listing}`;
}
catch (error) {
return `Error reading directory: ${error.message}`;
}
}
/**
* Check if a file is binary based on extension
*/
isBinaryFile(filePath) {
const binaryExtensions = [
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.ico', '.svg',
'.mp3', '.mp4', '.avi', '.mov', '.wmv',
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.zip', '.tar', '.gz', '.rar', '.7z',
'.exe', '.dll', '.so', '.dylib',
'.woff', '.woff2', '.ttf', '.eot'
];
const ext = path.extname(filePath).toLowerCase();
return binaryExtensions.includes(ext);
}
/**
* Format file size in human-readable format
*/
formatFileSize(bytes) {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
/**
* Parse @ tags from user input
*/
parseFileTags(input) {
const fileTagRegex = /@([\w\-./\\]+(?:\.[a-zA-Z0-9]+)?)/g;
const fileTags = [];
let match;
while ((match = fileTagRegex.exec(input)) !== null) {
fileTags.push(match[1]);
}
// Remove file tags from input
const cleanInput = input.replace(fileTagRegex, '').trim();
return { cleanInput, fileTags };
}
/**
* Build context from tagged files
*/
async buildFileContext(fileTags) {
if (fileTags.length === 0) {
return '';
}
const contextParts = [];
contextParts.push('📁 **FILE CONTEXT:**\n');
for (const fileTag of fileTags) {
const content = await this.readFileContent(fileTag);
if (content !== null) {
contextParts.push(`\n## 📄 ${fileTag}\n\`\`\`\n${content}\n\`\`\`\n`);
}
else {
contextParts.push(`\n## ❌ ${fileTag}\nFile not found or could not be read.\n`);
}
}
return contextParts.join('');
}
/**
* Clear caches
*/
clearCache() {
this.fileCache.clear();
this.contentCache.clear();
}
/**
* Update working directory and clear cache
*/
setWorkingDirectory(directory) {
this.options.workingDirectory = path.resolve(directory);
this.clearCache();
}
}
/**
* Global file tagging system instance
*/
export const fileTagging = new FileTaggingSystem();
/**
* Helper function to detect @ symbols in input
*/
export function detectFileTagging(input) {
return input.includes('@') && /@[\w\-./\\]*/.test(input);
}
/**
* Helper function to get current @ query being typed
*/
export function getCurrentTagQuery(input, cursorPosition) {
const beforeCursor = input.slice(0, cursorPosition);
const match = beforeCursor.match(/@([\w\-./\\]*)$/);
return match ? match[1] : null;
}