BMAD-METHOD/tools/cli/installers/lib/ide/_base-ide.js

267 lines
7.9 KiB
JavaScript

const path = require('node:path');
const fs = require('fs-extra');
const chalk = require('chalk');
const yaml = require('yaml');
/**
* Base class for IDE-specific setup
* All IDE handlers should extend this class
*/
class BaseIdeSetup {
constructor(name, displayName = null, preferred = false) {
this.name = name;
this.displayName = displayName || name; // Human-readable name for UI
this.preferred = preferred; // Whether this IDE should be shown in preferred list
this.configDir = null; // Override in subclasses (e.g., '.windsurf/workflows/wds')
this.configFile = null; // Override in subclasses when detection is file-based
this.detectionPaths = []; // Additional paths that indicate the IDE is configured
this.wdsFolderName = '_wds'; // Default, can be overridden
}
/**
* Set the WDS folder name for placeholder replacement
* @param {string} wdsFolderName - The WDS folder name
*/
setWdsFolderName(wdsFolderName) {
this.wdsFolderName = wdsFolderName;
}
/**
* Main setup method - must be implemented by subclasses
* @param {string} projectDir - Project directory
* @param {string} wdsDir - WDS installation directory
* @param {Object} options - Setup options
*/
async setup(projectDir, wdsDir, options = {}) {
throw new Error(`setup() must be implemented by ${this.name} handler`);
}
/**
* Cleanup IDE configuration
* @param {string} projectDir - Project directory
*/
async cleanup(projectDir) {
// Default implementation - can be overridden
if (this.configDir) {
const configPath = path.join(projectDir, this.configDir);
if (await fs.pathExists(configPath)) {
await fs.remove(configPath);
console.log(chalk.dim(`Removed ${this.name} WDS configuration`));
}
}
}
/**
* Detect whether this IDE already has configuration in the project
* Subclasses can override for custom logic
* @param {string} projectDir - Project directory
* @returns {boolean}
*/
async detect(projectDir) {
const pathsToCheck = [];
if (this.configDir) {
pathsToCheck.push(path.join(projectDir, this.configDir));
}
if (this.configFile) {
pathsToCheck.push(path.join(projectDir, this.configFile));
}
if (Array.isArray(this.detectionPaths)) {
for (const candidate of this.detectionPaths) {
if (!candidate) continue;
const resolved = path.isAbsolute(candidate) ? candidate : path.join(projectDir, candidate);
pathsToCheck.push(resolved);
}
}
for (const candidate of pathsToCheck) {
if (await fs.pathExists(candidate)) {
return true;
}
}
return false;
}
/**
* Get list of agents from WDS installation
* @param {string} wdsDir - WDS installation directory
* @returns {Array} List of agent files with metadata
*/
async getAgents(wdsDir) {
const agents = [];
const agentsPath = path.join(wdsDir, 'agents');
if (!(await fs.pathExists(agentsPath))) {
return agents;
}
const files = await fs.readdir(agentsPath);
for (const file of files) {
if (!file.endsWith('.md')) continue;
const filePath = path.join(agentsPath, file);
const agentName = file.replace('.md', '');
// Extract metadata from agent file
const metadata = await this.extractAgentMetadata(filePath);
// Create slug from agent name (e.g., 'saga-analyst' -> 'saga')
const slug = metadata.slug || agentName.split('-')[0];
agents.push({
name: agentName,
slug: slug,
path: filePath,
relativePath: path.relative(wdsDir, filePath),
filename: file,
metadata: metadata,
});
}
return agents;
}
/**
* Extract agent metadata from compiled agent markdown file
* Reads YAML frontmatter or fallback to defaults
* @param {string} filePath - Path to agent markdown file
* @returns {Object} Agent metadata
*/
async extractAgentMetadata(filePath) {
try {
const content = await fs.readFile(filePath, 'utf8');
// Try to extract YAML frontmatter
const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
const frontmatter = yaml.parse(frontmatterMatch[1]);
return {
name: frontmatter.name || path.basename(filePath, '.md'),
description: frontmatter.description || frontmatter.role || '',
icon: frontmatter.icon || '📋',
slug: frontmatter.id ? path.basename(frontmatter.id, '.md').split('-')[0] : null,
};
}
// Fallback: extract from filename
const agentName = path.basename(filePath, '.md');
return {
name: this.formatTitle(agentName),
description: agentName.includes('saga') ? 'Strategic Analyst' :
agentName.includes('freya') ? 'UX Designer' :
agentName.includes('idunn') ? 'Product Manager' : '',
icon: '📋',
slug: agentName.split('-')[0],
};
} catch (error) {
// Fallback metadata on error
const agentName = path.basename(filePath, '.md');
return {
name: this.formatTitle(agentName),
description: '',
icon: '📋',
slug: agentName.split('-')[0],
};
}
}
/**
* Format agent launcher content that references the compiled agent
* @param {string} agentName - Agent name (e.g., 'saga-analyst')
* @param {string} agentPath - Relative path to agent file (e.g., 'agents/saga-analyst.md')
* @returns {string} Launcher content
*/
formatAgentLauncher(agentName, agentPath) {
const relativePath = path.relative(process.cwd(), agentPath)
.replace(/\\/g, '/'); // Convert Windows paths to forward slashes
return `<!-- WDS Agent Launcher -->
<!-- This file references the compiled agent. Do not edit directly. -->
<!-- Source: ${this.wdsFolderName}/agents/${agentName}.md -->
@include(${this.wdsFolderName}/agents/${agentName}.md)
`;
}
/**
* Process content with IDE-specific frontmatter
* Subclasses must override this to add IDE-specific headers
* @param {string} content - Launcher content
* @param {Object} metadata - Agent metadata
* @returns {string} Processed content with IDE-specific frontmatter
*/
processContent(content, metadata = {}) {
// Default implementation - subclasses should override
return content;
}
/**
* Ensure directory exists
* @param {string} dirPath - Directory path
*/
async ensureDir(dirPath) {
await fs.ensureDir(dirPath);
}
/**
* Write file with content (replaces _wds placeholder)
* @param {string} filePath - File path
* @param {string} content - File content
*/
async writeFile(filePath, content) {
// Replace _wds placeholder if present
if (typeof content === 'string' && content.includes('_wds')) {
content = content.replaceAll('_wds', this.wdsFolderName);
}
await this.ensureDir(path.dirname(filePath));
await fs.writeFile(filePath, content, 'utf8');
}
/**
* Check if path exists
* @param {string} pathToCheck - Path to check
* @returns {boolean} True if path exists
*/
async exists(pathToCheck) {
return await fs.pathExists(pathToCheck);
}
/**
* Alias for exists method
* @param {string} pathToCheck - Path to check
* @returns {boolean} True if path exists
*/
async pathExists(pathToCheck) {
return await fs.pathExists(pathToCheck);
}
/**
* Read file content
* @param {string} filePath - File path
* @returns {string} File content
*/
async readFile(filePath) {
return await fs.readFile(filePath, 'utf8');
}
/**
* Format name as title
* @param {string} name - Name to format (e.g., 'saga-analyst')
* @returns {string} Formatted title (e.g., 'Saga Analyst')
*/
formatTitle(name) {
return name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
}
module.exports = { BaseIdeSetup };