mirror of
https://github.com/bmadcode/BMAD-METHOD.git
synced 2025-12-29 16:14:59 +00:00
feat: Complete BMAD agent creation system with install tooling, references, and field guidance
## Overview
This commit represents a complete overhaul of the BMAD agent creation system, establishing clear standards for agent development, installation workflows, and persona design. The changes span documentation, tooling, reference implementations, and field-specific guidance.
## Key Components
### 1. Agent Installation Infrastructure
**New CLI Command: `agent-install`**
- Interactive agent installation with persona customization
- Supports Simple (single YAML), Expert (sidecar files), and Module agents
- Template variable processing with Handlebars-style syntax
- Automatic compilation from YAML to XML (.md) format
- Manifest tracking and IDE integration (Claude Code, Cursor, Windsurf, etc.)
- Source preservation in `_cfg/custom/agents/` for reinstallation
**Files Created:**
- `tools/cli/commands/agent-install.js` - Main CLI command
- `tools/cli/lib/agent/compiler.js` - YAML to XML compilation engine
- `tools/cli/lib/agent/installer.js` - Installation orchestration
- `tools/cli/lib/agent/template-engine.js` - Handlebars template processing
**Compiler Features:**
- Auto-injects frontmatter, activation, handlers, help/exit menu items
- Smart handler inclusion (only includes action/workflow/exec/tmpl handlers actually used)
- Proper XML escaping and formatting
- Persona name customization (e.g., "Fred the Commit Poet")
### 2. Documentation Overhaul
**Deleted Bloated/Outdated Docs (2,651 lines removed):**
- Old verbose architecture docs
- Redundant pattern files
- Outdated workflow guides
**Created Focused, Type-Specific Docs:**
- `src/modules/bmb/docs/understanding-agent-types.md` - Architecture vs capability distinction
- `src/modules/bmb/docs/simple-agent-architecture.md` - Self-contained agents
- `src/modules/bmb/docs/expert-agent-architecture.md` - Agents with sidecar files
- `src/modules/bmb/docs/module-agent-architecture.md` - Workflow-integrated agents
- `src/modules/bmb/docs/agent-compilation.md` - YAML → XML process
- `src/modules/bmb/docs/agent-menu-patterns.md` - Menu design patterns
- `src/modules/bmb/docs/index.md` - Documentation hub
**Net Result:** ~1,930 line reduction while adding MORE value through focused content
### 3. Create-Agent Workflow Enhancements
**Critical Persona Field Guidance Added to Step 4:**
Explains how the LLM interprets each persona field when the agent activates:
- **role** → "What knowledge, skills, and capabilities do I possess?"
- **identity** → "What background, experience, and context shape my responses?"
- **communication_style** → "What verbal patterns, word choice, quirks, and phrasing do I use?"
- **principles** → "What beliefs and operating philosophy drive my choices?"
**Key Insight:** `communication_style` should ONLY describe HOW the agent talks, not restate role/identity/principles. The `communication-presets.csv` provides 60 pure communication styles with NO role/identity/principles mixed in.
**Files Updated:**
- `src/modules/bmb/workflows/create-agent/instructions.md` - Added persona field interpretation guide
- `src/modules/bmb/workflows/create-agent/brainstorm-context.md` - Refined to 137 lines
- `src/modules/bmb/workflows/create-agent/communication-presets.csv` - 60 styles across 13 categories
### 4. Reference Agent Cleanup
**Removed install_config Personality Bloat:**
Understanding: Future installer will handle personality customization, so stripped all personality toggles from reference agents.
**commit-poet.agent.yaml** (Simple Agent):
- BEFORE: 36 personality combinations (3 enthusiasm × 3 depths × 4 styles) = decision fatigue
- AFTER: Single concise persona with pure communication style
- Changed from verbose conditionals to: "Poetic drama and flair with every turn of a phrase. I transform mundane commits into lyrical masterpieces, finding beauty in your code's evolution."
- Reduction: 248 lines → 153 lines (38% reduction)
**journal-keeper.agent.yaml** (Expert Agent):
- Stripped install_config, simplified communication_style
- Shows proper Expert agent structure with sidecar files
**security-engineer.agent.yaml & trend-analyst.agent.yaml** (Module Agents):
- Added header comments explaining WHY Module Agent (design intent, not just location)
- Clarified: Module agents are designed FOR ecosystem integration, not capability-limited
**Files Updated:**
- `src/modules/bmb/reference/agents/simple-examples/commit-poet.agent.yaml`
- `src/modules/bmb/reference/agents/expert-examples/journal-keeper/journal-keeper.agent.yaml`
- `src/modules/bmb/reference/agents/module-examples/security-engineer.agent.yaml`
- `src/modules/bmb/reference/agents/module-examples/trend-analyst.agent.yaml`
### 5. BMM Agent Voice Enhancement
**Gave all 9 BMM agents distinct, memorable communication voices:**
**Mary (analyst)** - The favorite! Changed from generic "systematic and probing" to:
"Treats analysis like a treasure hunt - excited by every clue, thrilled when patterns emerge. Asks questions that spark 'aha!' moments while structuring insights with precision."
**Other Notable Voices:**
- **John (pm):** "Asks 'WHY?' relentlessly like a detective on a case. Direct and data-sharp, cuts through fluff to what actually matters."
- **Winston (architect):** "Speaks in calm, pragmatic tones, balancing 'what could be' with 'what should be.' Champions boring technology that actually works."
- **Amelia (dev):** "Ultra-succinct. Speaks in file paths and AC IDs - every statement citable. No fluff, all precision."
- **Bob (sm):** "Crisp and checklist-driven. Every word has a purpose, every requirement crystal clear. Zero tolerance for ambiguity."
- **Sally (ux-designer):** "Paints pictures with words, telling user stories that make you FEEL the problem. Empathetic advocate with creative storytelling flair."
**Pattern Applied:** Moved behaviors from communication_style to principles, keeping communication_style as PURE verbal patterns.
**Files Updated:**
- `src/modules/bmm/agents/analyst.agent.yaml`
- `src/modules/bmm/agents/pm.agent.yaml`
- `src/modules/bmm/agents/architect.agent.yaml`
- `src/modules/bmm/agents/dev.agent.yaml`
- `src/modules/bmm/agents/sm.agent.yaml`
- `src/modules/bmm/agents/tea.agent.yaml`
- `src/modules/bmm/agents/tech-writer.agent.yaml`
- `src/modules/bmm/agents/ux-designer.agent.yaml`
- `src/modules/bmm/agents/frame-expert.agent.yaml`
### 6. Linting Fixes
**ESLint Compliance:**
- Replaced all `'utf-8'` with `'utf8'` (unicorn/text-encoding-identifier-case)
- Changed `variables.hasOwnProperty(varName)` to `Object.hasOwn(variables, varName)` (unicorn/prefer-object-has-own)
- Replaced `JSON.parse(JSON.stringify(...))` with `structuredClone(...)` (unicorn/prefer-structured-clone)
- Fixed empty YAML mapping values in sample files
**Files Fixed:**
- 7 JavaScript files across agent tooling (compiler, installer, commands, IDE integration)
- 1 YAML sample file
## Architecture Decisions
### Agent Types Are About Architecture, Not Capability
- **Simple:** Self-contained in single YAML (NOT limited in capability)
- **Expert:** Includes sidecar files (templates, docs, etc.)
- **Module:** Designed for BMAD ecosystem integration (workflows, cross-agent coordination)
### Persona Field Separation Critical for LLM Interpretation
The LLM needs distinct fields to understand its role:
- Mixing role/identity/principles into communication_style confuses the persona
- Pure communication styles (from communication-presets.csv) have ZERO role/identity/principles content
- Example DON'T: "Experienced analyst who uses systematic approaches..." (mixing identity + style)
- Example DO: "Systematic and probing. Structures findings hierarchically." (pure style)
### Install-Time vs Runtime Configuration
- Template variables ({{var}}) resolve at compile-time
- Runtime variables ({user_name}, {bmad_folder}) resolve when agent activates
- Future installer will handle personality customization, so agents should ship with single default persona
## Testing
- All linting passes (ESLint with max-warnings=0)
- Agent compilation tested with commit-poet, journal-keeper examples
- Install workflow validated with Simple and Expert agent types
- Manifest tracking and IDE integration verified
## Impact
This establishes BMAD as having a complete, production-ready agent creation and installation system with:
- Clear documentation for all agent types
- Automated compilation and installation
- Strong persona design guidance
- Reference implementations showing best practices
- Distinct, memorable agent voices throughout BMM module
Co-Authored-By: BMad Builder <builder@bmad.dev>
Co-Authored-By: Mary the Analyst <analyst@bmad.dev>
Co-Authored-By: Paige the Tech Writer <tech-writer@bmad.dev>
This commit is contained in:
409
tools/cli/commands/agent-install.js
Normal file
409
tools/cli/commands/agent-install.js
Normal file
@@ -0,0 +1,409 @@
|
||||
const chalk = require('chalk');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const readline = require('node:readline');
|
||||
const {
|
||||
findBmadConfig,
|
||||
resolvePath,
|
||||
discoverAgents,
|
||||
loadAgentConfig,
|
||||
promptInstallQuestions,
|
||||
detectBmadProject,
|
||||
addToManifest,
|
||||
extractManifestData,
|
||||
checkManifestForPath,
|
||||
updateManifestEntry,
|
||||
saveAgentSource,
|
||||
createIdeSlashCommands,
|
||||
updateManifestYaml,
|
||||
} = require('../lib/agent/installer');
|
||||
|
||||
module.exports = {
|
||||
command: 'agent-install',
|
||||
description: 'Install and compile BMAD agents with personalization',
|
||||
options: [
|
||||
['-p, --path <path>', 'Path to specific agent YAML file or folder'],
|
||||
['-d, --defaults', 'Use default values without prompting'],
|
||||
['-t, --target <path>', 'Target installation directory (default: .bmad/agents)'],
|
||||
],
|
||||
action: async (options) => {
|
||||
try {
|
||||
console.log(chalk.cyan('\n🔧 BMAD Agent Installer\n'));
|
||||
|
||||
// Find BMAD config
|
||||
const config = findBmadConfig();
|
||||
if (!config) {
|
||||
console.log(chalk.yellow('No BMAD installation found in current directory.'));
|
||||
console.log(chalk.dim('Looking for .bmad/bmb/config.yaml...'));
|
||||
console.log(chalk.red('\nPlease run this command from a project with BMAD installed.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`Found BMAD at: ${config.bmadFolder}`));
|
||||
|
||||
let selectedAgent = null;
|
||||
|
||||
// If path provided, use it directly
|
||||
if (options.path) {
|
||||
const providedPath = path.resolve(options.path);
|
||||
|
||||
if (!fs.existsSync(providedPath)) {
|
||||
console.log(chalk.red(`Path not found: ${providedPath}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stat = fs.statSync(providedPath);
|
||||
if (stat.isFile() && providedPath.endsWith('.agent.yaml')) {
|
||||
selectedAgent = {
|
||||
type: 'simple',
|
||||
name: path.basename(providedPath, '.agent.yaml'),
|
||||
path: providedPath,
|
||||
yamlFile: providedPath,
|
||||
};
|
||||
} else if (stat.isDirectory()) {
|
||||
const yamlFiles = fs.readdirSync(providedPath).filter((f) => f.endsWith('.agent.yaml'));
|
||||
if (yamlFiles.length === 1) {
|
||||
selectedAgent = {
|
||||
type: 'expert',
|
||||
name: path.basename(providedPath),
|
||||
path: providedPath,
|
||||
yamlFile: path.join(providedPath, yamlFiles[0]),
|
||||
hasSidecar: true,
|
||||
};
|
||||
} else {
|
||||
console.log(chalk.red('Directory must contain exactly one .agent.yaml file'));
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.red('Path must be an .agent.yaml file or a folder containing one'));
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Discover agents from custom location
|
||||
const customAgentLocation = config.custom_agent_location
|
||||
? resolvePath(config.custom_agent_location, config)
|
||||
: path.join(config.bmadFolder, 'custom', 'agents');
|
||||
|
||||
console.log(chalk.dim(`Searching for agents in: ${customAgentLocation}\n`));
|
||||
|
||||
const agents = discoverAgents(customAgentLocation);
|
||||
|
||||
if (agents.length === 0) {
|
||||
console.log(chalk.yellow('No agents found in custom agent location.'));
|
||||
console.log(chalk.dim(`Expected location: ${customAgentLocation}`));
|
||||
console.log(chalk.dim('\nCreate agents using the BMad Builder workflow or place .agent.yaml files there.'));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// List available agents
|
||||
console.log(chalk.cyan('Available Agents:\n'));
|
||||
for (const [idx, agent] of agents.entries()) {
|
||||
const typeIcon = agent.type === 'expert' ? '📚' : '📄';
|
||||
console.log(` ${idx + 1}. ${typeIcon} ${chalk.bold(agent.name)} ${chalk.dim(`(${agent.type})`)}`);
|
||||
}
|
||||
|
||||
// Prompt for selection
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const selection = await new Promise((resolve) => {
|
||||
rl.question('\nSelect agent to install (number): ', resolve);
|
||||
});
|
||||
rl.close();
|
||||
|
||||
const selectedIdx = parseInt(selection, 10) - 1;
|
||||
if (isNaN(selectedIdx) || selectedIdx < 0 || selectedIdx >= agents.length) {
|
||||
console.log(chalk.red('Invalid selection'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
selectedAgent = agents[selectedIdx];
|
||||
}
|
||||
|
||||
console.log(chalk.cyan(`\nSelected: ${chalk.bold(selectedAgent.name)}`));
|
||||
|
||||
// Load agent configuration
|
||||
const agentConfig = loadAgentConfig(selectedAgent.yamlFile);
|
||||
|
||||
if (agentConfig.metadata.name) {
|
||||
console.log(chalk.dim(`Agent Name: ${agentConfig.metadata.name}`));
|
||||
}
|
||||
if (agentConfig.metadata.title) {
|
||||
console.log(chalk.dim(`Title: ${agentConfig.metadata.title}`));
|
||||
}
|
||||
|
||||
// Get the agent type (source name)
|
||||
const agentType = selectedAgent.name; // e.g., "commit-poet"
|
||||
|
||||
// Confirm/customize agent persona name
|
||||
const rl1 = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const defaultPersonaName = agentConfig.metadata.name || agentType;
|
||||
console.log(chalk.cyan('\n📛 Agent Persona Name\n'));
|
||||
console.log(chalk.dim(` Agent type: ${agentType}`));
|
||||
console.log(chalk.dim(` Default persona: ${defaultPersonaName}`));
|
||||
console.log(chalk.dim(' Leave blank to use default, or provide a custom name.'));
|
||||
console.log(chalk.dim(' Examples:'));
|
||||
console.log(chalk.dim(` - (blank) → "${defaultPersonaName}" as ${agentType}.md`));
|
||||
console.log(chalk.dim(` - "Fred" → "Fred" as fred-${agentType}.md`));
|
||||
console.log(chalk.dim(` - "Captain Code" → "Captain Code" as captain-code-${agentType}.md`));
|
||||
|
||||
const customPersonaName = await new Promise((resolve) => {
|
||||
rl1.question(`\n Custom name (or Enter for default): `, resolve);
|
||||
});
|
||||
rl1.close();
|
||||
|
||||
// Determine final agent file name based on persona name
|
||||
let finalAgentName;
|
||||
let personaName;
|
||||
if (customPersonaName.trim()) {
|
||||
personaName = customPersonaName.trim();
|
||||
const namePrefix = personaName.toLowerCase().replaceAll(/\s+/g, '-');
|
||||
finalAgentName = `${namePrefix}-${agentType}`;
|
||||
} else {
|
||||
personaName = defaultPersonaName;
|
||||
finalAgentName = agentType;
|
||||
}
|
||||
|
||||
console.log(chalk.dim(` Persona: ${personaName}`));
|
||||
console.log(chalk.dim(` File: ${finalAgentName}.md`));
|
||||
|
||||
// Get answers (prompt or use defaults)
|
||||
let presetAnswers = {};
|
||||
|
||||
// If custom persona name provided, inject it as custom_name for template processing
|
||||
if (customPersonaName.trim()) {
|
||||
presetAnswers.custom_name = personaName;
|
||||
}
|
||||
|
||||
let answers;
|
||||
if (agentConfig.installConfig && !options.defaults) {
|
||||
answers = await promptInstallQuestions(agentConfig.installConfig, agentConfig.defaults, presetAnswers);
|
||||
} else if (agentConfig.installConfig && options.defaults) {
|
||||
console.log(chalk.dim('\nUsing default configuration values.'));
|
||||
answers = { ...agentConfig.defaults, ...presetAnswers };
|
||||
} else {
|
||||
answers = { ...agentConfig.defaults, ...presetAnswers };
|
||||
}
|
||||
|
||||
// Determine target directory
|
||||
let targetDir = options.target ? path.resolve(options.target) : null;
|
||||
|
||||
// If no target specified, prompt for it
|
||||
if (targetDir) {
|
||||
// If target provided via --target, check if it's a project root and adjust
|
||||
const otherProject = detectBmadProject(targetDir);
|
||||
if (otherProject && !targetDir.includes('agents')) {
|
||||
// User specified project root, redirect to custom agents folder
|
||||
targetDir = path.join(otherProject.bmadFolder, 'custom', 'agents');
|
||||
console.log(chalk.dim(` Auto-selecting custom agents folder: ${targetDir}`));
|
||||
}
|
||||
} else {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
console.log(chalk.cyan('\n📂 Installation Target\n'));
|
||||
|
||||
// Option 1: Current project's custom agents folder
|
||||
const currentCustom = path.join(config.bmadFolder, 'custom', 'agents');
|
||||
console.log(` 1. Current project: ${chalk.dim(currentCustom)}`);
|
||||
|
||||
// Option 2: Specify another project path
|
||||
console.log(` 2. Another project (enter path)`);
|
||||
|
||||
const choice = await new Promise((resolve) => {
|
||||
rl.question('\n Select option (1 or path): ', resolve);
|
||||
});
|
||||
|
||||
if (choice.trim() === '1' || choice.trim() === '') {
|
||||
targetDir = currentCustom;
|
||||
} else if (choice.trim() === '2') {
|
||||
const projectPath = await new Promise((resolve) => {
|
||||
rl.question(' Project path: ', resolve);
|
||||
});
|
||||
|
||||
// Detect if it's a BMAD project and use its custom folder
|
||||
const otherProject = detectBmadProject(path.resolve(projectPath));
|
||||
if (otherProject) {
|
||||
targetDir = path.join(otherProject.bmadFolder, 'custom', 'agents');
|
||||
console.log(chalk.dim(` Found BMAD project, using: ${targetDir}`));
|
||||
} else {
|
||||
targetDir = path.resolve(projectPath);
|
||||
}
|
||||
} else {
|
||||
// User entered a path directly
|
||||
const otherProject = detectBmadProject(path.resolve(choice));
|
||||
if (otherProject) {
|
||||
targetDir = path.join(otherProject.bmadFolder, 'custom', 'agents');
|
||||
console.log(chalk.dim(` Found BMAD project, using: ${targetDir}`));
|
||||
} else {
|
||||
targetDir = path.resolve(choice);
|
||||
}
|
||||
}
|
||||
|
||||
rl.close();
|
||||
}
|
||||
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`\nInstalling to: ${targetDir}`));
|
||||
|
||||
// Detect if target is within a BMAD project
|
||||
const targetProject = detectBmadProject(targetDir);
|
||||
if (targetProject) {
|
||||
console.log(chalk.cyan(` Detected BMAD project at: ${targetProject.projectRoot}`));
|
||||
}
|
||||
|
||||
// Check for duplicate in manifest by path (not by type)
|
||||
let shouldUpdateExisting = false;
|
||||
let existingEntry = null;
|
||||
|
||||
if (targetProject) {
|
||||
// Check if this exact installed name already exists
|
||||
const expectedPath = `.bmad/custom/agents/${finalAgentName}/${finalAgentName}.md`;
|
||||
existingEntry = checkManifestForPath(targetProject.manifestFile, expectedPath);
|
||||
|
||||
if (existingEntry) {
|
||||
const rl2 = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
console.log(chalk.yellow(`\n⚠️ Agent "${finalAgentName}" already installed`));
|
||||
console.log(chalk.dim(` Type: ${agentType}`));
|
||||
console.log(chalk.dim(` Path: ${existingEntry.path}`));
|
||||
|
||||
const overwrite = await new Promise((resolve) => {
|
||||
rl2.question(' Overwrite existing installation? [Y/n]: ', resolve);
|
||||
});
|
||||
rl2.close();
|
||||
|
||||
if (overwrite.toLowerCase() === 'n') {
|
||||
console.log(chalk.yellow('Installation cancelled.'));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
shouldUpdateExisting = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Install the agent with custom name
|
||||
// Override the folder name with finalAgentName
|
||||
const agentTargetDir = path.join(targetDir, finalAgentName);
|
||||
|
||||
if (!fs.existsSync(agentTargetDir)) {
|
||||
fs.mkdirSync(agentTargetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Compile and install
|
||||
const { compileAgent } = require('../lib/agent/compiler');
|
||||
|
||||
// Calculate target path for agent ID
|
||||
const projectRoot = targetProject ? targetProject.projectRoot : config.projectRoot;
|
||||
const compiledFileName = `${finalAgentName}.md`;
|
||||
const compiledPath = path.join(agentTargetDir, compiledFileName);
|
||||
const relativePath = path.relative(projectRoot, compiledPath);
|
||||
|
||||
// Compile with proper name and path
|
||||
const { xml, metadata, processedYaml } = compileAgent(
|
||||
fs.readFileSync(selectedAgent.yamlFile, 'utf8'),
|
||||
answers,
|
||||
finalAgentName,
|
||||
relativePath,
|
||||
);
|
||||
|
||||
// Write compiled XML (.md) with custom name
|
||||
fs.writeFileSync(compiledPath, xml, 'utf8');
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
agentName: finalAgentName,
|
||||
targetDir: agentTargetDir,
|
||||
compiledFile: compiledPath,
|
||||
sidecarCopied: false,
|
||||
};
|
||||
|
||||
// Copy sidecar files for expert agents
|
||||
if (selectedAgent.hasSidecar && selectedAgent.type === 'expert') {
|
||||
const { copySidecarFiles } = require('../lib/agent/installer');
|
||||
const sidecarFiles = copySidecarFiles(selectedAgent.path, agentTargetDir, selectedAgent.yamlFile);
|
||||
result.sidecarCopied = true;
|
||||
result.sidecarFiles = sidecarFiles;
|
||||
}
|
||||
|
||||
console.log(chalk.green('\n✨ Agent installed successfully!'));
|
||||
console.log(chalk.cyan(` Name: ${result.agentName}`));
|
||||
console.log(chalk.cyan(` Location: ${result.targetDir}`));
|
||||
console.log(chalk.cyan(` Compiled: ${path.basename(result.compiledFile)}`));
|
||||
|
||||
if (result.sidecarCopied) {
|
||||
console.log(chalk.cyan(` Sidecar files: ${result.sidecarFiles.length} files copied`));
|
||||
}
|
||||
|
||||
// Save source YAML to _cfg/custom/agents/ and register in manifest
|
||||
if (targetProject) {
|
||||
// Save source for reinstallation with embedded answers
|
||||
console.log(chalk.dim(`\nSaving source to: ${targetProject.cfgFolder}/custom/agents/`));
|
||||
saveAgentSource(selectedAgent, targetProject.cfgFolder, finalAgentName, answers);
|
||||
console.log(chalk.green(` ✓ Source saved for reinstallation`));
|
||||
|
||||
// Register/update in manifest
|
||||
console.log(chalk.dim(`Registering in manifest: ${targetProject.manifestFile}`));
|
||||
|
||||
const manifestData = extractManifestData(xml, { ...metadata, name: finalAgentName }, relativePath, 'custom');
|
||||
// Use finalAgentName as the manifest name field (unique identifier)
|
||||
manifestData.name = finalAgentName;
|
||||
// Use compiled metadata.name (persona name after template processing), not source agentConfig
|
||||
manifestData.displayName = metadata.name || agentType;
|
||||
// Store the actual installed path/name
|
||||
manifestData.path = relativePath;
|
||||
|
||||
if (shouldUpdateExisting && existingEntry) {
|
||||
updateManifestEntry(targetProject.manifestFile, manifestData, existingEntry._lineNumber);
|
||||
console.log(chalk.green(` ✓ Updated existing entry in agent-manifest.csv`));
|
||||
} else {
|
||||
addToManifest(targetProject.manifestFile, manifestData);
|
||||
console.log(chalk.green(` ✓ Added to agent-manifest.csv`));
|
||||
}
|
||||
|
||||
// Create IDE slash commands
|
||||
const ideResults = await createIdeSlashCommands(targetProject.projectRoot, finalAgentName, relativePath, metadata);
|
||||
if (Object.keys(ideResults).length > 0) {
|
||||
console.log(chalk.green(` ✓ Created IDE commands:`));
|
||||
for (const [ideName, result] of Object.entries(ideResults)) {
|
||||
console.log(chalk.dim(` ${ideName}: ${result.command}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Update manifest.yaml with custom_agents tracking
|
||||
const manifestYamlPath = path.join(targetProject.cfgFolder, 'manifest.yaml');
|
||||
if (updateManifestYaml(manifestYamlPath, finalAgentName, agentType)) {
|
||||
console.log(chalk.green(` ✓ Updated manifest.yaml custom_agents`));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`\nAgent ID: ${relativePath}`));
|
||||
|
||||
if (targetProject) {
|
||||
console.log(chalk.yellow('\nAgent is now registered and available in the target project!'));
|
||||
} else {
|
||||
console.log(chalk.yellow('\nTo use this agent, reference it in your manifest or load it directly.'));
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error(chalk.red('Agent installation failed:'), error.message);
|
||||
console.error(chalk.dim(error.stack));
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -840,6 +840,15 @@ class Installer {
|
||||
console.log(chalk.dim('Review the .bak files to see your changes and merge if needed.\n'));
|
||||
}
|
||||
|
||||
// Reinstall custom agents from _cfg/custom/agents/ sources
|
||||
const customAgentResults = await this.reinstallCustomAgents(projectDir, bmadDir);
|
||||
if (customAgentResults.count > 0) {
|
||||
console.log(chalk.green(`\n✓ Reinstalled ${customAgentResults.count} custom agent${customAgentResults.count > 1 ? 's' : ''}`));
|
||||
for (const agent of customAgentResults.agents) {
|
||||
console.log(chalk.dim(` - ${agent}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Display completion message
|
||||
const { UI } = require('../../../lib/ui');
|
||||
const ui = new UI();
|
||||
@@ -2245,6 +2254,116 @@ class Installer {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinstall custom agents from _cfg/custom/agents/ sources
|
||||
* This preserves custom agents across quick updates/reinstalls
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @returns {Object} Result with count and agent names
|
||||
*/
|
||||
async reinstallCustomAgents(projectDir, bmadDir) {
|
||||
const customAgentsCfgDir = path.join(bmadDir, '_cfg', 'custom', 'agents');
|
||||
const results = { count: 0, agents: [] };
|
||||
|
||||
if (!(await fs.pathExists(customAgentsCfgDir))) {
|
||||
return results;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
discoverAgents,
|
||||
loadAgentConfig,
|
||||
extractManifestData,
|
||||
addToManifest,
|
||||
createIdeSlashCommands,
|
||||
updateManifestYaml,
|
||||
} = require('../../../lib/agent/installer');
|
||||
const { compileAgent } = require('../../../lib/agent/compiler');
|
||||
|
||||
// Discover custom agents in _cfg/custom/agents/
|
||||
const agents = discoverAgents(customAgentsCfgDir);
|
||||
|
||||
if (agents.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const customAgentsDir = path.join(bmadDir, 'custom', 'agents');
|
||||
await fs.ensureDir(customAgentsDir);
|
||||
|
||||
const manifestFile = path.join(bmadDir, '_cfg', 'agent-manifest.csv');
|
||||
const manifestYamlFile = path.join(bmadDir, '_cfg', 'manifest.yaml');
|
||||
|
||||
for (const agent of agents) {
|
||||
try {
|
||||
const agentConfig = loadAgentConfig(agent.yamlFile);
|
||||
const finalAgentName = agent.name; // Already named correctly from save
|
||||
|
||||
// Determine agent type from the name (e.g., "fred-commit-poet" → "commit-poet")
|
||||
let agentType = finalAgentName;
|
||||
const parts = finalAgentName.split('-');
|
||||
if (parts.length >= 2) {
|
||||
// Try to extract type (last part or last two parts)
|
||||
// For "fred-commit-poet", we want "commit-poet"
|
||||
// This is heuristic - could be improved with metadata storage
|
||||
agentType = parts.slice(-2).join('-'); // Take last 2 parts as type
|
||||
}
|
||||
|
||||
// Create target directory
|
||||
const agentTargetDir = path.join(customAgentsDir, finalAgentName);
|
||||
await fs.ensureDir(agentTargetDir);
|
||||
|
||||
// Calculate paths
|
||||
const compiledFileName = `${finalAgentName}.md`;
|
||||
const compiledPath = path.join(agentTargetDir, compiledFileName);
|
||||
const relativePath = path.relative(projectDir, compiledPath);
|
||||
|
||||
// Compile with embedded defaults (answers are already in defaults section)
|
||||
const { xml, metadata } = compileAgent(
|
||||
await fs.readFile(agent.yamlFile, 'utf8'),
|
||||
agentConfig.defaults || {},
|
||||
finalAgentName,
|
||||
relativePath,
|
||||
);
|
||||
|
||||
// Write compiled agent
|
||||
await fs.writeFile(compiledPath, xml, 'utf8');
|
||||
|
||||
// Copy sidecar files if expert agent
|
||||
if (agent.hasSidecar && agent.type === 'expert') {
|
||||
const { copySidecarFiles } = require('../../../lib/agent/installer');
|
||||
copySidecarFiles(agent.path, agentTargetDir, agent.yamlFile);
|
||||
}
|
||||
|
||||
// Update manifest CSV
|
||||
if (await fs.pathExists(manifestFile)) {
|
||||
const manifestData = extractManifestData(xml, { ...metadata, name: finalAgentName }, relativePath, 'custom');
|
||||
manifestData.name = finalAgentName;
|
||||
manifestData.displayName = metadata.name || finalAgentName;
|
||||
manifestData.path = relativePath;
|
||||
addToManifest(manifestFile, manifestData);
|
||||
}
|
||||
|
||||
// Create IDE slash commands (async function)
|
||||
await createIdeSlashCommands(projectDir, finalAgentName, relativePath, metadata);
|
||||
|
||||
// Update manifest.yaml
|
||||
if (await fs.pathExists(manifestYamlFile)) {
|
||||
updateManifestYaml(manifestYamlFile, finalAgentName, agentType);
|
||||
}
|
||||
|
||||
results.count++;
|
||||
results.agents.push(finalAgentName);
|
||||
} catch (agentError) {
|
||||
console.log(chalk.yellow(` ⚠️ Failed to reinstall ${agent.name}: ${agentError.message}`));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` ⚠️ Error reinstalling custom agents: ${error.message}`));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy IDE-specific documentation to BMAD docs
|
||||
* @param {Array} ides - List of selected IDEs
|
||||
|
||||
@@ -73,6 +73,19 @@ class BaseIdeSetup {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a custom agent launcher - subclasses should override
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
|
||||
* @param {string} agentPath - Path to compiled agent (relative to project root)
|
||||
* @param {Object} metadata - Agent metadata
|
||||
* @returns {Object|null} Info about created command, or null if not supported
|
||||
*/
|
||||
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
|
||||
// Default implementation - subclasses can override
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether this IDE already has configuration in the project
|
||||
* Subclasses can override for custom logic
|
||||
|
||||
@@ -466,6 +466,49 @@ class ClaudeCodeSetup extends BaseIdeSetup {
|
||||
console.log(chalk.dim(` Total subagents installed: ${copiedCount}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a custom agent launcher for Claude Code
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
|
||||
* @param {string} agentPath - Path to compiled agent (relative to project root)
|
||||
* @param {Object} metadata - Agent metadata
|
||||
* @returns {Object|null} Info about created command
|
||||
*/
|
||||
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
|
||||
const customAgentsDir = path.join(projectDir, this.configDir, this.commandsDir, 'bmad', 'custom', 'agents');
|
||||
|
||||
if (!(await this.exists(path.join(projectDir, this.configDir)))) {
|
||||
return null; // IDE not configured for this project
|
||||
}
|
||||
|
||||
await this.ensureDir(customAgentsDir);
|
||||
|
||||
const launcherContent = `---
|
||||
name: '${agentName}'
|
||||
description: '${agentName} agent'
|
||||
---
|
||||
|
||||
You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
|
||||
|
||||
<agent-activation CRITICAL="TRUE">
|
||||
1. LOAD the FULL agent file from @${agentPath}
|
||||
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
|
||||
3. FOLLOW every step in the <activation> section precisely
|
||||
4. DISPLAY the welcome/greeting as instructed
|
||||
5. PRESENT the numbered menu
|
||||
6. WAIT for user input before proceeding
|
||||
</agent-activation>
|
||||
`;
|
||||
|
||||
const launcherPath = path.join(customAgentsDir, `${agentName}.md`);
|
||||
await this.writeFile(launcherPath, launcherContent);
|
||||
|
||||
return {
|
||||
path: launcherPath,
|
||||
command: `/bmad:custom:agents:${agentName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ClaudeCodeSetup };
|
||||
|
||||
@@ -344,6 +344,45 @@ class CodexSetup extends BaseIdeSetup {
|
||||
await this.clearOldBmadFiles(projectSpecificDir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a custom agent launcher for Codex
|
||||
* @param {string} projectDir - Project directory (not used, Codex installs to home)
|
||||
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
|
||||
* @param {string} agentPath - Path to compiled agent (relative to project root)
|
||||
* @param {Object} metadata - Agent metadata
|
||||
* @returns {Object|null} Info about created command
|
||||
*/
|
||||
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
|
||||
const destDir = this.getCodexPromptDir();
|
||||
await fs.ensureDir(destDir);
|
||||
|
||||
const launcherContent = `---
|
||||
name: '${agentName}'
|
||||
description: '${agentName} agent'
|
||||
---
|
||||
|
||||
You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
|
||||
|
||||
<agent-activation CRITICAL="TRUE">
|
||||
1. LOAD the FULL agent file from @${agentPath}
|
||||
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
|
||||
3. FOLLOW every step in the <activation> section precisely
|
||||
4. DISPLAY the welcome/greeting as instructed
|
||||
5. PRESENT the numbered menu
|
||||
6. WAIT for user input before proceeding
|
||||
</agent-activation>
|
||||
`;
|
||||
|
||||
const fileName = `bmad-custom-agents-${agentName}.md`;
|
||||
const launcherPath = path.join(destDir, fileName);
|
||||
await fs.writeFile(launcherPath, launcherContent, 'utf8');
|
||||
|
||||
return {
|
||||
path: launcherPath,
|
||||
command: `/${fileName.replace('.md', '')}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { CodexSetup };
|
||||
|
||||
@@ -347,6 +347,54 @@ alwaysApply: false
|
||||
// Return MDC header + launcher content (without its original frontmatter)
|
||||
return mdcHeader + contentWithoutFrontmatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a custom agent launcher for Cursor
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
|
||||
* @param {string} agentPath - Path to compiled agent (relative to project root)
|
||||
* @param {Object} metadata - Agent metadata
|
||||
* @returns {Object|null} Info about created command
|
||||
*/
|
||||
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
|
||||
const customAgentsDir = path.join(projectDir, this.configDir, this.rulesDir, 'bmad', 'custom', 'agents');
|
||||
|
||||
if (!(await this.exists(path.join(projectDir, this.configDir)))) {
|
||||
return null; // IDE not configured for this project
|
||||
}
|
||||
|
||||
await this.ensureDir(customAgentsDir);
|
||||
|
||||
const launcherContent = `You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
|
||||
|
||||
<agent-activation CRITICAL="TRUE">
|
||||
1. LOAD the FULL agent file from @${agentPath}
|
||||
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
|
||||
3. FOLLOW every step in the <activation> section precisely
|
||||
4. DISPLAY the welcome/greeting as instructed
|
||||
5. PRESENT the numbered menu
|
||||
6. WAIT for user input before proceeding
|
||||
</agent-activation>
|
||||
`;
|
||||
|
||||
// Cursor uses MDC format with metadata header
|
||||
const mdcContent = `---
|
||||
description: "${agentName} agent"
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
${launcherContent}
|
||||
`;
|
||||
|
||||
const launcherPath = path.join(customAgentsDir, `${agentName}.mdc`);
|
||||
await this.writeFile(launcherPath, mdcContent);
|
||||
|
||||
return {
|
||||
path: launcherPath,
|
||||
command: `@${agentName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { CursorSetup };
|
||||
|
||||
@@ -297,6 +297,80 @@ ${cleanContent}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a custom agent launcher for GitHub Copilot
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
|
||||
* @param {string} agentPath - Path to compiled agent (relative to project root)
|
||||
* @param {Object} metadata - Agent metadata
|
||||
* @returns {Object|null} Info about created command
|
||||
*/
|
||||
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
|
||||
const chatmodesDir = path.join(projectDir, this.configDir, this.chatmodesDir);
|
||||
|
||||
if (!(await this.exists(path.join(projectDir, this.configDir)))) {
|
||||
return null; // IDE not configured for this project
|
||||
}
|
||||
|
||||
await this.ensureDir(chatmodesDir);
|
||||
|
||||
const launcherContent = `You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
|
||||
|
||||
<agent-activation CRITICAL="TRUE">
|
||||
1. LOAD the FULL agent file from @${agentPath}
|
||||
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
|
||||
3. FOLLOW every step in the <activation> section precisely
|
||||
4. DISPLAY the welcome/greeting as instructed
|
||||
5. PRESENT the numbered menu
|
||||
6. WAIT for user input before proceeding
|
||||
</agent-activation>
|
||||
`;
|
||||
|
||||
// GitHub Copilot needs specific tools in frontmatter
|
||||
const copilotTools = [
|
||||
'changes',
|
||||
'codebase',
|
||||
'createDirectory',
|
||||
'createFile',
|
||||
'editFiles',
|
||||
'fetch',
|
||||
'fileSearch',
|
||||
'githubRepo',
|
||||
'listDirectory',
|
||||
'problems',
|
||||
'readFile',
|
||||
'runInTerminal',
|
||||
'runTask',
|
||||
'runTests',
|
||||
'runVscodeCommand',
|
||||
'search',
|
||||
'searchResults',
|
||||
'terminalLastCommand',
|
||||
'terminalSelection',
|
||||
'testFailure',
|
||||
'textSearch',
|
||||
'usages',
|
||||
];
|
||||
|
||||
const chatmodeContent = `---
|
||||
description: "Activates the ${metadata.title || agentName} agent persona."
|
||||
tools: ${JSON.stringify(copilotTools)}
|
||||
---
|
||||
|
||||
# ${metadata.title || agentName} Agent
|
||||
|
||||
${launcherContent}
|
||||
`;
|
||||
|
||||
const chatmodePath = path.join(chatmodesDir, `bmad-agent-custom-${agentName}.chatmode.md`);
|
||||
await this.writeFile(chatmodePath, chatmodeContent);
|
||||
|
||||
return {
|
||||
path: chatmodePath,
|
||||
command: `bmad-agent-custom-${agentName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { GitHubCopilotSetup };
|
||||
|
||||
@@ -204,6 +204,41 @@ class IdeManager {
|
||||
|
||||
return detected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install custom agent launchers for specified IDEs
|
||||
* @param {Array} ides - List of IDE names to install for
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
|
||||
* @param {string} agentPath - Path to compiled agent (relative to project root)
|
||||
* @param {Object} metadata - Agent metadata
|
||||
* @returns {Object} Results for each IDE
|
||||
*/
|
||||
async installCustomAgentLaunchers(ides, projectDir, agentName, agentPath, metadata) {
|
||||
const results = {};
|
||||
|
||||
for (const ideName of ides) {
|
||||
const handler = this.handlers.get(ideName.toLowerCase());
|
||||
|
||||
if (!handler) {
|
||||
console.warn(chalk.yellow(`⚠️ IDE '${ideName}' is not yet supported for custom agent installation`));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof handler.installCustomAgentLauncher === 'function') {
|
||||
const result = await handler.installCustomAgentLauncher(projectDir, agentName, agentPath, metadata);
|
||||
if (result) {
|
||||
results[ideName] = result;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(chalk.yellow(`⚠️ Failed to install ${ideName} launcher: ${error.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { IdeManager };
|
||||
|
||||
@@ -207,6 +207,51 @@ class OpenCodeSetup extends BaseIdeSetup {
|
||||
console.log(chalk.dim(` Cleaned up ${removed} existing BMAD files`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a custom agent launcher for OpenCode
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
|
||||
* @param {string} agentPath - Path to compiled agent (relative to project root)
|
||||
* @param {Object} metadata - Agent metadata
|
||||
* @returns {Object|null} Info about created command
|
||||
*/
|
||||
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
|
||||
const agentsDir = path.join(projectDir, this.configDir, this.agentsDir);
|
||||
|
||||
if (!(await this.exists(path.join(projectDir, this.configDir)))) {
|
||||
return null; // IDE not configured for this project
|
||||
}
|
||||
|
||||
await this.ensureDir(agentsDir);
|
||||
|
||||
const launcherContent = `---
|
||||
name: '${agentName}'
|
||||
description: '${metadata.title || agentName} agent'
|
||||
mode: 'primary'
|
||||
---
|
||||
|
||||
You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
|
||||
|
||||
<agent-activation CRITICAL="TRUE">
|
||||
1. LOAD the FULL agent file from @${agentPath}
|
||||
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
|
||||
3. FOLLOW every step in the <activation> section precisely
|
||||
4. DISPLAY the welcome/greeting as instructed
|
||||
5. PRESENT the numbered menu
|
||||
6. WAIT for user input before proceeding
|
||||
</agent-activation>
|
||||
`;
|
||||
|
||||
// OpenCode uses flat naming: bmad-agent-custom-{name}.md
|
||||
const launcherPath = path.join(agentsDir, `bmad-agent-custom-${agentName}.md`);
|
||||
await this.writeFile(launcherPath, launcherContent);
|
||||
|
||||
return {
|
||||
path: launcherPath,
|
||||
command: `bmad-agent-custom-${agentName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { OpenCodeSetup };
|
||||
|
||||
@@ -206,6 +206,53 @@ ${content}`;
|
||||
console.log(chalk.dim(` Cleaned up existing BMAD workflows`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a custom agent launcher for Windsurf
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
|
||||
* @param {string} agentPath - Path to compiled agent (relative to project root)
|
||||
* @param {Object} metadata - Agent metadata
|
||||
* @returns {Object|null} Info about created command
|
||||
*/
|
||||
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
|
||||
const fs = require('fs-extra');
|
||||
const customAgentsDir = path.join(projectDir, this.configDir, this.workflowsDir, 'bmad', 'custom', 'agents');
|
||||
|
||||
if (!(await this.exists(path.join(projectDir, this.configDir)))) {
|
||||
return null; // IDE not configured for this project
|
||||
}
|
||||
|
||||
await this.ensureDir(customAgentsDir);
|
||||
|
||||
const launcherContent = `You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
|
||||
|
||||
<agent-activation CRITICAL="TRUE">
|
||||
1. LOAD the FULL agent file from @${agentPath}
|
||||
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
|
||||
3. FOLLOW every step in the <activation> section precisely
|
||||
4. DISPLAY the welcome/greeting as instructed
|
||||
5. PRESENT the numbered menu
|
||||
6. WAIT for user input before proceeding
|
||||
</agent-activation>
|
||||
`;
|
||||
|
||||
// Windsurf uses workflow format with frontmatter
|
||||
const workflowContent = `---
|
||||
description: ${metadata.title || agentName}
|
||||
auto_execution_mode: 3
|
||||
---
|
||||
|
||||
${launcherContent}`;
|
||||
|
||||
const launcherPath = path.join(customAgentsDir, `${agentName}.md`);
|
||||
await fs.writeFile(launcherPath, workflowContent);
|
||||
|
||||
return {
|
||||
path: launcherPath,
|
||||
command: `bmad/custom/agents/${agentName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { WindsurfSetup };
|
||||
|
||||
390
tools/cli/lib/agent/compiler.js
Normal file
390
tools/cli/lib/agent/compiler.js
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* BMAD Agent Compiler
|
||||
* Transforms agent YAML to compiled XML (.md) format
|
||||
* Uses the existing BMAD builder infrastructure for proper formatting
|
||||
*/
|
||||
|
||||
const yaml = require('yaml');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { processAgentYaml, extractInstallConfig, stripInstallConfig, getDefaultValues } = require('./template-engine');
|
||||
|
||||
// Use existing BMAD builder if available
|
||||
let YamlXmlBuilder;
|
||||
try {
|
||||
YamlXmlBuilder = require('../../lib/yaml-xml-builder').YamlXmlBuilder;
|
||||
} catch {
|
||||
YamlXmlBuilder = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape XML special characters
|
||||
*/
|
||||
function escapeXml(text) {
|
||||
if (!text) return '';
|
||||
return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build frontmatter for agent
|
||||
* @param {Object} metadata - Agent metadata
|
||||
* @param {string} agentName - Final agent name
|
||||
* @returns {string} YAML frontmatter
|
||||
*/
|
||||
function buildFrontmatter(metadata, agentName) {
|
||||
const nameFromFile = agentName.replaceAll('-', ' ');
|
||||
const description = metadata.title || 'BMAD Agent';
|
||||
|
||||
return `---
|
||||
name: "${nameFromFile}"
|
||||
description: "${description}"
|
||||
---
|
||||
|
||||
You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build simple activation block for custom agents
|
||||
* @param {Array} criticalActions - Agent-specific critical actions
|
||||
* @param {Array} menuItems - Menu items to determine which handlers to include
|
||||
* @returns {string} Activation XML
|
||||
*/
|
||||
function buildSimpleActivation(criticalActions = [], menuItems = []) {
|
||||
let activation = '<activation critical="MANDATORY">\n';
|
||||
|
||||
let stepNum = 1;
|
||||
|
||||
// Standard steps
|
||||
activation += ` <step n="${stepNum++}">Load persona from this current agent file (already in context)</step>\n`;
|
||||
activation += ` <step n="${stepNum++}">Load and read {project-root}/{bmad_folder}/core/config.yaml to get {user_name}, {communication_language}, {output_folder}</step>\n`;
|
||||
activation += ` <step n="${stepNum++}">Remember: user's name is {user_name}</step>\n`;
|
||||
|
||||
// Agent-specific steps from critical_actions
|
||||
for (const action of criticalActions) {
|
||||
activation += ` <step n="${stepNum++}">${action}</step>\n`;
|
||||
}
|
||||
|
||||
// Menu and interaction steps
|
||||
activation += ` <step n="${stepNum++}">ALWAYS communicate in {communication_language}</step>\n`;
|
||||
activation += ` <step n="${stepNum++}">Show greeting using {user_name} from config, communicate in {communication_language}, then display numbered list of
|
||||
ALL menu items from menu section</step>\n`;
|
||||
activation += ` <step n="${stepNum++}">STOP and WAIT for user input - do NOT execute menu items automatically - accept number or cmd trigger or fuzzy command
|
||||
match</step>\n`;
|
||||
activation += ` <step n="${stepNum++}">On user input: Number → execute menu item[n] | Text → case-insensitive substring match | Multiple matches → ask user
|
||||
to clarify | No match → show "Not recognized"</step>\n`;
|
||||
|
||||
// Detect which handlers are actually used
|
||||
const usedHandlers = new Set();
|
||||
for (const item of menuItems) {
|
||||
if (item.action) usedHandlers.add('action');
|
||||
if (item.workflow) usedHandlers.add('workflow');
|
||||
if (item.exec) usedHandlers.add('exec');
|
||||
if (item.tmpl) usedHandlers.add('tmpl');
|
||||
}
|
||||
|
||||
// Only include menu-handlers section if handlers are used
|
||||
if (usedHandlers.size > 0) {
|
||||
activation += ` <step n="${stepNum++}">When executing a menu item: Check menu-handlers section below - extract any attributes from the selected menu item and follow the corresponding handler instructions</step>\n`;
|
||||
|
||||
// Menu handlers - only include what's used
|
||||
activation += `
|
||||
<menu-handlers>
|
||||
<handlers>\n`;
|
||||
|
||||
if (usedHandlers.has('action')) {
|
||||
activation += ` <handler type="action">
|
||||
When menu item has: action="#id" → Find prompt with id="id" in current agent XML, execute its content
|
||||
When menu item has: action="text" → Execute the text directly as an inline instruction
|
||||
</handler>\n`;
|
||||
}
|
||||
|
||||
if (usedHandlers.has('workflow')) {
|
||||
activation += ` <handler type="workflow">
|
||||
When menu item has: workflow="path/to/workflow.yaml"
|
||||
1. CRITICAL: Always LOAD {project-root}/{bmad_folder}/core/tasks/workflow.xml
|
||||
2. Read the complete file - this is the CORE OS for executing BMAD workflows
|
||||
3. Pass the yaml path as 'workflow-config' parameter to those instructions
|
||||
4. Execute workflow.xml instructions precisely following all steps
|
||||
5. Save outputs after completing EACH workflow step (never batch multiple steps together)
|
||||
6. If workflow.yaml path is "todo", inform user the workflow hasn't been implemented yet
|
||||
</handler>\n`;
|
||||
}
|
||||
|
||||
if (usedHandlers.has('exec')) {
|
||||
activation += ` <handler type="exec">
|
||||
When menu item has: exec="command" → Execute the command directly
|
||||
</handler>\n`;
|
||||
}
|
||||
|
||||
if (usedHandlers.has('tmpl')) {
|
||||
activation += ` <handler type="tmpl">
|
||||
When menu item has: tmpl="template-path" → Load and apply the template
|
||||
</handler>\n`;
|
||||
}
|
||||
|
||||
activation += ` </handlers>
|
||||
</menu-handlers>\n`;
|
||||
}
|
||||
|
||||
activation += `
|
||||
<rules>
|
||||
- ALWAYS communicate in {communication_language} UNLESS contradicted by communication_style
|
||||
- Stay in character until exit selected
|
||||
- Menu triggers use asterisk (*) - NOT markdown, display exactly as shown
|
||||
- Number all lists, use letters for sub-options
|
||||
- Load files ONLY when executing menu items or a workflow or command requires it. EXCEPTION: Config file MUST be loaded at startup step 2
|
||||
- CRITICAL: Written File Output in workflows will be +2sd your communication style and use professional {communication_language}.
|
||||
</rules>
|
||||
</activation>\n`;
|
||||
|
||||
return activation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build persona XML section
|
||||
* @param {Object} persona - Persona object
|
||||
* @returns {string} Persona XML
|
||||
*/
|
||||
function buildPersonaXml(persona) {
|
||||
if (!persona) return '';
|
||||
|
||||
let xml = ' <persona>\n';
|
||||
|
||||
if (persona.role) {
|
||||
const roleText = persona.role.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ');
|
||||
xml += ` <role>${escapeXml(roleText)}</role>\n`;
|
||||
}
|
||||
|
||||
if (persona.identity) {
|
||||
const identityText = persona.identity.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ');
|
||||
xml += ` <identity>${escapeXml(identityText)}</identity>\n`;
|
||||
}
|
||||
|
||||
if (persona.communication_style) {
|
||||
const styleText = persona.communication_style.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ');
|
||||
xml += ` <communication_style>${escapeXml(styleText)}</communication_style>\n`;
|
||||
}
|
||||
|
||||
if (persona.principles) {
|
||||
let principlesText;
|
||||
if (Array.isArray(persona.principles)) {
|
||||
principlesText = persona.principles.join(' ');
|
||||
} else {
|
||||
principlesText = persona.principles.trim().replaceAll(/\n+/g, ' ');
|
||||
}
|
||||
xml += ` <principles>${escapeXml(principlesText)}</principles>\n`;
|
||||
}
|
||||
|
||||
xml += ' </persona>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build prompts XML section
|
||||
* @param {Array} prompts - Prompts array
|
||||
* @returns {string} Prompts XML
|
||||
*/
|
||||
function buildPromptsXml(prompts) {
|
||||
if (!prompts || prompts.length === 0) return '';
|
||||
|
||||
let xml = ' <prompts>\n';
|
||||
|
||||
for (const prompt of prompts) {
|
||||
xml += ` <prompt id="${prompt.id || ''}">\n`;
|
||||
xml += ` <content>\n`;
|
||||
// Don't escape prompt content - it's meant to be read as-is
|
||||
xml += `${prompt.content || ''}\n`;
|
||||
xml += ` </content>\n`;
|
||||
xml += ` </prompt>\n`;
|
||||
}
|
||||
|
||||
xml += ' </prompts>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build menu XML section
|
||||
* @param {Array} menuItems - Menu items
|
||||
* @returns {string} Menu XML
|
||||
*/
|
||||
function buildMenuXml(menuItems) {
|
||||
let xml = ' <menu>\n';
|
||||
|
||||
// Always inject *help first
|
||||
xml += ` <item cmd="*help">Show numbered menu</item>\n`;
|
||||
|
||||
// Add user-defined menu items
|
||||
if (menuItems && menuItems.length > 0) {
|
||||
for (const item of menuItems) {
|
||||
let trigger = item.trigger || '';
|
||||
if (!trigger.startsWith('*')) {
|
||||
trigger = '*' + trigger;
|
||||
}
|
||||
|
||||
const attrs = [`cmd="${trigger}"`];
|
||||
|
||||
// Add handler attributes
|
||||
if (item.workflow) attrs.push(`workflow="${item.workflow}"`);
|
||||
if (item.exec) attrs.push(`exec="${item.exec}"`);
|
||||
if (item.tmpl) attrs.push(`tmpl="${item.tmpl}"`);
|
||||
if (item.data) attrs.push(`data="${item.data}"`);
|
||||
if (item.action) attrs.push(`action="${item.action}"`);
|
||||
|
||||
xml += ` <item ${attrs.join(' ')}>${escapeXml(item.description || '')}</item>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Always inject *exit last
|
||||
xml += ` <item cmd="*exit">Exit with confirmation</item>\n`;
|
||||
|
||||
xml += ' </menu>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile agent YAML to proper XML format
|
||||
* @param {Object} agentYaml - Parsed and processed agent YAML
|
||||
* @param {string} agentName - Final agent name (for ID and frontmatter)
|
||||
* @param {string} targetPath - Target path for agent ID
|
||||
* @returns {string} Compiled XML string with frontmatter
|
||||
*/
|
||||
function compileToXml(agentYaml, agentName = '', targetPath = '') {
|
||||
const agent = agentYaml.agent;
|
||||
const meta = agent.metadata;
|
||||
|
||||
let xml = '';
|
||||
|
||||
// Build frontmatter
|
||||
xml += buildFrontmatter(meta, agentName || meta.name || 'agent');
|
||||
|
||||
// Start code fence
|
||||
xml += '```xml\n';
|
||||
|
||||
// Agent opening tag
|
||||
const agentAttrs = [
|
||||
`id="${targetPath || meta.id || ''}"`,
|
||||
`name="${meta.name || ''}"`,
|
||||
`title="${meta.title || ''}"`,
|
||||
`icon="${meta.icon || '🤖'}"`,
|
||||
];
|
||||
|
||||
xml += `<agent ${agentAttrs.join(' ')}>\n`;
|
||||
|
||||
// Activation block - pass menu items to determine which handlers to include
|
||||
xml += buildSimpleActivation(agent.critical_actions || [], agent.menu || []);
|
||||
|
||||
// Persona section
|
||||
xml += buildPersonaXml(agent.persona);
|
||||
|
||||
// Prompts section (if present)
|
||||
if (agent.prompts && agent.prompts.length > 0) {
|
||||
xml += buildPromptsXml(agent.prompts);
|
||||
}
|
||||
|
||||
// Menu section
|
||||
xml += buildMenuXml(agent.menu || []);
|
||||
|
||||
// Closing agent tag
|
||||
xml += '</agent>\n';
|
||||
|
||||
// Close code fence
|
||||
xml += '```\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full compilation pipeline
|
||||
* @param {string} yamlContent - Raw YAML string
|
||||
* @param {Object} answers - Answers from install_config questions (or defaults)
|
||||
* @param {string} agentName - Optional final agent name (user's custom persona name)
|
||||
* @param {string} targetPath - Optional target path for agent ID
|
||||
* @returns {Object} { xml: string, metadata: Object }
|
||||
*/
|
||||
function compileAgent(yamlContent, answers = {}, agentName = '', targetPath = '') {
|
||||
// Parse YAML
|
||||
const agentYaml = yaml.parse(yamlContent);
|
||||
|
||||
// Inject custom agent name into metadata.name if provided
|
||||
// This is the user's chosen persona name (e.g., "Fred" instead of "Inkwell Von Comitizen")
|
||||
if (agentName && agentYaml.agent && agentYaml.agent.metadata) {
|
||||
// Convert kebab-case to title case for the name field
|
||||
// e.g., "fred-commit-poet" → "Fred Commit Poet"
|
||||
const titleCaseName = agentName
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
agentYaml.agent.metadata.name = titleCaseName;
|
||||
}
|
||||
|
||||
// Extract install_config
|
||||
const installConfig = extractInstallConfig(agentYaml);
|
||||
|
||||
// Merge defaults with provided answers
|
||||
let finalAnswers = answers;
|
||||
if (installConfig) {
|
||||
const defaults = getDefaultValues(installConfig);
|
||||
finalAnswers = { ...defaults, ...answers };
|
||||
}
|
||||
|
||||
// Process templates with answers
|
||||
const processedYaml = processAgentYaml(agentYaml, finalAnswers);
|
||||
|
||||
// Strip install_config from output
|
||||
const cleanYaml = stripInstallConfig(processedYaml);
|
||||
|
||||
// Compile to XML
|
||||
const xml = compileToXml(cleanYaml, agentName, targetPath);
|
||||
|
||||
return {
|
||||
xml,
|
||||
metadata: cleanYaml.agent.metadata,
|
||||
processedYaml: cleanYaml,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile agent file to .md
|
||||
* @param {string} yamlPath - Path to agent YAML file
|
||||
* @param {Object} options - { answers: {}, outputPath: string }
|
||||
* @returns {Object} Compilation result
|
||||
*/
|
||||
function compileAgentFile(yamlPath, options = {}) {
|
||||
const yamlContent = fs.readFileSync(yamlPath, 'utf8');
|
||||
const result = compileAgent(yamlContent, options.answers || {});
|
||||
|
||||
// Determine output path
|
||||
let outputPath = options.outputPath;
|
||||
if (!outputPath) {
|
||||
// Default: same directory, same name, .md extension
|
||||
const dir = path.dirname(yamlPath);
|
||||
const basename = path.basename(yamlPath, '.agent.yaml');
|
||||
outputPath = path.join(dir, `${basename}.md`);
|
||||
}
|
||||
|
||||
// Write compiled XML
|
||||
fs.writeFileSync(outputPath, result.xml, 'utf8');
|
||||
|
||||
return {
|
||||
...result,
|
||||
outputPath,
|
||||
sourcePath: yamlPath,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compileToXml,
|
||||
compileAgent,
|
||||
compileAgentFile,
|
||||
escapeXml,
|
||||
buildFrontmatter,
|
||||
buildSimpleActivation,
|
||||
buildPersonaXml,
|
||||
buildPromptsXml,
|
||||
buildMenuXml,
|
||||
};
|
||||
725
tools/cli/lib/agent/installer.js
Normal file
725
tools/cli/lib/agent/installer.js
Normal file
@@ -0,0 +1,725 @@
|
||||
/**
|
||||
* BMAD Agent Installer
|
||||
* Discovers, prompts, compiles, and installs agents
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const yaml = require('yaml');
|
||||
const readline = require('node:readline');
|
||||
const { compileAgent, compileAgentFile } = require('./compiler');
|
||||
const { extractInstallConfig, getDefaultValues } = require('./template-engine');
|
||||
|
||||
/**
|
||||
* Find BMAD config file in project
|
||||
* @param {string} startPath - Starting directory to search from
|
||||
* @returns {Object|null} Config data or null
|
||||
*/
|
||||
function findBmadConfig(startPath = process.cwd()) {
|
||||
// Look for common BMAD folder names
|
||||
const possibleNames = ['.bmad', 'bmad', '.bmad-method'];
|
||||
|
||||
for (const name of possibleNames) {
|
||||
const configPath = path.join(startPath, name, 'bmb', 'config.yaml');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const config = yaml.parse(content);
|
||||
return {
|
||||
...config,
|
||||
bmadFolder: path.join(startPath, name),
|
||||
projectRoot: startPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve path variables like {project-root} and {bmad-folder}
|
||||
* @param {string} pathStr - Path with variables
|
||||
* @param {Object} context - Contains projectRoot, bmadFolder
|
||||
* @returns {string} Resolved path
|
||||
*/
|
||||
function resolvePath(pathStr, context) {
|
||||
return pathStr.replaceAll('{project-root}', context.projectRoot).replaceAll('{bmad-folder}', context.bmadFolder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover available agents in the custom agent location
|
||||
* @param {string} searchPath - Path to search for agents
|
||||
* @returns {Array} List of agent info objects
|
||||
*/
|
||||
function discoverAgents(searchPath) {
|
||||
if (!fs.existsSync(searchPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const agents = [];
|
||||
const entries = fs.readdirSync(searchPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(searchPath, entry.name);
|
||||
|
||||
if (entry.isFile() && entry.name.endsWith('.agent.yaml')) {
|
||||
// Simple agent (single file)
|
||||
agents.push({
|
||||
type: 'simple',
|
||||
name: entry.name.replace('.agent.yaml', ''),
|
||||
path: fullPath,
|
||||
yamlFile: fullPath,
|
||||
});
|
||||
} else if (entry.isDirectory()) {
|
||||
// Check for agent with sidecar (folder containing .agent.yaml)
|
||||
const yamlFiles = fs.readdirSync(fullPath).filter((f) => f.endsWith('.agent.yaml'));
|
||||
if (yamlFiles.length === 1) {
|
||||
const agentYamlPath = path.join(fullPath, yamlFiles[0]);
|
||||
agents.push({
|
||||
type: 'expert',
|
||||
name: entry.name,
|
||||
path: fullPath,
|
||||
yamlFile: agentYamlPath,
|
||||
hasSidecar: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load agent YAML and extract install_config
|
||||
* @param {string} yamlPath - Path to agent YAML file
|
||||
* @returns {Object} Agent YAML and install config
|
||||
*/
|
||||
function loadAgentConfig(yamlPath) {
|
||||
const content = fs.readFileSync(yamlPath, 'utf8');
|
||||
const agentYaml = yaml.parse(content);
|
||||
const installConfig = extractInstallConfig(agentYaml);
|
||||
const defaults = installConfig ? getDefaultValues(installConfig) : {};
|
||||
|
||||
// Check for saved_answers (from previously installed custom agents)
|
||||
// These take precedence over defaults
|
||||
const savedAnswers = agentYaml?.saved_answers || {};
|
||||
|
||||
return {
|
||||
yamlContent: content,
|
||||
agentYaml,
|
||||
installConfig,
|
||||
defaults: { ...defaults, ...savedAnswers }, // saved_answers override defaults
|
||||
metadata: agentYaml?.agent?.metadata || {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive prompt for install_config questions
|
||||
* @param {Object} installConfig - Install configuration with questions
|
||||
* @param {Object} defaults - Default values
|
||||
* @returns {Promise<Object>} User answers
|
||||
*/
|
||||
async function promptInstallQuestions(installConfig, defaults, presetAnswers = {}) {
|
||||
if (!installConfig || !installConfig.questions || installConfig.questions.length === 0) {
|
||||
return { ...defaults, ...presetAnswers };
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const question = (prompt) =>
|
||||
new Promise((resolve) => {
|
||||
rl.question(prompt, resolve);
|
||||
});
|
||||
|
||||
const answers = { ...defaults, ...presetAnswers };
|
||||
|
||||
console.log('\n📝 Agent Configuration\n');
|
||||
if (installConfig.description) {
|
||||
console.log(` ${installConfig.description}\n`);
|
||||
}
|
||||
|
||||
for (const q of installConfig.questions) {
|
||||
// Skip questions for variables that are already set (e.g., custom_name set upfront)
|
||||
if (answers[q.var] !== undefined && answers[q.var] !== defaults[q.var]) {
|
||||
console.log(chalk.dim(` ${q.var}: ${answers[q.var]} (already set)`));
|
||||
continue;
|
||||
}
|
||||
|
||||
let response;
|
||||
|
||||
switch (q.type) {
|
||||
case 'text': {
|
||||
const defaultHint = q.default ? ` (default: ${q.default})` : '';
|
||||
response = await question(` ${q.prompt}${defaultHint}: `);
|
||||
answers[q.var] = response || q.default || '';
|
||||
|
||||
break;
|
||||
}
|
||||
case 'boolean': {
|
||||
const defaultHint = q.default ? ' [Y/n]' : ' [y/N]';
|
||||
response = await question(` ${q.prompt}${defaultHint}: `);
|
||||
if (response === '') {
|
||||
answers[q.var] = q.default;
|
||||
} else {
|
||||
answers[q.var] = response.toLowerCase().startsWith('y');
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'choice': {
|
||||
console.log(` ${q.prompt}`);
|
||||
for (const [idx, opt] of q.options.entries()) {
|
||||
const marker = opt.value === q.default ? '* ' : ' ';
|
||||
console.log(` ${marker}${idx + 1}. ${opt.label}`);
|
||||
}
|
||||
const defaultIdx = q.options.findIndex((o) => o.value === q.default) + 1;
|
||||
let validChoice = false;
|
||||
let choiceIdx;
|
||||
while (!validChoice) {
|
||||
response = await question(` Choice (default: ${defaultIdx}): `);
|
||||
if (response) {
|
||||
choiceIdx = parseInt(response, 10) - 1;
|
||||
if (isNaN(choiceIdx) || choiceIdx < 0 || choiceIdx >= q.options.length) {
|
||||
console.log(` Invalid choice. Please enter 1-${q.options.length}`);
|
||||
} else {
|
||||
validChoice = true;
|
||||
}
|
||||
} else {
|
||||
choiceIdx = defaultIdx - 1;
|
||||
validChoice = true;
|
||||
}
|
||||
}
|
||||
answers[q.var] = q.options[choiceIdx].value;
|
||||
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
}
|
||||
|
||||
rl.close();
|
||||
return answers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a compiled agent to target location
|
||||
* @param {Object} agentInfo - Agent discovery info
|
||||
* @param {Object} answers - User answers for install_config
|
||||
* @param {string} targetPath - Target installation directory
|
||||
* @returns {Object} Installation result
|
||||
*/
|
||||
function installAgent(agentInfo, answers, targetPath) {
|
||||
// Compile the agent
|
||||
const { xml, metadata, processedYaml } = compileAgent(fs.readFileSync(agentInfo.yamlFile, 'utf8'), answers);
|
||||
|
||||
// Determine target agent folder name
|
||||
const agentFolderName = metadata.name ? metadata.name.toLowerCase().replaceAll(/\s+/g, '-') : agentInfo.name;
|
||||
|
||||
const agentTargetDir = path.join(targetPath, agentFolderName);
|
||||
|
||||
// Create target directory
|
||||
if (!fs.existsSync(agentTargetDir)) {
|
||||
fs.mkdirSync(agentTargetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write compiled XML (.md)
|
||||
const compiledFileName = `${agentFolderName}.md`;
|
||||
const compiledPath = path.join(agentTargetDir, compiledFileName);
|
||||
fs.writeFileSync(compiledPath, xml, 'utf8');
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
agentName: metadata.name || agentInfo.name,
|
||||
targetDir: agentTargetDir,
|
||||
compiledFile: compiledPath,
|
||||
sidecarCopied: false,
|
||||
};
|
||||
|
||||
// Copy sidecar files for expert agents
|
||||
if (agentInfo.hasSidecar && agentInfo.type === 'expert') {
|
||||
const sidecarFiles = copySidecarFiles(agentInfo.path, agentTargetDir, agentInfo.yamlFile);
|
||||
result.sidecarCopied = true;
|
||||
result.sidecarFiles = sidecarFiles;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copy sidecar files (everything except the .agent.yaml)
|
||||
* @param {string} sourceDir - Source agent directory
|
||||
* @param {string} targetDir - Target agent directory
|
||||
* @param {string} excludeYaml - The .agent.yaml file to exclude
|
||||
* @returns {Array} List of copied files
|
||||
*/
|
||||
function copySidecarFiles(sourceDir, targetDir, excludeYaml) {
|
||||
const copied = [];
|
||||
|
||||
function copyDir(src, dest) {
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(src, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
|
||||
// Skip the source YAML file
|
||||
if (srcPath === excludeYaml) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
copyDir(srcPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
copied.push(destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
copyDir(sourceDir, targetDir);
|
||||
return copied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update agent metadata ID to reflect installed location
|
||||
* @param {string} compiledContent - Compiled XML content
|
||||
* @param {string} targetPath - Target installation path relative to project
|
||||
* @returns {string} Updated content
|
||||
*/
|
||||
function updateAgentId(compiledContent, targetPath) {
|
||||
// Update the id attribute in the opening agent tag
|
||||
return compiledContent.replace(/(<agent\s+id=")[^"]*(")/, `$1${targetPath}$2`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a path is within a BMAD project
|
||||
* @param {string} targetPath - Path to check
|
||||
* @returns {Object|null} Project info with bmadFolder and cfgFolder
|
||||
*/
|
||||
function detectBmadProject(targetPath) {
|
||||
let checkPath = path.resolve(targetPath);
|
||||
const root = path.parse(checkPath).root;
|
||||
|
||||
// Walk up directory tree looking for BMAD installation
|
||||
while (checkPath !== root) {
|
||||
const possibleNames = ['.bmad', 'bmad'];
|
||||
for (const name of possibleNames) {
|
||||
const bmadFolder = path.join(checkPath, name);
|
||||
const cfgFolder = path.join(bmadFolder, '_cfg');
|
||||
const manifestFile = path.join(cfgFolder, 'agent-manifest.csv');
|
||||
|
||||
if (fs.existsSync(manifestFile)) {
|
||||
return {
|
||||
projectRoot: checkPath,
|
||||
bmadFolder,
|
||||
cfgFolder,
|
||||
manifestFile,
|
||||
};
|
||||
}
|
||||
}
|
||||
checkPath = path.dirname(checkPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape CSV field value
|
||||
* @param {string} value - Value to escape
|
||||
* @returns {string} Escaped value
|
||||
*/
|
||||
function escapeCsvField(value) {
|
||||
if (typeof value !== 'string') value = String(value);
|
||||
// If contains comma, quote, or newline, wrap in quotes and escape internal quotes
|
||||
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||||
return '"' + value.replaceAll('"', '""') + '"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CSV line respecting quoted fields
|
||||
* @param {string} line - CSV line
|
||||
* @returns {Array} Parsed fields
|
||||
*/
|
||||
function parseCsvLine(line) {
|
||||
const fields = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
const nextChar = line[i + 1];
|
||||
|
||||
if (char === '"' && !inQuotes) {
|
||||
inQuotes = true;
|
||||
} else if (char === '"' && inQuotes) {
|
||||
if (nextChar === '"') {
|
||||
current += '"';
|
||||
i++; // Skip escaped quote
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
fields.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
fields.push(current);
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if agent name exists in manifest
|
||||
* @param {string} manifestFile - Path to agent-manifest.csv
|
||||
* @param {string} agentName - Agent name to check
|
||||
* @returns {Object|null} Existing entry or null
|
||||
*/
|
||||
function checkManifestForAgent(manifestFile, agentName) {
|
||||
const content = fs.readFileSync(manifestFile, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
if (lines.length < 2) return null;
|
||||
|
||||
const header = parseCsvLine(lines[0]);
|
||||
const nameIndex = header.indexOf('name');
|
||||
|
||||
if (nameIndex === -1) return null;
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const fields = parseCsvLine(lines[i]);
|
||||
if (fields[nameIndex] === agentName) {
|
||||
const entry = {};
|
||||
for (const [idx, col] of header.entries()) {
|
||||
entry[col] = fields[idx] || '';
|
||||
}
|
||||
entry._lineNumber = i;
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if agent path exists in manifest
|
||||
* @param {string} manifestFile - Path to agent-manifest.csv
|
||||
* @param {string} agentPath - Agent path to check
|
||||
* @returns {Object|null} Existing entry or null
|
||||
*/
|
||||
function checkManifestForPath(manifestFile, agentPath) {
|
||||
const content = fs.readFileSync(manifestFile, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
if (lines.length < 2) return null;
|
||||
|
||||
const header = parseCsvLine(lines[0]);
|
||||
const pathIndex = header.indexOf('path');
|
||||
|
||||
if (pathIndex === -1) return null;
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const fields = parseCsvLine(lines[i]);
|
||||
if (fields[pathIndex] === agentPath) {
|
||||
const entry = {};
|
||||
for (const [idx, col] of header.entries()) {
|
||||
entry[col] = fields[idx] || '';
|
||||
}
|
||||
entry._lineNumber = i;
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing entry in manifest
|
||||
* @param {string} manifestFile - Path to agent-manifest.csv
|
||||
* @param {Object} agentData - New agent data
|
||||
* @param {number} lineNumber - Line number to replace (1-indexed, excluding header)
|
||||
* @returns {boolean} Success
|
||||
*/
|
||||
function updateManifestEntry(manifestFile, agentData, lineNumber) {
|
||||
const content = fs.readFileSync(manifestFile, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
const header = lines[0];
|
||||
const columns = header.split(',');
|
||||
|
||||
// Build the new row
|
||||
const row = columns.map((col) => {
|
||||
const value = agentData[col] || '';
|
||||
return escapeCsvField(value);
|
||||
});
|
||||
|
||||
// Replace the line
|
||||
lines[lineNumber] = row.join(',');
|
||||
|
||||
fs.writeFileSync(manifestFile, lines.join('\n') + '\n', 'utf8');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add agent to manifest CSV
|
||||
* @param {string} manifestFile - Path to agent-manifest.csv
|
||||
* @param {Object} agentData - Agent metadata and path info
|
||||
* @returns {boolean} Success
|
||||
*/
|
||||
function addToManifest(manifestFile, agentData) {
|
||||
const content = fs.readFileSync(manifestFile, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
// Parse header to understand column order
|
||||
const header = lines[0];
|
||||
const columns = header.split(',');
|
||||
|
||||
// Build the new row based on header columns
|
||||
const row = columns.map((col) => {
|
||||
const value = agentData[col] || '';
|
||||
return escapeCsvField(value);
|
||||
});
|
||||
|
||||
// Append new row
|
||||
const newLine = row.join(',');
|
||||
const updatedContent = content.trim() + '\n' + newLine + '\n';
|
||||
|
||||
fs.writeFileSync(manifestFile, updatedContent, 'utf8');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save agent source YAML to _cfg/custom/agents/ for reinstallation
|
||||
* Stores user answers in a top-level saved_answers section (cleaner than overwriting defaults)
|
||||
* @param {Object} agentInfo - Agent info (path, type, etc.)
|
||||
* @param {string} cfgFolder - Path to _cfg folder
|
||||
* @param {string} agentName - Final agent name (e.g., "fred-commit-poet")
|
||||
* @param {Object} answers - User answers to save for reinstallation
|
||||
* @returns {Object} Info about saved source
|
||||
*/
|
||||
function saveAgentSource(agentInfo, cfgFolder, agentName, answers = {}) {
|
||||
// Save to _cfg/custom/agents/ instead of _cfg/agents/
|
||||
const customAgentsCfgDir = path.join(cfgFolder, 'custom', 'agents');
|
||||
|
||||
if (!fs.existsSync(customAgentsCfgDir)) {
|
||||
fs.mkdirSync(customAgentsCfgDir, { recursive: true });
|
||||
}
|
||||
|
||||
const yamlLib = require('yaml');
|
||||
|
||||
/**
|
||||
* Add saved_answers section to store user's actual answers
|
||||
*/
|
||||
function addSavedAnswers(agentYaml, answers) {
|
||||
// Store answers in a clear, separate section
|
||||
agentYaml.saved_answers = answers;
|
||||
return agentYaml;
|
||||
}
|
||||
|
||||
if (agentInfo.type === 'simple') {
|
||||
// Simple agent: copy YAML with saved_answers section
|
||||
const targetYaml = path.join(customAgentsCfgDir, `${agentName}.agent.yaml`);
|
||||
const originalContent = fs.readFileSync(agentInfo.yamlFile, 'utf8');
|
||||
const agentYaml = yamlLib.parse(originalContent);
|
||||
|
||||
// Add saved_answers section with user's choices
|
||||
addSavedAnswers(agentYaml, answers);
|
||||
|
||||
fs.writeFileSync(targetYaml, yamlLib.stringify(agentYaml), 'utf8');
|
||||
return { type: 'simple', path: targetYaml };
|
||||
} else {
|
||||
// Expert agent with sidecar: copy entire folder with saved_answers
|
||||
const targetFolder = path.join(customAgentsCfgDir, agentName);
|
||||
if (!fs.existsSync(targetFolder)) {
|
||||
fs.mkdirSync(targetFolder, { recursive: true });
|
||||
}
|
||||
|
||||
// Copy YAML and entire sidecar structure
|
||||
const sourceDir = agentInfo.path;
|
||||
const copied = [];
|
||||
|
||||
function copyDir(src, dest) {
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(src, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
copyDir(srcPath, destPath);
|
||||
} else if (entry.name.endsWith('.agent.yaml')) {
|
||||
// For the agent YAML, add saved_answers section
|
||||
const originalContent = fs.readFileSync(srcPath, 'utf8');
|
||||
const agentYaml = yamlLib.parse(originalContent);
|
||||
addSavedAnswers(agentYaml, answers);
|
||||
// Rename YAML to match final agent name
|
||||
const newYamlPath = path.join(dest, `${agentName}.agent.yaml`);
|
||||
fs.writeFileSync(newYamlPath, yamlLib.stringify(agentYaml), 'utf8');
|
||||
copied.push(newYamlPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
copied.push(destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
copyDir(sourceDir, targetFolder);
|
||||
return { type: 'expert', path: targetFolder, files: copied };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create IDE slash command wrapper for agent
|
||||
* Leverages IdeManager to dispatch to IDE-specific handlers
|
||||
* @param {string} projectRoot - Project root path
|
||||
* @param {string} agentName - Agent name (e.g., "commit-poet")
|
||||
* @param {string} agentPath - Path to compiled agent (relative to project root)
|
||||
* @param {Object} metadata - Agent metadata
|
||||
* @returns {Promise<Object>} Info about created slash commands
|
||||
*/
|
||||
async function createIdeSlashCommands(projectRoot, agentName, agentPath, metadata) {
|
||||
// Read manifest.yaml to get installed IDEs
|
||||
const manifestPath = path.join(projectRoot, '.bmad', '_cfg', 'manifest.yaml');
|
||||
let installedIdes = ['claude-code']; // Default to Claude Code if no manifest
|
||||
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const yamlLib = require('yaml');
|
||||
const manifestContent = fs.readFileSync(manifestPath, 'utf8');
|
||||
const manifest = yamlLib.parse(manifestContent);
|
||||
if (manifest.ides && Array.isArray(manifest.ides)) {
|
||||
installedIdes = manifest.ides;
|
||||
}
|
||||
}
|
||||
|
||||
// Use IdeManager to install custom agent launchers for all configured IDEs
|
||||
const { IdeManager } = require('../../installers/lib/ide/manager');
|
||||
const ideManager = new IdeManager();
|
||||
|
||||
const results = await ideManager.installCustomAgentLaunchers(installedIdes, projectRoot, agentName, agentPath, metadata);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update manifest.yaml to track custom agent
|
||||
* @param {string} manifestPath - Path to manifest.yaml
|
||||
* @param {string} agentName - Agent name
|
||||
* @param {string} agentType - Agent type (source name)
|
||||
* @returns {boolean} Success
|
||||
*/
|
||||
function updateManifestYaml(manifestPath, agentName, agentType) {
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const yamlLib = require('yaml');
|
||||
const content = fs.readFileSync(manifestPath, 'utf8');
|
||||
const manifest = yamlLib.parse(content);
|
||||
|
||||
// Initialize custom_agents array if not exists
|
||||
if (!manifest.custom_agents) {
|
||||
manifest.custom_agents = [];
|
||||
}
|
||||
|
||||
// Check if this agent is already registered
|
||||
const existingIndex = manifest.custom_agents.findIndex((a) => a.name === agentName || (typeof a === 'string' && a === agentName));
|
||||
|
||||
const agentEntry = {
|
||||
name: agentName,
|
||||
type: agentType,
|
||||
installed: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (existingIndex === -1) {
|
||||
// Add new entry
|
||||
manifest.custom_agents.push(agentEntry);
|
||||
} else {
|
||||
// Update existing entry
|
||||
manifest.custom_agents[existingIndex] = agentEntry;
|
||||
}
|
||||
|
||||
// Update lastUpdated timestamp
|
||||
if (manifest.installation) {
|
||||
manifest.installation.lastUpdated = new Date().toISOString();
|
||||
}
|
||||
|
||||
// Write back
|
||||
const newContent = yamlLib.stringify(manifest);
|
||||
fs.writeFileSync(manifestPath, newContent, 'utf8');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract manifest data from compiled agent XML
|
||||
* @param {string} xmlContent - Compiled agent XML
|
||||
* @param {Object} metadata - Agent metadata from YAML
|
||||
* @param {string} agentPath - Relative path to agent file
|
||||
* @param {string} moduleName - Module name (default: 'custom')
|
||||
* @returns {Object} Manifest row data
|
||||
*/
|
||||
function extractManifestData(xmlContent, metadata, agentPath, moduleName = 'custom') {
|
||||
// Extract data from XML using regex (simple parsing)
|
||||
const extractTag = (tag) => {
|
||||
const match = xmlContent.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`));
|
||||
if (!match) return '';
|
||||
// Collapse multiple lines into single line, normalize whitespace
|
||||
return match[1].trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
const extractPrinciples = () => {
|
||||
const match = xmlContent.match(/<principles>([\s\S]*?)<\/principles>/);
|
||||
if (!match) return '';
|
||||
// Extract individual principle lines
|
||||
const principles = match[1]
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0)
|
||||
.join(' ');
|
||||
return principles;
|
||||
};
|
||||
|
||||
return {
|
||||
name: metadata.id ? path.basename(metadata.id, '.md') : metadata.name.toLowerCase().replaceAll(/\s+/g, '-'),
|
||||
displayName: metadata.name || '',
|
||||
title: metadata.title || '',
|
||||
icon: metadata.icon || '',
|
||||
role: extractTag('role'),
|
||||
identity: extractTag('identity'),
|
||||
communicationStyle: extractTag('communication_style'),
|
||||
principles: extractPrinciples(),
|
||||
module: moduleName,
|
||||
path: agentPath,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findBmadConfig,
|
||||
resolvePath,
|
||||
discoverAgents,
|
||||
loadAgentConfig,
|
||||
promptInstallQuestions,
|
||||
installAgent,
|
||||
copySidecarFiles,
|
||||
updateAgentId,
|
||||
detectBmadProject,
|
||||
addToManifest,
|
||||
extractManifestData,
|
||||
escapeCsvField,
|
||||
checkManifestForAgent,
|
||||
checkManifestForPath,
|
||||
updateManifestEntry,
|
||||
saveAgentSource,
|
||||
createIdeSlashCommands,
|
||||
updateManifestYaml,
|
||||
};
|
||||
152
tools/cli/lib/agent/template-engine.js
Normal file
152
tools/cli/lib/agent/template-engine.js
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Template Engine for BMAD Agent Install Configuration
|
||||
* Processes {{variable}}, {{#if}}, {{#unless}}, and {{/if}} blocks
|
||||
*/
|
||||
|
||||
/**
|
||||
* Process all template syntax in a string
|
||||
* @param {string} content - Content with template syntax
|
||||
* @param {Object} variables - Key-value pairs from install_config answers
|
||||
* @returns {string} Processed content
|
||||
*/
|
||||
function processTemplate(content, variables = {}) {
|
||||
let result = content;
|
||||
|
||||
// Process conditionals first (they may contain variables)
|
||||
result = processConditionals(result, variables);
|
||||
|
||||
// Then process simple variable replacements
|
||||
result = processVariables(result, variables);
|
||||
|
||||
// Clean up any empty lines left by removed conditionals
|
||||
result = cleanupEmptyLines(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process {{#if}}, {{#unless}}, {{/if}}, {{/unless}} blocks
|
||||
*/
|
||||
function processConditionals(content, variables) {
|
||||
let result = content;
|
||||
|
||||
// Process {{#if variable == "value"}} blocks
|
||||
// Handle both regular quotes and JSON-escaped quotes (\")
|
||||
const ifEqualsPattern = /\{\{#if\s+(\w+)\s*==\s*\\?"([^"\\]+)\\?"\s*\}\}([\s\S]*?)\{\{\/if\}\}/g;
|
||||
result = result.replaceAll(ifEqualsPattern, (match, varName, value, block) => {
|
||||
return variables[varName] === value ? block : '';
|
||||
});
|
||||
|
||||
// Process {{#if variable}} blocks (boolean or truthy check)
|
||||
const ifBoolPattern = /\{\{#if\s+(\w+)\s*\}\}([\s\S]*?)\{\{\/if\}\}/g;
|
||||
result = result.replaceAll(ifBoolPattern, (match, varName, block) => {
|
||||
const val = variables[varName];
|
||||
// Treat as truthy: true, non-empty string, non-zero number
|
||||
const isTruthy = val === true || (typeof val === 'string' && val.length > 0) || (typeof val === 'number' && val !== 0);
|
||||
return isTruthy ? block : '';
|
||||
});
|
||||
|
||||
// Process {{#unless variable}} blocks (inverse of if)
|
||||
const unlessPattern = /\{\{#unless\s+(\w+)\s*\}\}([\s\S]*?)\{\{\/unless\}\}/g;
|
||||
result = result.replaceAll(unlessPattern, (match, varName, block) => {
|
||||
const val = variables[varName];
|
||||
const isFalsy = val === false || val === '' || val === null || val === undefined || val === 0;
|
||||
return isFalsy ? block : '';
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process {{variable}} replacements
|
||||
*/
|
||||
function processVariables(content, variables) {
|
||||
let result = content;
|
||||
|
||||
// Replace {{variable}} with value
|
||||
const varPattern = /\{\{(\w+)\}\}/g;
|
||||
result = result.replaceAll(varPattern, (match, varName) => {
|
||||
if (Object.hasOwn(variables, varName)) {
|
||||
return String(variables[varName]);
|
||||
}
|
||||
// If variable not found, leave as-is (might be runtime variable like {user_name})
|
||||
return match;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up excessive empty lines left after removing conditional blocks
|
||||
*/
|
||||
function cleanupEmptyLines(content) {
|
||||
// Replace 3+ consecutive newlines with 2
|
||||
return content.replaceAll(/\n{3,}/g, '\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract install_config from agent YAML object
|
||||
* @param {Object} agentYaml - Parsed agent YAML
|
||||
* @returns {Object|null} install_config section or null
|
||||
*/
|
||||
function extractInstallConfig(agentYaml) {
|
||||
return agentYaml?.agent?.install_config || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove install_config from agent YAML (after processing)
|
||||
* @param {Object} agentYaml - Parsed agent YAML
|
||||
* @returns {Object} Agent YAML without install_config
|
||||
*/
|
||||
function stripInstallConfig(agentYaml) {
|
||||
const result = structuredClone(agentYaml);
|
||||
if (result.agent) {
|
||||
delete result.agent.install_config;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process entire agent YAML object with template variables
|
||||
* @param {Object} agentYaml - Parsed agent YAML
|
||||
* @param {Object} variables - Answers from install_config questions
|
||||
* @returns {Object} Processed agent YAML
|
||||
*/
|
||||
function processAgentYaml(agentYaml, variables) {
|
||||
// Convert to JSON string, process templates, parse back
|
||||
const jsonString = JSON.stringify(agentYaml, null, 2);
|
||||
const processed = processTemplate(jsonString, variables);
|
||||
return JSON.parse(processed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default values from install_config questions
|
||||
* @param {Object} installConfig - install_config section
|
||||
* @returns {Object} Default values keyed by variable name
|
||||
*/
|
||||
function getDefaultValues(installConfig) {
|
||||
const defaults = {};
|
||||
|
||||
if (!installConfig?.questions) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
for (const question of installConfig.questions) {
|
||||
if (question.var && question.default !== undefined) {
|
||||
defaults[question.var] = question.default;
|
||||
}
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
processTemplate,
|
||||
processConditionals,
|
||||
processVariables,
|
||||
extractInstallConfig,
|
||||
stripInstallConfig,
|
||||
processAgentYaml,
|
||||
getDefaultValues,
|
||||
cleanupEmptyLines,
|
||||
};
|
||||
Reference in New Issue
Block a user