aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
299 lines (262 loc) • 9.31 kB
JavaScript
/**
* Variable Elicitation Module
* Handles interactive prompting for template variables using inquirer.js
*
* @module elicitation
* @version 2.0.0
*/
;
const inquirer = require('inquirer');
/**
* Map template variable types to inquirer question types
* @param {string} type - Template variable type
* @returns {string} Inquirer question type
*/
function mapQuestionType(type) {
const typeMap = {
string: 'input',
text: 'editor',
number: 'number',
boolean: 'confirm',
choice: 'list',
multichoice: 'checkbox',
password: 'password',
array: 'input',
};
return typeMap[type] || 'input';
}
/**
* Create an inquirer question from a template variable
* @param {Object} variable - Template variable definition
* @param {Object} context - Existing context values
* @returns {Object} Inquirer question configuration
*/
function createQuestion(variable, context = {}) {
const question = {
type: mapQuestionType(variable.type),
name: variable.name,
message: variable.prompt,
default: context[variable.name] ?? variable.default,
};
// Add choices for choice/multichoice types
if (variable.choices) {
question.choices = variable.choices;
}
// Add validation
if (variable.required && variable.type !== 'boolean') {
question.validate = (input) => {
if (variable.type === 'array') {
return input && input.trim().length > 0 ? true : `${variable.name} is required`;
}
if (input === undefined || input === null || input === '') {
return `${variable.name} is required`;
}
return true;
};
}
// Add custom validation
if (variable.validation) {
const baseValidate = question.validate;
question.validate = (input) => {
if (baseValidate) {
const baseResult = baseValidate(input);
if (baseResult !== true) return baseResult;
}
if (variable.validation.minLength && input.length < variable.validation.minLength) {
return `${variable.name} must be at least ${variable.validation.minLength} characters`;
}
if (variable.validation.maxLength && input.length > variable.validation.maxLength) {
return `${variable.name} must be at most ${variable.validation.maxLength} characters`;
}
if (variable.validation.pattern) {
const regex = new RegExp(variable.validation.pattern);
if (!regex.test(input)) {
return variable.validation.message || `${variable.name} format is invalid`;
}
}
return true;
};
}
// Transform array input
if (variable.type === 'array') {
question.filter = (input) => {
if (typeof input === 'string') {
return input.split(',').map(s => s.trim()).filter(Boolean);
}
return input;
};
}
return question;
}
/**
* VariableElicitation class for handling interactive prompts
*/
class VariableElicitation {
/**
* Create a VariableElicitation instance
* @param {Object} options - Configuration options
* @param {boolean} options.interactive - Enable interactive mode (default: true)
*/
constructor(options = {}) {
this.interactive = options.interactive !== false;
this.autoResolvers = new Map();
this.registerDefaultAutoResolvers();
}
/**
* Register default auto-resolvers for common auto values
*/
registerDefaultAutoResolvers() {
// Next number resolvers
this.autoResolvers.set('next_adr_number', async () => this.getNextNumber('adr'));
this.autoResolvers.set('next_pmdr_number', async () => this.getNextNumber('pmdr'));
this.autoResolvers.set('next_dbdr_number', async () => this.getNextNumber('dbdr'));
this.autoResolvers.set('next_story_number', async () => this.getNextNumber('story'));
this.autoResolvers.set('next_epic_number', async () => this.getNextNumber('epic'));
this.autoResolvers.set('next_task_number', async () => this.getNextNumber('task'));
// Current date/time
this.autoResolvers.set('current_date', async () => new Date().toISOString().split('T')[0]);
this.autoResolvers.set('current_datetime', async () => new Date().toISOString());
this.autoResolvers.set('now', async () => new Date());
}
/**
* Get next number for a document type by scanning existing files
* @param {string} docType - Document type (adr, pmdr, etc.)
* @returns {Promise<number>} Next available number
*/
async getNextNumber(docType) {
const fs = require('fs').promises;
const path = require('path');
const glob = require('glob');
const patterns = {
adr: 'docs/architecture/decisions/adr-*.md',
pmdr: 'docs/decisions/pmdr-*.md',
dbdr: 'docs/decisions/dbdr-*.md',
story: 'docs/stories/**/story-*.md',
epic: 'docs/epics/epic-*.md',
task: 'docs/tasks/task-*.md',
};
const pattern = patterns[docType] || `**/${docType}-*.md`;
try {
const files = glob.sync(pattern, { cwd: process.cwd() });
const numbers = files.map(f => {
const match = path.basename(f).match(new RegExp(`${docType}-(\\d+)`));
return match ? parseInt(match[1], 10) : 0;
});
return Math.max(...numbers, 0) + 1;
} catch {
return 1;
}
}
/**
* Register a custom auto-resolver
* @param {string} name - Resolver name (matches variable.auto value)
* @param {Function} resolver - Async function that returns the value
*/
registerAutoResolver(name, resolver) {
this.autoResolvers.set(name, resolver);
}
/**
* Check if a variable's requiredIf condition is met
* @param {Object} variable - Variable with potential requiredIf condition
* @param {Object} values - Current values to check condition against
* @returns {boolean} True if variable should be required (condition met or no condition)
*/
isConditionallyRequired(variable, values) {
if (!variable.requiredIf) {
return variable.required;
}
// requiredIf is the name of another variable that must be truthy
return !!values[variable.requiredIf];
}
/**
* Elicit values for template variables
* @param {Array} variables - Template variable definitions
* @param {Object} context - Pre-provided context values
* @returns {Promise<Object>} Collected variable values
*/
async elicit(variables, context = {}) {
const values = { ...context };
const questions = [];
for (const variable of variables) {
// Skip if value already provided in context
if (context[variable.name] !== undefined) {
continue;
}
// Handle auto-resolved values
if (variable.auto && this.autoResolvers.has(variable.auto)) {
try {
values[variable.name] = await this.autoResolvers.get(variable.auto)();
continue;
} catch (error) {
console.warn(`Auto-resolver ${variable.auto} failed: ${error.message}`);
}
}
// Check if this is a conditionally required variable that should be skipped
// (e.g., userFlows is only needed when includeUIUX=true)
if (variable.requiredIf) {
const conditionMet = this.isConditionallyRequired(variable, values);
if (!conditionMet) {
// Skip this variable - its condition is not met
continue;
}
}
// Add to questions for interactive prompt
if (this.interactive) {
questions.push(createQuestion(variable, context));
} else if (variable.required && variable.default === undefined) {
throw new Error(`Required variable ${variable.name} has no default and interactive mode is disabled`);
} else {
values[variable.name] = variable.default;
}
}
// Prompt for remaining values
if (questions.length > 0 && this.interactive) {
const answers = await inquirer.prompt(questions);
Object.assign(values, answers);
}
return values;
}
/**
* Validate that all required variables have values
* @param {Array} variables - Template variable definitions
* @param {Object} values - Collected values
* @returns {Object} Validation result with isValid and errors
*/
validate(variables, values) {
const errors = [];
for (const variable of variables) {
// Check if variable is required (either always or conditionally)
const isRequired = this.isConditionallyRequired(variable, values);
if (isRequired) {
const value = values[variable.name];
if (value === undefined || value === null || value === '') {
errors.push(`Missing required variable: ${variable.name}`);
}
}
}
return {
isValid: errors.length === 0,
errors,
};
}
/**
* Merge context with defaults for non-interactive mode
* @param {Array} variables - Template variable definitions
* @param {Object} context - Provided context
* @returns {Object} Merged values with defaults
*/
mergeDefaults(variables, context = {}) {
const values = { ...context };
for (const variable of variables) {
if (values[variable.name] === undefined && variable.default !== undefined) {
values[variable.name] = variable.default;
}
}
return values;
}
}
module.exports = {
VariableElicitation,
createQuestion,
mapQuestionType,
};