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

303 lines 11 kB
import { existsSync, readdirSync, statSync } from 'fs'; import { basename, join } from 'path'; import { getConfigPath } from '../config/paths.js'; import { parseCommandFile } from '../custom-commands/parser.js'; import { logError } from '../utils/message-queue.js'; const RESOURCES_DIR = 'resources'; const RELEVANCE_THRESHOLD = 5; const MAX_COMMANDS_IN_CONTEXT = 3; /** * Validate that a directory entry doesn't contain path traversal patterns. */ function isSafeEntry(entry) { return (entry !== '..' && entry !== '.' && !entry.includes('/') && !entry.includes('\\')); } export class CustomCommandLoader { commands = new Map(); aliases = new Map(); // alias -> command name projectRoot; projectCommandsDir; personalCommandsDir; deprecationWarned = false; constructor(projectRoot = process.cwd()) { this.projectRoot = projectRoot; // nosemgrep this.projectCommandsDir = join(projectRoot, '.nanocoder', 'commands'); // nosemgrep this.personalCommandsDir = join(getConfigPath(), 'commands'); } /** * Load all custom commands from both project and personal directories */ loadCommands() { this.commands.clear(); this.aliases.clear(); // Load personal commands first (lower priority) if (existsSync(this.personalCommandsDir)) { this.scanDirectory(this.personalCommandsDir, undefined, 'personal'); } // Load project commands (higher priority, overrides personal) if (existsSync(this.projectCommandsDir)) { this.scanDirectory(this.projectCommandsDir, undefined, 'project'); } // Emit deprecation warning for old skills directories this.checkDeprecatedSkillsDirs(); } /** * Check for deprecated .nanocoder/skills directories and warn */ checkDeprecatedSkillsDirs() { if (this.deprecationWarned) return; const projectSkillsDir = join(this.projectRoot, '.nanocoder', 'skills'); const personalSkillsDir = join(getConfigPath(), 'skills'); let warned = false; if (existsSync(projectSkillsDir)) { logError('Skills have been merged into commands. Move your SKILL.md files from .nanocoder/skills/ to .nanocoder/commands/ and rename them.'); warned = true; } if (existsSync(personalSkillsDir)) { logError('Skills have been merged into commands. Move your SKILL.md files from ~/.config/nanocoder/skills/ to ~/.config/nanocoder/commands/ and rename them.'); warned = true; } if (warned) this.deprecationWarned = true; } /** * Recursively scan directory for .md files, supporting directory-as-command */ scanDirectory(dir, namespace, source) { const entries = readdirSync(dir); for (const entry of entries) { if (!isSafeEntry(entry)) continue; const fullPath = join(dir, entry); // nosemgrep const stat = statSync(fullPath); if (stat.isDirectory()) { // Check if this is a directory-as-command pattern: // directory contains <dirname>.md + optional resources/ const commandFile = join(fullPath, `${entry}.md`); // nosemgrep if (existsSync(commandFile)) { this.loadCommand(commandFile, namespace, source, fullPath); } else { // Regular subdirectory becomes a namespace const subNamespace = namespace ? `${namespace}:${entry}` : entry; this.scanDirectory(fullPath, subNamespace, source); } } else if (entry.endsWith('.md')) { // Parse and register command this.loadCommand(fullPath, namespace, source); } } } /** * Load a single command file. * If commandDir is provided, also load resources from its resources/ subdirectory. */ loadCommand(filePath, namespace, source, commandDir) { try { const parsed = parseCommandFile(filePath); const commandName = basename(filePath, '.md'); const fullName = namespace ? `${namespace}:${commandName}` : commandName; // Load resources if this is a directory-based command let loadedResources; if (commandDir) { loadedResources = this.loadResources(commandDir); } // Get file modification time let lastModified; try { const st = statSync(filePath); lastModified = st.mtime; } catch { // ignore } const command = { name: commandName, path: filePath, namespace, fullName, metadata: parsed.metadata, content: parsed.content, source, lastModified, loadedResources: loadedResources && loadedResources.length > 0 ? loadedResources : undefined, }; // Register main command (project commands override personal with same name) this.commands.set(fullName, command); // Register aliases if (parsed.metadata.aliases) { for (const alias of parsed.metadata.aliases) { const fullAlias = namespace ? `${namespace}:${alias}` : alias; this.aliases.set(fullAlias, fullName); } } } catch (error) { logError(`Failed to load custom command from ${filePath}: ${String(error)}`); } } /** * Load resources from a command's resources/ subdirectory */ loadResources(commandDir) { const resourcesDir = join(commandDir, RESOURCES_DIR); // nosemgrep if (!existsSync(resourcesDir)) { return []; } const entries = readdirSync(resourcesDir); const resources = []; for (const entry of entries) { if (!isSafeEntry(entry)) continue; const resourcePath = join(resourcesDir, entry); // nosemgrep let st; try { st = statSync(resourcePath); } catch { continue; } if (!st.isFile()) continue; const ext = entry.toLowerCase().slice(entry.lastIndexOf('.')); let type = 'document'; if (['.py', '.js', '.sh', '.bat', '.ts'].includes(ext)) { type = 'script'; } else if (['.txt', '.md'].includes(ext)) { type = entry.endsWith('.template') ? 'template' : 'document'; } else if (['.json', '.yaml', '.yml', '.toml'].includes(ext)) { type = 'config'; } const executable = Boolean(type === 'script' && st.mode & 0o111); resources.push({ name: entry, path: resourcePath, type, executable: executable || undefined, }); } return resources; } /** * Get a command by name (checking aliases too) */ getCommand(name) { // Check direct command name const command = this.commands.get(name); if (command) return command; // Check aliases const aliasTarget = this.aliases.get(name); if (aliasTarget) { return this.commands.get(aliasTarget); } return undefined; } /** * Get all available commands */ getAllCommands() { return Array.from(this.commands.values()); } /** * Get only commands that participate in auto-injection * (those with triggers or tags defined) */ getAutoInjectableCommands() { return this.getAllCommands().filter(cmd => cmd.metadata.triggers?.length || cmd.metadata.tags?.length); } /** * Get command suggestions for autocomplete */ getSuggestions(prefix) { const suggestions = []; const lowerPrefix = prefix.toLowerCase(); // Add matching command names for (const [name, _command] of this.commands.entries()) { if (name.toLowerCase().startsWith(lowerPrefix)) { suggestions.push(name); } } // Add matching aliases for (const [alias, _target] of this.aliases.entries()) { if (alias.toLowerCase().startsWith(lowerPrefix) && !suggestions.includes(alias)) { suggestions.push(alias); } } return suggestions.sort(); } /** * Find relevant commands for auto-injection based on user request */ findRelevantCommands(request, availableTools) { const requestLower = request.toLowerCase(); const scored = []; for (const command of this.getAutoInjectableCommands()) { const score = this.calculateRelevanceScore(command, requestLower, availableTools); if (score >= RELEVANCE_THRESHOLD) { scored.push({ command, score }); } } scored.sort((a, b) => b.score - a.score); return scored.slice(0, MAX_COMMANDS_IN_CONTEXT).map(s => s.command); } /** * Calculate relevance score for a command against a user request */ calculateRelevanceScore(command, requestLower, availableTools) { let score = 0; const meta = command.metadata; if (meta.description?.toLowerCase().includes(requestLower)) { score += 10; } if (meta.category?.toLowerCase().includes(requestLower)) { score += 5; } if (meta.triggers?.length) { for (const trigger of meta.triggers) { if (requestLower.includes(trigger.toLowerCase())) { score += 15; } } } if (meta.tags?.length) { for (const tag of meta.tags) { if (requestLower.includes(tag.toLowerCase())) { score += 5; } } } return score; } /** * Check if commands directory exists */ hasCustomCommands() { return (existsSync(this.projectCommandsDir) || existsSync(this.personalCommandsDir)); } /** * Get the project commands directory path */ getCommandsDirectory() { return this.projectCommandsDir; } /** * Get the personal commands directory path */ getPersonalCommandsDirectory() { return this.personalCommandsDir; } } //# sourceMappingURL=loader.js.map