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

239 lines 7.96 kB
import { readFileSync } from 'fs'; import { logError } from '../utils/message-queue.js'; /** * Set of frontmatter keys that are always parsed as arrays */ const ARRAY_KEYS = new Set([ 'aliases', 'parameters', 'tags', 'triggers', 'examples', 'references', 'dependencies', ]); /** * Set of frontmatter keys that are parsed as numbers */ const NUMBER_KEYS = new Set(['estimated-tokens']); /** * Parse a value that may be a JSON-style array or a single item. * Returns an array of strings. */ function parseArrayValue(value) { const trimmed = value.trim(); if (trimmed.startsWith('[') && trimmed.endsWith(']')) { const content = trimmed.slice(1, -1); return content .split(',') .map(s => s.trim().replace(/^["']|["']$/g, '')) .filter(s => s.length > 0); } const cleaned = trimmed.replace(/^["']|["']$/g, ''); return cleaned ? [cleaned] : []; } /** * Enhanced YAML frontmatter parser with support for multi-line strings, * arrays, and all command/skill metadata fields. */ function parseEnhancedFrontmatter(frontmatter) { const raw = {}; const lines = frontmatter.split('\n'); let currentKey = null; let currentValue = []; let isMultiline = false; let indentLevel = 0; const storeValue = (key, value) => { const trimmedValue = value.trim(); if (ARRAY_KEYS.has(key)) { raw[key] = parseArrayValue(trimmedValue); } else if (NUMBER_KEYS.has(key)) { const num = Number(trimmedValue); if (!Number.isNaN(num)) { raw[key] = num; } } else { raw[key] = trimmedValue.replace(/^["']|["']$/g, ''); } }; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!line) continue; // Skip if line is undefined const trimmedLine = line.trim(); // Skip empty lines and comments if (!trimmedLine || trimmedLine.startsWith('#')) { continue; } // Check for YAML dash syntax (array items) if (trimmedLine.startsWith('- ') && currentKey && ARRAY_KEYS.has(currentKey)) { const arrayItem = trimmedLine .slice(2) .trim() .replace(/^["']|["']$/g, ''); const arr = raw[currentKey] ?? []; arr.push(arrayItem); raw[currentKey] = arr; continue; } // Check for multi-line string indicators if (trimmedLine.endsWith('|') || trimmedLine.endsWith('>')) { const colonIndex = line.indexOf(':'); if (colonIndex !== -1) { currentKey = line.slice(0, colonIndex).trim(); isMultiline = true; currentValue = []; indentLevel = 0; continue; } } // Handle multi-line content if (isMultiline && currentKey) { const lineIndent = line.length - line.trimStart().length; if (trimmedLine && indentLevel === 0) { indentLevel = lineIndent; } if (trimmedLine && lineIndent >= indentLevel) { currentValue.push(line.slice(indentLevel)); } else if (trimmedLine && lineIndent < indentLevel) { // End of multi-line block const multilineContent = currentValue.join('\n').trim(); storeValue(currentKey, multilineContent); isMultiline = false; currentKey = null; currentValue = []; indentLevel = 0; // Re-process this line as a regular key-value pair i--; // Reprocess current line continue; } else if (!trimmedLine) { currentValue.push(''); } // If this is the last line, process the accumulated multi-line value if (i === lines.length - 1 && currentValue.length > 0) { const multilineContent = currentValue.join('\n').trim(); storeValue(currentKey, multilineContent); } continue; } // Handle regular key-value pairs — find colon outside quotes const colonIndex = findColonOutsideQuotes(line); if (colonIndex === -1) continue; const key = line.slice(0, colonIndex).trim(); const value = line.slice(colonIndex + 1).trim(); if (value) { storeValue(key, value); currentKey = key; // For potential array items following } else { currentKey = key; // Key with no immediate value, might have array items below } } return mapRawToMetadata(raw); } /** * Find the first colon that is not inside single or double quotes. */ function findColonOutsideQuotes(line) { let inSingleQuote = false; let inDoubleQuote = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === "'" && !inDoubleQuote) { inSingleQuote = !inSingleQuote; } else if (char === '"' && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; } else if (char === ':' && !inSingleQuote && !inDoubleQuote) { return i; } } return -1; } /** * Map raw parsed key-value pairs to typed CustomCommandMetadata. */ function mapRawToMetadata(raw) { const metadata = {}; if (raw.description) metadata.description = raw.description; if (raw.aliases) metadata.aliases = raw.aliases; if (raw.parameters) metadata.parameters = raw.parameters; if (raw.tags) metadata.tags = raw.tags; if (raw.triggers) metadata.triggers = raw.triggers; if (typeof raw['estimated-tokens'] === 'number') metadata.estimatedTokens = raw['estimated-tokens']; if (raw.category) metadata.category = raw.category; if (raw.version) metadata.version = raw.version; if (raw.author) metadata.author = raw.author; if (raw.examples) metadata.examples = raw.examples; if (raw.references) metadata.references = raw.references; if (raw.dependencies) metadata.dependencies = raw.dependencies; return metadata; } /** * Parse a markdown file with optional YAML frontmatter */ export function parseCommandFile(filePath) { const fileContent = readFileSync(filePath, 'utf-8'); // Check for frontmatter const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/; const match = fileContent.match(frontmatterRegex); if (match && match[1] && match[2]) { // Parse YAML frontmatter const frontmatter = match[1]; const content = match[2]; let metadata = {}; try { metadata = parseEnhancedFrontmatter(frontmatter); } catch (error) { // If parsing fails, treat entire file as content logError(`Failed to parse frontmatter in ${filePath}: ${String(error)}`); return { metadata: {}, content: fileContent, }; } return { metadata, content: content.trim(), }; } // No frontmatter, entire file is content return { metadata: {}, content: fileContent.trim(), }; } /** * Replace template variables in command content */ export function substituteTemplateVariables(content, variables) { let result = content; // Replace {{variable}} patterns for (const [key, value] of Object.entries(variables)) { const pattern = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g'); result = result.replace(pattern, value); } return result; } //# sourceMappingURL=parser.js.map