UNPKG

aios-core

Version:

Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework

872 lines (740 loc) 20.6 kB
/** * Squad Extender Utility * * Extends existing squads with new components (agents, tasks, workflows, etc.) * with automatic manifest updates and validation. * * Used by: squad-creator agent (*extend-squad task) * * @module squad-extender * @version 1.0.0 * @see Story SQS-11: Squad Analyze & Extend */ const fs = require('fs').promises; const path = require('path'); const yaml = require('js-yaml'); /** * Default path for squads directory * @constant {string} */ const DEFAULT_SQUADS_PATH = './squads'; /** * Default templates directory path * @constant {string} */ const DEFAULT_TEMPLATES_PATH = './.aios-core/development/templates/squad'; /** * Component types and their configurations * @constant {Object} */ const COMPONENT_CONFIG = { agent: { directory: 'agents', extension: '.md', template: 'agent-template.md', manifestKey: 'agents', }, task: { directory: 'tasks', extension: '.md', template: 'task-template.md', manifestKey: 'tasks', }, workflow: { directory: 'workflows', extension: '.yaml', template: 'workflow-template.yaml', manifestKey: 'workflows', }, checklist: { directory: 'checklists', extension: '.md', template: 'checklist-template.md', manifestKey: 'checklists', }, template: { directory: 'templates', extension: '.md', template: 'template-template.md', manifestKey: 'templates', }, tool: { directory: 'tools', extension: '.js', template: 'tool-template.js', manifestKey: 'tools', }, script: { directory: 'scripts', extension: '.js', template: 'script-template.js', manifestKey: 'scripts', }, data: { directory: 'data', extension: '.yaml', template: 'data-template.yaml', manifestKey: 'data', }, }; /** * Manifest file names in order of preference * @constant {string[]} */ const MANIFEST_FILES = ['squad.yaml', 'config.yaml']; /** * Error codes for SquadExtenderError * @enum {string} */ const ErrorCodes = { SQUAD_NOT_FOUND: 'SQUAD_NOT_FOUND', MANIFEST_NOT_FOUND: 'MANIFEST_NOT_FOUND', MANIFEST_UPDATE_FAILED: 'MANIFEST_UPDATE_FAILED', COMPONENT_EXISTS: 'COMPONENT_EXISTS', INVALID_COMPONENT_NAME: 'INVALID_COMPONENT_NAME', INVALID_COMPONENT_TYPE: 'INVALID_COMPONENT_TYPE', AGENT_NOT_FOUND: 'AGENT_NOT_FOUND', TEMPLATE_NOT_FOUND: 'TEMPLATE_NOT_FOUND', PATH_TRAVERSAL: 'PATH_TRAVERSAL', CREATION_FAILED: 'CREATION_FAILED', }; /** * Custom error class for Squad Extender operations * @extends Error */ class SquadExtenderError extends Error { /** * Create a SquadExtenderError * @param {string} code - Error code from ErrorCodes enum * @param {string} message - Human-readable error message * @param {string} [suggestion] - Suggested fix for the error */ constructor(code, message, suggestion) { super(message); this.name = 'SquadExtenderError'; this.code = code; this.suggestion = suggestion || ''; if (Error.captureStackTrace) { Error.captureStackTrace(this, SquadExtenderError); } } /** * Create error for squad not found * @param {string} squadName - Name of the squad * @returns {SquadExtenderError} */ static squadNotFound(squadName) { return new SquadExtenderError( ErrorCodes.SQUAD_NOT_FOUND, `Squad "${squadName}" not found`, `Use *list-squads to see available squads, or *create-squad ${squadName} to create it`, ); } /** * Create error for manifest not found * @param {string} squadPath - Path to squad directory * @returns {SquadExtenderError} */ static manifestNotFound(squadPath) { return new SquadExtenderError( ErrorCodes.MANIFEST_NOT_FOUND, `No squad.yaml or config.yaml found in ${squadPath}`, 'Create squad.yaml with squad metadata', ); } /** * Create error for component already exists * @param {string} filePath - Path to existing component * @returns {SquadExtenderError} */ static componentExists(filePath) { return new SquadExtenderError( ErrorCodes.COMPONENT_EXISTS, `Component already exists at ${filePath}`, 'Use --force to overwrite, or choose a different name', ); } /** * Create error for invalid component name * @param {string} name - Invalid component name * @returns {SquadExtenderError} */ static invalidComponentName(name) { return new SquadExtenderError( ErrorCodes.INVALID_COMPONENT_NAME, `Invalid component name: "${name}"`, 'Use kebab-case (lowercase letters, numbers, and hyphens only)', ); } /** * Create error for invalid component type * @param {string} type - Invalid component type * @returns {SquadExtenderError} */ static invalidComponentType(type) { const validTypes = Object.keys(COMPONENT_CONFIG).join(', '); return new SquadExtenderError( ErrorCodes.INVALID_COMPONENT_TYPE, `Invalid component type: "${type}"`, `Valid types are: ${validTypes}`, ); } /** * Create error for agent not found * @param {string} agentId - Agent ID not found * @param {string[]} availableAgents - List of available agents * @returns {SquadExtenderError} */ static agentNotFound(agentId, availableAgents) { return new SquadExtenderError( ErrorCodes.AGENT_NOT_FOUND, `Agent "${agentId}" not found in squad`, `Available agents: ${availableAgents.join(', ')}`, ); } /** * Create error for path traversal attempt * @param {string} name - Component name with path characters * @returns {SquadExtenderError} */ static pathTraversal(name) { return new SquadExtenderError( ErrorCodes.PATH_TRAVERSAL, `Invalid component name - path traversal not allowed: "${name}"`, 'Component names cannot contain path separators or ".."', ); } } /** * Squad Extender class for adding new components to squads */ class SquadExtender { /** * Create a SquadExtender instance * @param {Object} [options={}] - Configuration options * @param {string} [options.squadsPath] - Custom squads directory path * @param {string} [options.templatesPath] - Custom templates directory path * @param {boolean} [options.verbose=false] - Enable verbose output */ constructor(options = {}) { this.squadsPath = options.squadsPath || DEFAULT_SQUADS_PATH; this.templatesPath = options.templatesPath || DEFAULT_TEMPLATES_PATH; this.verbose = options.verbose || false; } /** * Add a new component to a squad * @param {string} squadName - Name of the squad * @param {Object} componentInfo - Component configuration * @param {string} componentInfo.type - Component type (agent, task, workflow, etc.) * @param {string} componentInfo.name - Component name (kebab-case) * @param {string} [componentInfo.agentId] - Agent ID (required for tasks) * @param {string} [componentInfo.description] - Component description * @param {string} [componentInfo.storyId] - Related story ID for traceability * @param {Object} [options={}] - Add options * @param {boolean} [options.force=false] - Overwrite existing component * @returns {Promise<Object>} Result object with file path and status */ async addComponent(squadName, componentInfo, options = {}) { const { type, name, agentId, description, storyId } = componentInfo; const { force = false } = options; // Validate inputs this._validateComponentType(type); this._validateComponentName(name); const squadPath = path.join(this.squadsPath, squadName); // Check squad exists const exists = await this._directoryExists(squadPath); if (!exists) { throw SquadExtenderError.squadNotFound(squadName); } // For tasks, validate agent exists if (type === 'task' && agentId) { await this._validateAgentExists(squadPath, agentId); } // Get component config const config = COMPONENT_CONFIG[type]; // Build file name const fileName = this._buildFileName(type, name, agentId, config.extension); // Build target path const targetDir = path.join(squadPath, config.directory); const targetPath = path.join(targetDir, fileName); const relativePath = path.join(config.directory, fileName); // Check if file already exists if (await this._fileExists(targetPath)) { if (!force) { throw SquadExtenderError.componentExists(relativePath); } // Create backup before overwriting await this._createBackup(targetPath); } // Ensure target directory exists await this._ensureDirectory(targetDir); // Load and render template const content = await this._renderTemplate(type, { componentName: name, agentId, description: description || `${type} component: ${name}`, storyId: storyId || '', squadName, createdAt: new Date().toISOString().split('T')[0], }); // Write component file await fs.writeFile(targetPath, content, 'utf8'); // Update manifest const manifestUpdated = await this.updateManifest(squadPath, { type, file: fileName, }); return { success: true, filePath: targetPath, relativePath, fileName, type, templateUsed: config.template, manifestUpdated, }; } /** * Update squad manifest with new component * @param {string} squadPath - Path to squad directory * @param {Object} componentInfo - Component information * @param {string} componentInfo.type - Component type * @param {string} componentInfo.file - Component file name * @returns {Promise<boolean>} True if manifest was updated */ async updateManifest(squadPath, componentInfo) { const { type, file } = componentInfo; const config = COMPONENT_CONFIG[type]; const manifestKey = config.manifestKey; // Find manifest file const { manifestPath, manifest } = await this._loadManifest(squadPath); // Create backup await this._createBackup(manifestPath); // Ensure components section exists if (!manifest.components) { manifest.components = {}; } // Ensure component type array exists if (!manifest.components[manifestKey]) { manifest.components[manifestKey] = []; } // Add file if not already present if (!manifest.components[manifestKey].includes(file)) { manifest.components[manifestKey].push(file); } // Write updated manifest const yamlContent = yaml.dump(manifest, { indent: 2, lineWidth: -1, noRefs: true, sortKeys: false, }); await fs.writeFile(manifestPath, yamlContent, 'utf8'); return true; } /** * List agents in a squad * @param {string} squadPath - Path to squad directory * @returns {Promise<string[]>} List of agent IDs */ async listAgents(squadPath) { const agentsDir = path.join(squadPath, 'agents'); try { const entries = await fs.readdir(agentsDir, { withFileTypes: true }); return entries .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) .map((entry) => entry.name.replace('.md', '')); } catch { return []; } } // ============================================ // Private Helper Methods // ============================================ /** * Validate component type * @private */ _validateComponentType(type) { if (!COMPONENT_CONFIG[type]) { throw SquadExtenderError.invalidComponentType(type); } } /** * Validate component name (kebab-case) * @private */ _validateComponentName(name) { // Check for path traversal if (name.includes('/') || name.includes('\\') || name.includes('..')) { throw SquadExtenderError.pathTraversal(name); } // Check kebab-case format const kebabCasePattern = /^[a-z][a-z0-9-]*[a-z0-9]$|^[a-z]$/; if (!kebabCasePattern.test(name)) { throw SquadExtenderError.invalidComponentName(name); } } /** * Validate agent exists in squad * @private */ async _validateAgentExists(squadPath, agentId) { const agents = await this.listAgents(squadPath); if (!agents.includes(agentId)) { throw SquadExtenderError.agentNotFound(agentId, agents); } } /** * Build file name for component * @private */ _buildFileName(type, name, agentId, extension) { // For tasks, prepend agent ID if (type === 'task' && agentId) { return `${agentId}-${name}${extension}`; } return `${name}${extension}`; } /** * Check if directory exists * @private */ async _directoryExists(dirPath) { try { const stats = await fs.stat(dirPath); return stats.isDirectory(); } catch { return false; } } /** * Check if file exists * @private */ async _fileExists(filePath) { try { await fs.access(filePath); return true; } catch { return false; } } /** * Ensure directory exists, create if not * @private */ async _ensureDirectory(dirPath) { await fs.mkdir(dirPath, { recursive: true }); } /** * Create backup of file * @private */ async _createBackup(filePath) { try { const backupPath = `${filePath}.bak`; await fs.copyFile(filePath, backupPath); if (this.verbose) { console.log(`Backup created: ${backupPath}`); } } catch { // File might not exist yet, that's ok } } /** * Load squad manifest * @private */ async _loadManifest(squadPath) { for (const manifestFile of MANIFEST_FILES) { const manifestPath = path.join(squadPath, manifestFile); try { const content = await fs.readFile(manifestPath, 'utf8'); const manifest = yaml.load(content); return { manifestPath, manifest }; } catch (error) { if (error.code !== 'ENOENT') { throw new SquadExtenderError( ErrorCodes.MANIFEST_UPDATE_FAILED, `Failed to parse ${manifestFile}: ${error.message}`, 'Check YAML syntax - use a YAML linter', ); } } } throw SquadExtenderError.manifestNotFound(squadPath); } /** * Load and render template * @private */ async _renderTemplate(type, context) { const config = COMPONENT_CONFIG[type]; const templatePath = path.join(this.templatesPath, config.template); try { // Try to load template from templates path const template = await fs.readFile(templatePath, 'utf8'); return this._interpolateTemplate(template, context); } catch (error) { if (error.code === 'ENOENT') { // Template not found, use default template return this._getDefaultTemplate(type, context); } throw error; } } /** * Interpolate template variables * @private */ _interpolateTemplate(template, context) { let result = template; for (const [key, value] of Object.entries(context)) { const placeholder = new RegExp(`\\{\\{${key.toUpperCase()}\\}\\}`, 'g'); result = result.replace(placeholder, value || ''); } return result; } /** * Get default template for component type * @private */ _getDefaultTemplate(type, context) { const templates = { agent: `# ${context.componentName} > Agent definition for ${context.squadName} > Created: ${context.createdAt} ${context.storyId ? `> Story: ${context.storyId}` : ''} ## Description ${context.description} ## Configuration \`\`\`yaml agent: name: ${context.componentName} id: ${context.componentName} title: "Agent Title" icon: "🤖" persona: role: "Describe the agent's role" style: "Communication style" commands: - help: "Show available commands" - exit: "Exit agent mode" dependencies: tasks: [] templates: [] \`\`\` `, task: `--- task: ${context.componentName} responsavel: "@${context.agentId || 'agent'}" responsavel_type: Agent atomic_layer: Task elicit: false Entrada: - campo: input_param tipo: string origem: User Input obrigatorio: true validacao: "Description of validation" Saida: - campo: output_result tipo: object destino: Return value persistido: false Checklist: - "[ ] Step 1" - "[ ] Step 2" - "[ ] Step 3" --- # ${context.componentName} ## Description ${context.description} ${context.storyId ? `## Story Reference\n\n- **Story:** ${context.storyId}\n` : ''} ## Execution Steps ### Step 1: Initialize \`\`\`javascript // Implementation here \`\`\` ### Step 2: Process \`\`\`javascript // Implementation here \`\`\` ### Step 3: Complete \`\`\`javascript // Implementation here \`\`\` ## Error Handling \`\`\`yaml error: ERROR_CODE cause: Description of cause resolution: How to resolve \`\`\` ## Metadata \`\`\`yaml ${context.storyId ? `story: ${context.storyId}` : 'story: N/A'} version: 1.0.0 created: ${context.createdAt} author: squad-creator \`\`\` `, workflow: `# ${context.componentName} Workflow name: ${context.componentName} description: ${context.description} version: 1.0.0 ${context.storyId ? `story: ${context.storyId}` : ''} created: ${context.createdAt} # Trigger conditions triggers: - manual # Workflow steps steps: - id: step-1 name: "Step 1" description: "Description of step 1" action: task task: task-name - id: step-2 name: "Step 2" description: "Description of step 2" action: task task: task-name depends_on: - step-1 # Completion criteria completion: success_message: "Workflow completed successfully" failure_message: "Workflow failed" `, checklist: `# ${context.componentName} Checklist > ${context.description} > Created: ${context.createdAt} ${context.storyId ? `> Story: ${context.storyId}` : ''} ## Pre-Conditions - [ ] Pre-condition 1 - [ ] Pre-condition 2 ## Validation Items ### Category 1 - [ ] Item 1 - [ ] Item 2 - [ ] Item 3 ### Category 2 - [ ] Item 4 - [ ] Item 5 ## Post-Conditions - [ ] Post-condition 1 - [ ] Post-condition 2 `, template: `# ${context.componentName} Template > ${context.description} > Created: ${context.createdAt} ${context.storyId ? `> Story: ${context.storyId}` : ''} ## Template Content Replace placeholders with actual values: - {{PLACEHOLDER_1}}: Description - {{PLACEHOLDER_2}}: Description --- ## Content {{PLACEHOLDER_1}} ### Section 1 {{PLACEHOLDER_2}} --- *Template generated by squad-creator* `, tool: `/** * ${context.componentName} Tool * * ${context.description} * * @module ${context.componentName} * @version 1.0.0 ${context.storyId ? `* @see ${context.storyId}` : ''} */ /** * Main function for ${context.componentName} * @param {Object} input - Input parameters * @returns {Object} Result */ function ${this._toCamelCase(context.componentName)}(input) { // Implementation here return { success: true, data: {}, }; } module.exports = { ${this._toCamelCase(context.componentName)}, }; `, script: `#!/usr/bin/env node /** * ${context.componentName} Script * * ${context.description} * * @module ${context.componentName} * @version 1.0.0 ${context.storyId ? `* @see ${context.storyId}` : ''} */ const fs = require('fs').promises; const path = require('path'); /** * Main entry point * @param {string[]} args - Command line arguments */ async function main(args) { console.log('${context.componentName} script started'); // Implementation here console.log('${context.componentName} script completed'); } // Run if called directly if (require.main === module) { main(process.argv.slice(2)) .catch((error) => { console.error('Error:', error.message); process.exit(1); }); } module.exports = { main }; `, data: `# ${context.componentName} Data name: ${context.componentName} description: ${context.description} version: 1.0.0 ${context.storyId ? `story: ${context.storyId}` : ''} created: ${context.createdAt} # Data schema schema: type: object properties: field1: type: string description: "Field description" field2: type: number description: "Field description" # Default values defaults: field1: "default value" field2: 0 # Data entries entries: [] `, }; return templates[type] || `# ${context.componentName}\n\n${context.description}`; } /** * Convert kebab-case to camelCase * @private */ _toCamelCase(str) { return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); } } module.exports = { SquadExtender, SquadExtenderError, ErrorCodes, COMPONENT_CONFIG, };