BMAD-METHOD/expansion-packs/bmad-prp-integration/tools/prp-runner-adapter.js

404 lines
10 KiB
JavaScript

#!/usr/bin/env node
/**
* PRP Runner Adapter for BMad-Method Integration
*
* This tool provides integration between BMad-Method and PRPs-agentic-eng
* by managing PRP execution, result collection, and integration with BMad workflow.
*/
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs').promises;
const yaml = require('js-yaml');
class PRPRunnerAdapter {
constructor(config = {}) {
this.config = {
runnerPath: config.runnerPath || 'tools/prp_runner.py',
outputDir: config.outputDir || 'PRPs',
workingDir: config.workingDir || process.cwd(),
...config
};
}
/**
* Execute a PRP using the PRPs-agentic-eng runner
* @param {string} prpPath - Path to the PRP file
* @param {Object} options - Execution options
* @returns {Promise<Object>} Execution results
*/
async executePRP(prpPath, options = {}) {
const {
mode = 'interactive',
outputFormat = 'text',
verbose = false,
timeout = 300000 // 5 minutes default
} = options;
try {
// Validate PRP file exists
await this.validatePRPFile(prpPath);
// Build runner command
const command = this.buildRunnerCommand(prpPath, {
mode,
outputFormat,
verbose
});
// Execute PRP
const result = await this.runPRPCommand(command, { timeout });
// Process results
return await this.processResults(result, prpPath);
} catch (error) {
throw new Error(`PRP execution failed: ${error.message}`);
}
}
/**
* Validate PRP file exists and is properly formatted
* @param {string} prpPath - Path to PRP file
*/
async validatePRPFile(prpPath) {
try {
const stats = await fs.stat(prpPath);
if (!stats.isFile()) {
throw new Error(`PRP path is not a file: ${prpPath}`);
}
// Basic format validation
const content = await fs.readFile(prpPath, 'utf8');
if (!content.includes('## Goal') || !content.includes('## What')) {
throw new Error(`PRP file does not appear to be in correct format: ${prpPath}`);
}
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`PRP file not found: ${prpPath}`);
}
throw error;
}
}
/**
* Build PRP runner command with parameters
* @param {string} prpPath - Path to PRP file
* @param {Object} options - Command options
* @returns {Array} Command array for spawn
*/
buildRunnerCommand(prpPath, options) {
const { mode, outputFormat, verbose } = options;
const args = [
this.config.runnerPath,
'--prp-path', prpPath
];
// Add mode-specific arguments
if (mode === 'interactive') {
args.push('--interactive');
} else if (mode === 'headless') {
args.push('--output-format', outputFormat);
} else if (mode === 'streaming') {
args.push('--output-format', 'stream-json');
}
// Add verbose flag if requested
if (verbose) {
args.push('--verbose');
}
return ['python', args];
}
/**
* Execute PRP runner command
* @param {Array} command - Command array
* @param {Object} options - Execution options
* @returns {Promise<Object>} Execution result
*/
runPRPCommand(command, options = {}) {
return new Promise((resolve, reject) => {
const [executable, args] = command;
const { timeout } = options;
const child = spawn(executable, args, {
cwd: this.config.workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env }
});
let stdout = '';
let stderr = '';
let exitCode = null;
// Set timeout
const timeoutId = timeout ? setTimeout(() => {
child.kill('SIGTERM');
reject(new Error(`PRP execution timed out after ${timeout}ms`));
}, timeout) : null;
// Handle stdout
child.stdout.on('data', (data) => {
stdout += data.toString();
});
// Handle stderr
child.stderr.on('data', (data) => {
stderr += data.toString();
});
// Handle process completion
child.on('close', (code) => {
exitCode = code;
if (timeoutId) {
clearTimeout(timeoutId);
}
if (code === 0) {
resolve({
success: true,
stdout,
stderr,
exitCode
});
} else {
reject(new Error(`PRP execution failed with exit code ${code}: ${stderr}`));
}
});
// Handle process errors
child.on('error', (error) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
reject(new Error(`PRP execution error: ${error.message}`));
});
});
}
/**
* Process PRP execution results
* @param {Object} result - Raw execution result
* @param {string} prpPath - Original PRP file path
* @returns {Object} Processed results
*/
async processResults(result, prpPath) {
const { stdout, stderr, exitCode } = result;
// Parse output based on format
let parsedOutput;
try {
if (stdout.includes('{') && stdout.includes('}')) {
// Try to parse as JSON
const jsonMatch = stdout.match(/\{[\s\S]*\}/);
if (jsonMatch) {
parsedOutput = JSON.parse(jsonMatch[0]);
}
}
} catch (error) {
// Fall back to text parsing
parsedOutput = this.parseTextOutput(stdout);
}
// Collect generated artifacts
const artifacts = await this.collectArtifacts(prpPath);
return {
success: true,
exitCode,
output: parsedOutput || stdout,
stderr,
artifacts,
metadata: {
prpPath,
executionTime: new Date().toISOString(),
runnerVersion: await this.getRunnerVersion()
}
};
}
/**
* Parse text output from PRP execution
* @param {string} output - Raw output text
* @returns {Object} Parsed output
*/
parseTextOutput(output) {
const lines = output.split('\n');
const result = {
files: [],
tests: [],
errors: [],
warnings: []
};
for (const line of lines) {
if (line.includes('Created:') || line.includes('Modified:')) {
const fileMatch = line.match(/(?:Created|Modified):\s*(.+)/);
if (fileMatch) {
result.files.push(fileMatch[1].trim());
}
} else if (line.includes('test') && line.includes('passed')) {
result.tests.push(line.trim());
} else if (line.includes('ERROR:') || line.includes('FAILED:')) {
result.errors.push(line.trim());
} else if (line.includes('WARNING:') || line.includes('Warning:')) {
result.warnings.push(line.trim());
}
}
return result;
}
/**
* Collect artifacts generated during PRP execution
* @param {string} prpPath - Original PRP file path
* @returns {Array} List of generated artifacts
*/
async collectArtifacts(prpPath) {
const artifacts = [];
const prpDir = path.dirname(prpPath);
const workingDir = this.config.workingDir;
try {
// Look for common artifact patterns
const patterns = [
'src/**/*.py',
'src/**/*.js',
'src/**/*.ts',
'tests/**/*.py',
'tests/**/*.js',
'tests/**/*.ts',
'*.md',
'*.json',
'*.yaml',
'*.yml'
];
for (const pattern of patterns) {
try {
const files = await this.findFiles(path.join(workingDir, pattern));
artifacts.push(...files);
} catch (error) {
// Pattern not found, continue
}
}
// Remove duplicates and filter by modification time
const uniqueArtifacts = [...new Set(artifacts)];
const recentArtifacts = await this.filterRecentFiles(uniqueArtifacts, prpPath);
return recentArtifacts;
} catch (error) {
console.warn(`Warning: Could not collect artifacts: ${error.message}`);
return [];
}
}
/**
* Find files matching a pattern
* @param {string} pattern - File pattern
* @returns {Array} Matching files
*/
async findFiles(pattern) {
const { glob } = require('glob');
return await glob(pattern, { cwd: this.config.workingDir });
}
/**
* Filter files modified after PRP file
* @param {Array} files - List of files
* @param {string} prpPath - PRP file path
* @returns {Array} Recently modified files
*/
async filterRecentFiles(files, prpPath) {
try {
const prpStats = await fs.stat(prpPath);
const recentFiles = [];
for (const file of files) {
try {
const fileStats = await fs.stat(path.join(this.config.workingDir, file));
if (fileStats.mtime > prpStats.mtime) {
recentFiles.push(file);
}
} catch (error) {
// File not accessible, skip
}
}
return recentFiles;
} catch (error) {
console.warn(`Warning: Could not filter recent files: ${error.message}`);
return files;
}
}
/**
* Get PRP runner version
* @returns {string} Runner version
*/
async getRunnerVersion() {
try {
const result = await this.runPRPCommand(['python', [this.config.runnerPath, '--version']]);
return result.stdout.trim();
} catch (error) {
return 'unknown';
}
}
/**
* Validate PRP execution environment
* @returns {Object} Validation results
*/
async validateEnvironment() {
const results = {
runnerExists: false,
pythonAvailable: false,
workingDirValid: false,
outputDirValid: false
};
try {
// Check if runner exists
await fs.access(this.config.runnerPath);
results.runnerExists = true;
} catch (error) {
// Runner not found
}
try {
// Check if Python is available
const { execSync } = require('child_process');
execSync('python --version', { stdio: 'pipe' });
results.pythonAvailable = true;
} catch (error) {
// Python not available
}
try {
// Check working directory
await fs.access(this.config.workingDir);
results.workingDirValid = true;
} catch (error) {
// Working directory not accessible
}
try {
// Check output directory
await fs.access(this.config.outputDir);
results.outputDirValid = true;
} catch (error) {
// Output directory not accessible
}
return results;
}
}
module.exports = PRPRunnerAdapter;