761 lines
27 KiB
JavaScript
761 lines
27 KiB
JavaScript
const path = require('node:path');
|
||
const fs = require('fs-extra');
|
||
const yaml = require('yaml');
|
||
const crypto = require('node:crypto');
|
||
const csv = require('csv-parse/sync');
|
||
const { getSourcePath, getModulePath } = require('../project-root');
|
||
const prompts = require('../prompts');
|
||
const {
|
||
loadSkillManifest: loadSkillManifestShared,
|
||
getCanonicalId: getCanonicalIdShared,
|
||
getArtifactType: getArtifactTypeShared,
|
||
getInstallToBmad: getInstallToBmadShared,
|
||
} = require('../ide/shared/skill-manifest');
|
||
|
||
// Load package.json for version info
|
||
const packageJson = require('../../../package.json');
|
||
|
||
/**
|
||
* Generates manifest files for installed skills and agents
|
||
*/
|
||
class ManifestGenerator {
|
||
constructor() {
|
||
this.skills = [];
|
||
this.agents = [];
|
||
this.modules = [];
|
||
this.files = [];
|
||
this.selectedIdes = [];
|
||
}
|
||
|
||
/** Delegate to shared skill-manifest module */
|
||
async loadSkillManifest(dirPath) {
|
||
return loadSkillManifestShared(dirPath);
|
||
}
|
||
|
||
/** Delegate to shared skill-manifest module */
|
||
getCanonicalId(manifest, filename) {
|
||
return getCanonicalIdShared(manifest, filename);
|
||
}
|
||
|
||
/** Delegate to shared skill-manifest module */
|
||
getArtifactType(manifest, filename) {
|
||
return getArtifactTypeShared(manifest, filename);
|
||
}
|
||
|
||
/** Delegate to shared skill-manifest module */
|
||
getInstallToBmad(manifest, filename) {
|
||
return getInstallToBmadShared(manifest, filename);
|
||
}
|
||
|
||
/**
|
||
* Clean text for CSV output by normalizing whitespace.
|
||
* Note: Quote escaping is handled by escapeCsv() at write time.
|
||
* @param {string} text - Text to clean
|
||
* @returns {string} Cleaned text
|
||
*/
|
||
cleanForCSV(text) {
|
||
if (!text) return '';
|
||
return text.trim().replaceAll(/\s+/g, ' '); // Normalize all whitespace (including newlines) to single space
|
||
}
|
||
|
||
/**
|
||
* Generate all manifests for the installation
|
||
* @param {string} bmadDir - _bmad
|
||
* @param {Array} selectedModules - Selected modules for installation
|
||
* @param {Array} installedFiles - All installed files (optional, for hash tracking)
|
||
*/
|
||
async generateManifests(bmadDir, selectedModules, installedFiles = [], options = {}) {
|
||
// Create _config directory if it doesn't exist
|
||
const cfgDir = path.join(bmadDir, '_config');
|
||
await fs.ensureDir(cfgDir);
|
||
|
||
// Store modules list (all modules including preserved ones)
|
||
const preservedModules = options.preservedModules || [];
|
||
|
||
// Scan the bmad directory to find all actually installed modules
|
||
const installedModules = await this.scanInstalledModules(bmadDir);
|
||
|
||
// Since custom modules are now installed the same way as regular modules,
|
||
// we don't need to exclude them from manifest generation
|
||
const allModules = [...new Set(['core', ...selectedModules, ...preservedModules, ...installedModules])];
|
||
|
||
this.modules = allModules;
|
||
this.updatedModules = allModules; // Include ALL modules (including custom) for scanning
|
||
|
||
this.bmadDir = bmadDir;
|
||
this.bmadFolderName = path.basename(bmadDir); // Get the actual folder name (e.g., '_bmad' or 'bmad')
|
||
this.allInstalledFiles = installedFiles;
|
||
|
||
if (!Object.prototype.hasOwnProperty.call(options, 'ides')) {
|
||
throw new Error('ManifestGenerator requires `options.ides` to be provided – installer should supply the selected IDEs array.');
|
||
}
|
||
|
||
const resolvedIdes = options.ides ?? [];
|
||
if (!Array.isArray(resolvedIdes)) {
|
||
throw new TypeError('ManifestGenerator expected `options.ides` to be an array.');
|
||
}
|
||
|
||
// Filter out any undefined/null values from IDE list
|
||
this.selectedIdes = resolvedIdes.filter((ide) => ide && typeof ide === 'string');
|
||
|
||
// Reset files list (defensive: prevent stale data if instance is reused)
|
||
this.files = [];
|
||
|
||
// Collect skills first (populates skillClaimedDirs before legacy collectors run)
|
||
await this.collectSkills();
|
||
|
||
// Collect agent data - use updatedModules which includes all installed modules
|
||
await this.collectAgents(this.updatedModules);
|
||
|
||
// Write manifest files and collect their paths
|
||
const manifestFiles = [
|
||
await this.writeMainManifest(cfgDir),
|
||
await this.writeSkillManifest(cfgDir),
|
||
await this.writeAgentManifest(cfgDir),
|
||
await this.writeFilesManifest(cfgDir),
|
||
];
|
||
|
||
return {
|
||
skills: this.skills.length,
|
||
agents: this.agents.length,
|
||
files: this.files.length,
|
||
manifestFiles: manifestFiles,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Recursively walk a module directory tree, collecting native SKILL.md entrypoints.
|
||
* A directory is discovered as a skill when it contains a SKILL.md file with
|
||
* valid name/description frontmatter (name must match directory name).
|
||
* Manifest YAML is loaded only when present — for install_to_bmad and agent metadata.
|
||
* Populates this.skills[] and this.skillClaimedDirs (Set of absolute paths).
|
||
*/
|
||
async collectSkills() {
|
||
this.skills = [];
|
||
this.skillClaimedDirs = new Set();
|
||
const debug = process.env.BMAD_DEBUG_MANIFEST === 'true';
|
||
|
||
for (const moduleName of this.updatedModules) {
|
||
const modulePath = path.join(this.bmadDir, moduleName);
|
||
if (!(await fs.pathExists(modulePath))) continue;
|
||
|
||
// Recursive walk skipping . and _ prefixed dirs
|
||
const walk = async (dir) => {
|
||
let entries;
|
||
try {
|
||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
// SKILL.md with valid frontmatter is the primary discovery gate
|
||
const skillFile = 'SKILL.md';
|
||
const skillMdPath = path.join(dir, skillFile);
|
||
const dirName = path.basename(dir);
|
||
|
||
const skillMeta = await this.parseSkillMd(skillMdPath, dir, dirName, debug);
|
||
|
||
if (skillMeta) {
|
||
// Load manifest when present (for install_to_bmad and agent metadata)
|
||
const manifest = await this.loadSkillManifest(dir);
|
||
const artifactType = this.getArtifactType(manifest, skillFile);
|
||
|
||
// Build path relative from module root (points to SKILL.md — the permanent entrypoint)
|
||
const relativePath = path.relative(modulePath, dir).split(path.sep).join('/');
|
||
const installPath = relativePath
|
||
? `${this.bmadFolderName}/${moduleName}/${relativePath}/${skillFile}`
|
||
: `${this.bmadFolderName}/${moduleName}/${skillFile}`;
|
||
|
||
// Native SKILL.md entrypoints derive canonicalId from directory name.
|
||
// Agent entrypoints may keep canonicalId metadata for compatibility, so
|
||
// only warn for non-agent SKILL.md directories.
|
||
if (manifest && manifest.__single && manifest.__single.canonicalId && artifactType !== 'agent') {
|
||
console.warn(
|
||
`Warning: Native entrypoint manifest at ${dir}/bmad-skill-manifest.yaml contains canonicalId — this field is ignored for SKILL.md directories (directory name is the canonical ID)`,
|
||
);
|
||
}
|
||
const canonicalId = dirName;
|
||
|
||
this.skills.push({
|
||
name: skillMeta.name,
|
||
description: this.cleanForCSV(skillMeta.description),
|
||
module: moduleName,
|
||
path: installPath,
|
||
canonicalId,
|
||
install_to_bmad: this.getInstallToBmad(manifest, skillFile),
|
||
});
|
||
|
||
// Add to files list
|
||
this.files.push({
|
||
type: 'skill',
|
||
name: skillMeta.name,
|
||
module: moduleName,
|
||
path: installPath,
|
||
});
|
||
|
||
this.skillClaimedDirs.add(dir);
|
||
|
||
if (debug) {
|
||
console.log(`[DEBUG] collectSkills: claimed skill "${skillMeta.name}" as ${canonicalId} at ${dir}`);
|
||
}
|
||
}
|
||
|
||
// Recurse into subdirectories
|
||
for (const entry of entries) {
|
||
if (!entry.isDirectory()) continue;
|
||
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
|
||
await walk(path.join(dir, entry.name));
|
||
}
|
||
};
|
||
|
||
await walk(modulePath);
|
||
}
|
||
|
||
if (debug) {
|
||
console.log(`[DEBUG] collectSkills: total skills found: ${this.skills.length}, claimed dirs: ${this.skillClaimedDirs.size}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Parse and validate SKILL.md for a skill directory.
|
||
* Returns parsed frontmatter object with name/description, or null if invalid.
|
||
* @param {string} skillMdPath - Absolute path to SKILL.md
|
||
* @param {string} dir - Skill directory path (for error messages)
|
||
* @param {string} dirName - Expected name (must match frontmatter name)
|
||
* @param {boolean} debug - Whether to emit debug-level messages
|
||
* @returns {Promise<Object|null>} Parsed frontmatter or null
|
||
*/
|
||
async parseSkillMd(skillMdPath, dir, dirName, debug = false) {
|
||
if (!(await fs.pathExists(skillMdPath))) {
|
||
if (debug) console.log(`[DEBUG] parseSkillMd: "${dir}" is missing SKILL.md — skipping`);
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const rawContent = await fs.readFile(skillMdPath, 'utf8');
|
||
const content = rawContent.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
||
|
||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||
if (frontmatterMatch) {
|
||
const skillMeta = yaml.parse(frontmatterMatch[1]);
|
||
|
||
if (
|
||
!skillMeta ||
|
||
typeof skillMeta !== 'object' ||
|
||
typeof skillMeta.name !== 'string' ||
|
||
typeof skillMeta.description !== 'string' ||
|
||
!skillMeta.name ||
|
||
!skillMeta.description
|
||
) {
|
||
if (debug) console.log(`[DEBUG] parseSkillMd: SKILL.md in "${dir}" is missing name or description (or wrong type) — skipping`);
|
||
return null;
|
||
}
|
||
|
||
if (skillMeta.name !== dirName) {
|
||
console.error(`Error: SKILL.md name "${skillMeta.name}" does not match directory name "${dirName}" — skipping`);
|
||
return null;
|
||
}
|
||
|
||
return skillMeta;
|
||
}
|
||
|
||
if (debug) console.log(`[DEBUG] parseSkillMd: SKILL.md in "${dir}" has no frontmatter — skipping`);
|
||
return null;
|
||
} catch (error) {
|
||
if (debug) console.log(`[DEBUG] parseSkillMd: failed to parse SKILL.md in "${dir}": ${error.message} — skipping`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Collect all agents from core and selected modules
|
||
* Scans the INSTALLED bmad directory, not the source
|
||
*/
|
||
async collectAgents(selectedModules) {
|
||
this.agents = [];
|
||
|
||
// Use updatedModules which already includes deduplicated 'core' + selectedModules
|
||
for (const moduleName of this.updatedModules) {
|
||
const agentsPath = path.join(this.bmadDir, moduleName, 'agents');
|
||
|
||
if (await fs.pathExists(agentsPath)) {
|
||
const moduleAgents = await this.getAgentsFromDir(agentsPath, moduleName);
|
||
this.agents.push(...moduleAgents);
|
||
}
|
||
}
|
||
|
||
// Get standalone agents from bmad/agents/ directory
|
||
const standaloneAgentsDir = path.join(this.bmadDir, 'agents');
|
||
if (await fs.pathExists(standaloneAgentsDir)) {
|
||
const agentDirs = await fs.readdir(standaloneAgentsDir, { withFileTypes: true });
|
||
|
||
for (const agentDir of agentDirs) {
|
||
if (!agentDir.isDirectory()) continue;
|
||
|
||
const agentDirPath = path.join(standaloneAgentsDir, agentDir.name);
|
||
const standaloneAgents = await this.getAgentsFromDir(agentDirPath, 'standalone');
|
||
this.agents.push(...standaloneAgents);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get agents from a directory recursively
|
||
* Only includes .md files with agent content
|
||
*/
|
||
async getAgentsFromDir(dirPath, moduleName, relativePath = '') {
|
||
// Skip directories claimed by collectSkills
|
||
if (this.skillClaimedDirs && this.skillClaimedDirs.has(dirPath)) return [];
|
||
const agents = [];
|
||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||
// Load skill manifest for this directory (if present)
|
||
const skillManifest = await this.loadSkillManifest(dirPath);
|
||
|
||
for (const entry of entries) {
|
||
const fullPath = path.join(dirPath, entry.name);
|
||
|
||
if (entry.isDirectory()) {
|
||
// Check for new-format agent: bmad-skill-manifest.yaml with type: agent
|
||
// Note: type:agent dirs may also be claimed by collectSkills for IDE installation,
|
||
// but we still need to process them here for agent-manifest.csv
|
||
const dirManifest = await this.loadSkillManifest(fullPath);
|
||
if (dirManifest && dirManifest.__single && dirManifest.__single.type === 'agent') {
|
||
const m = dirManifest.__single;
|
||
const dirRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||
const installPath =
|
||
moduleName === 'core'
|
||
? `${this.bmadFolderName}/core/agents/${dirRelativePath}`
|
||
: `${this.bmadFolderName}/${moduleName}/agents/${dirRelativePath}`;
|
||
|
||
agents.push({
|
||
name: m.name || entry.name,
|
||
displayName: m.displayName || m.name || entry.name,
|
||
title: m.title || '',
|
||
icon: m.icon || '',
|
||
capabilities: m.capabilities ? this.cleanForCSV(m.capabilities) : '',
|
||
role: m.role ? this.cleanForCSV(m.role) : '',
|
||
identity: m.identity ? this.cleanForCSV(m.identity) : '',
|
||
communicationStyle: m.communicationStyle ? this.cleanForCSV(m.communicationStyle) : '',
|
||
principles: m.principles ? this.cleanForCSV(m.principles) : '',
|
||
module: m.module || moduleName,
|
||
path: installPath,
|
||
canonicalId: m.canonicalId || '',
|
||
});
|
||
|
||
this.files.push({
|
||
type: 'agent',
|
||
name: m.name || entry.name,
|
||
module: moduleName,
|
||
path: installPath,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
// Skip directories claimed by collectSkills (non-agent type skills)
|
||
if (this.skillClaimedDirs && this.skillClaimedDirs.has(fullPath)) continue;
|
||
|
||
// Recurse into subdirectories
|
||
const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||
const subDirAgents = await this.getAgentsFromDir(fullPath, moduleName, newRelativePath);
|
||
agents.push(...subDirAgents);
|
||
} else if (entry.name.endsWith('.md') && entry.name.toLowerCase() !== 'readme.md') {
|
||
const content = await fs.readFile(fullPath, 'utf8');
|
||
|
||
// Skip files that don't contain <agent> tag (e.g., README files)
|
||
if (!content.includes('<agent')) {
|
||
continue;
|
||
}
|
||
|
||
// Skip web-only agents
|
||
if (content.includes('localskip="true"')) {
|
||
continue;
|
||
}
|
||
|
||
// Extract agent metadata from the XML structure
|
||
const nameMatch = content.match(/name="([^"]+)"/);
|
||
const titleMatch = content.match(/title="([^"]+)"/);
|
||
const iconMatch = content.match(/icon="([^"]+)"/);
|
||
const capabilitiesMatch = content.match(/capabilities="([^"]+)"/);
|
||
|
||
// Extract persona fields
|
||
const roleMatch = content.match(/<role>([^<]+)<\/role>/);
|
||
const identityMatch = content.match(/<identity>([\s\S]*?)<\/identity>/);
|
||
const styleMatch = content.match(/<communication_style>([\s\S]*?)<\/communication_style>/);
|
||
const principlesMatch = content.match(/<principles>([\s\S]*?)<\/principles>/);
|
||
|
||
// Build relative path for installation
|
||
const fileRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||
const installPath =
|
||
moduleName === 'core'
|
||
? `${this.bmadFolderName}/core/agents/${fileRelativePath}`
|
||
: `${this.bmadFolderName}/${moduleName}/agents/${fileRelativePath}`;
|
||
|
||
const agentName = entry.name.replace('.md', '');
|
||
|
||
agents.push({
|
||
name: agentName,
|
||
displayName: nameMatch ? nameMatch[1] : agentName,
|
||
title: titleMatch ? titleMatch[1] : '',
|
||
icon: iconMatch ? iconMatch[1] : '',
|
||
capabilities: capabilitiesMatch ? this.cleanForCSV(capabilitiesMatch[1]) : '',
|
||
role: roleMatch ? this.cleanForCSV(roleMatch[1]) : '',
|
||
identity: identityMatch ? this.cleanForCSV(identityMatch[1]) : '',
|
||
communicationStyle: styleMatch ? this.cleanForCSV(styleMatch[1]) : '',
|
||
principles: principlesMatch ? this.cleanForCSV(principlesMatch[1]) : '',
|
||
module: moduleName,
|
||
path: installPath,
|
||
canonicalId: this.getCanonicalId(skillManifest, entry.name),
|
||
});
|
||
|
||
// Add to files list
|
||
this.files.push({
|
||
type: 'agent',
|
||
name: agentName,
|
||
module: moduleName,
|
||
path: installPath,
|
||
});
|
||
}
|
||
}
|
||
|
||
return agents;
|
||
}
|
||
|
||
/**
|
||
* Write main manifest as YAML with installation info only
|
||
* Fetches fresh version info for all modules
|
||
* @returns {string} Path to the manifest file
|
||
*/
|
||
async writeMainManifest(cfgDir) {
|
||
const manifestPath = path.join(cfgDir, 'manifest.yaml');
|
||
|
||
// Read existing manifest to preserve install date
|
||
let existingInstallDate = null;
|
||
const existingModulesMap = new Map();
|
||
|
||
if (await fs.pathExists(manifestPath)) {
|
||
try {
|
||
const existingContent = await fs.readFile(manifestPath, 'utf8');
|
||
const existingManifest = yaml.parse(existingContent);
|
||
|
||
// Preserve original install date
|
||
if (existingManifest.installation?.installDate) {
|
||
existingInstallDate = existingManifest.installation.installDate;
|
||
}
|
||
|
||
// Build map of existing modules for quick lookup
|
||
if (existingManifest.modules && Array.isArray(existingManifest.modules)) {
|
||
for (const m of existingManifest.modules) {
|
||
if (typeof m === 'object' && m.name) {
|
||
existingModulesMap.set(m.name, m);
|
||
} else if (typeof m === 'string') {
|
||
existingModulesMap.set(m, { installDate: existingInstallDate });
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// If we can't read existing manifest, continue with defaults
|
||
}
|
||
}
|
||
|
||
// Fetch fresh version info for all modules
|
||
const { Manifest } = require('./manifest');
|
||
const manifestObj = new Manifest();
|
||
const updatedModules = [];
|
||
|
||
for (const moduleName of this.modules) {
|
||
// Get fresh version info from source
|
||
const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, this.bmadDir);
|
||
|
||
// Get existing install date if available
|
||
const existing = existingModulesMap.get(moduleName);
|
||
|
||
updatedModules.push({
|
||
name: moduleName,
|
||
version: versionInfo.version,
|
||
installDate: existing?.installDate || new Date().toISOString(),
|
||
lastUpdated: new Date().toISOString(),
|
||
source: versionInfo.source,
|
||
npmPackage: versionInfo.npmPackage,
|
||
repoUrl: versionInfo.repoUrl,
|
||
});
|
||
}
|
||
|
||
const manifest = {
|
||
installation: {
|
||
version: packageJson.version,
|
||
installDate: existingInstallDate || new Date().toISOString(),
|
||
lastUpdated: new Date().toISOString(),
|
||
},
|
||
modules: updatedModules,
|
||
ides: this.selectedIdes,
|
||
};
|
||
|
||
// Clean the manifest to remove any non-serializable values
|
||
const cleanManifest = structuredClone(manifest);
|
||
|
||
const yamlStr = yaml.stringify(cleanManifest, {
|
||
indent: 2,
|
||
lineWidth: 0,
|
||
sortKeys: false,
|
||
});
|
||
|
||
// Ensure POSIX-compliant final newline
|
||
const content = yamlStr.endsWith('\n') ? yamlStr : yamlStr + '\n';
|
||
await fs.writeFile(manifestPath, content);
|
||
return manifestPath;
|
||
}
|
||
|
||
/**
|
||
* Write skill manifest CSV
|
||
* @returns {string} Path to the manifest file
|
||
*/
|
||
async writeSkillManifest(cfgDir) {
|
||
const csvPath = path.join(cfgDir, 'skill-manifest.csv');
|
||
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
|
||
|
||
let csvContent = 'canonicalId,name,description,module,path,install_to_bmad\n';
|
||
|
||
for (const skill of this.skills) {
|
||
const row = [
|
||
escapeCsv(skill.canonicalId),
|
||
escapeCsv(skill.name),
|
||
escapeCsv(skill.description),
|
||
escapeCsv(skill.module),
|
||
escapeCsv(skill.path),
|
||
escapeCsv(skill.install_to_bmad),
|
||
].join(',');
|
||
csvContent += row + '\n';
|
||
}
|
||
|
||
await fs.writeFile(csvPath, csvContent);
|
||
return csvPath;
|
||
}
|
||
|
||
/**
|
||
* Write agent manifest CSV
|
||
* @returns {string} Path to the manifest file
|
||
*/
|
||
async writeAgentManifest(cfgDir) {
|
||
const csvPath = path.join(cfgDir, 'agent-manifest.csv');
|
||
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
|
||
|
||
// Read existing manifest to preserve entries
|
||
const existingEntries = new Map();
|
||
if (await fs.pathExists(csvPath)) {
|
||
const content = await fs.readFile(csvPath, 'utf8');
|
||
const records = csv.parse(content, {
|
||
columns: true,
|
||
skip_empty_lines: true,
|
||
});
|
||
for (const record of records) {
|
||
existingEntries.set(`${record.module}:${record.name}`, record);
|
||
}
|
||
}
|
||
|
||
// Create CSV header with persona fields and canonicalId
|
||
let csvContent = 'name,displayName,title,icon,capabilities,role,identity,communicationStyle,principles,module,path,canonicalId\n';
|
||
|
||
// Combine existing and new agents, preferring new data for duplicates
|
||
const allAgents = new Map();
|
||
|
||
// Add existing entries
|
||
for (const [key, value] of existingEntries) {
|
||
allAgents.set(key, value);
|
||
}
|
||
|
||
// Add/update new agents
|
||
for (const agent of this.agents) {
|
||
const key = `${agent.module}:${agent.name}`;
|
||
allAgents.set(key, {
|
||
name: agent.name,
|
||
displayName: agent.displayName,
|
||
title: agent.title,
|
||
icon: agent.icon,
|
||
capabilities: agent.capabilities,
|
||
role: agent.role,
|
||
identity: agent.identity,
|
||
communicationStyle: agent.communicationStyle,
|
||
principles: agent.principles,
|
||
module: agent.module,
|
||
path: agent.path,
|
||
canonicalId: agent.canonicalId || '',
|
||
});
|
||
}
|
||
|
||
// Write all agents
|
||
for (const [, record] of allAgents) {
|
||
const row = [
|
||
escapeCsv(record.name),
|
||
escapeCsv(record.displayName),
|
||
escapeCsv(record.title),
|
||
escapeCsv(record.icon),
|
||
escapeCsv(record.capabilities),
|
||
escapeCsv(record.role),
|
||
escapeCsv(record.identity),
|
||
escapeCsv(record.communicationStyle),
|
||
escapeCsv(record.principles),
|
||
escapeCsv(record.module),
|
||
escapeCsv(record.path),
|
||
escapeCsv(record.canonicalId),
|
||
].join(',');
|
||
csvContent += row + '\n';
|
||
}
|
||
|
||
await fs.writeFile(csvPath, csvContent);
|
||
return csvPath;
|
||
}
|
||
|
||
/**
|
||
* Write files manifest CSV
|
||
*/
|
||
/**
|
||
* Calculate SHA256 hash of a file
|
||
* @param {string} filePath - Path to file
|
||
* @returns {string} SHA256 hash
|
||
*/
|
||
async calculateFileHash(filePath) {
|
||
try {
|
||
const content = await fs.readFile(filePath);
|
||
return crypto.createHash('sha256').update(content).digest('hex');
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @returns {string} Path to the manifest file
|
||
*/
|
||
async writeFilesManifest(cfgDir) {
|
||
const csvPath = path.join(cfgDir, 'files-manifest.csv');
|
||
|
||
// Create CSV header with hash column
|
||
let csv = 'type,name,module,path,hash\n';
|
||
|
||
// If we have ALL installed files, use those instead of just workflows/agents/tasks
|
||
const allFiles = [];
|
||
if (this.allInstalledFiles && this.allInstalledFiles.length > 0) {
|
||
// Process all installed files
|
||
for (const filePath of this.allInstalledFiles) {
|
||
// Store paths relative to bmadDir (no folder prefix)
|
||
const relativePath = filePath.replace(this.bmadDir, '').replaceAll('\\', '/').replace(/^\//, '');
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
const fileName = path.basename(filePath, ext);
|
||
|
||
// Determine module from path (first directory component)
|
||
const pathParts = relativePath.split('/');
|
||
const module = pathParts.length > 0 ? pathParts[0] : 'unknown';
|
||
|
||
// Calculate hash
|
||
const hash = await this.calculateFileHash(filePath);
|
||
|
||
allFiles.push({
|
||
type: ext.slice(1) || 'file',
|
||
name: fileName,
|
||
module: module,
|
||
path: relativePath,
|
||
hash: hash,
|
||
});
|
||
}
|
||
} else {
|
||
// Fallback: use the collected workflows/agents/tasks
|
||
for (const file of this.files) {
|
||
// Strip the folder prefix if present (for consistency)
|
||
const relPath = file.path.replace(this.bmadFolderName + '/', '');
|
||
const filePath = path.join(this.bmadDir, relPath);
|
||
const hash = await this.calculateFileHash(filePath);
|
||
allFiles.push({
|
||
...file,
|
||
path: relPath,
|
||
hash: hash,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Sort files by module, then type, then name
|
||
allFiles.sort((a, b) => {
|
||
if (a.module !== b.module) return a.module.localeCompare(b.module);
|
||
if (a.type !== b.type) return a.type.localeCompare(b.type);
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
|
||
// Add all files
|
||
for (const file of allFiles) {
|
||
csv += `"${file.type}","${file.name}","${file.module}","${file.path}","${file.hash}"\n`;
|
||
}
|
||
|
||
await fs.writeFile(csvPath, csv);
|
||
return csvPath;
|
||
}
|
||
|
||
/**
|
||
* Scan the bmad directory to find all installed modules
|
||
* @param {string} bmadDir - Path to bmad directory
|
||
* @returns {Array} List of module names
|
||
*/
|
||
async scanInstalledModules(bmadDir) {
|
||
const modules = [];
|
||
|
||
try {
|
||
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
||
|
||
for (const entry of entries) {
|
||
// Skip if not a directory or is a special directory
|
||
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === '_config') {
|
||
continue;
|
||
}
|
||
|
||
// Check if this looks like a module (has agents, workflows, or tasks directory)
|
||
const modulePath = path.join(bmadDir, entry.name);
|
||
const hasAgents = await fs.pathExists(path.join(modulePath, 'agents'));
|
||
const hasWorkflows = await fs.pathExists(path.join(modulePath, 'workflows'));
|
||
const hasTasks = await fs.pathExists(path.join(modulePath, 'tasks'));
|
||
const hasTools = await fs.pathExists(path.join(modulePath, 'tools'));
|
||
|
||
// Check for native-entrypoint-only modules: recursive scan for SKILL.md
|
||
let hasSkills = false;
|
||
if (!hasAgents && !hasWorkflows && !hasTasks && !hasTools) {
|
||
hasSkills = await this._hasSkillMdRecursive(modulePath);
|
||
}
|
||
|
||
// If it has any of these directories or skill manifests, it's likely a module
|
||
if (hasAgents || hasWorkflows || hasTasks || hasTools || hasSkills) {
|
||
modules.push(entry.name);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
await prompts.log.warn(`Could not scan for installed modules: ${error.message}`);
|
||
}
|
||
|
||
return modules;
|
||
}
|
||
|
||
/**
|
||
* Recursively check if a directory tree contains a SKILL.md file.
|
||
* Skips directories starting with . or _.
|
||
* @param {string} dir - Directory to search
|
||
* @returns {boolean} True if a SKILL.md is found
|
||
*/
|
||
async _hasSkillMdRecursive(dir) {
|
||
let entries;
|
||
try {
|
||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||
} catch {
|
||
return false;
|
||
}
|
||
|
||
// Check for SKILL.md in this directory
|
||
if (entries.some((e) => !e.isDirectory() && e.name === 'SKILL.md')) return true;
|
||
|
||
// Recurse into subdirectories
|
||
for (const entry of entries) {
|
||
if (!entry.isDirectory()) continue;
|
||
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
|
||
if (await this._hasSkillMdRecursive(path.join(dir, entry.name))) return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
module.exports = { ManifestGenerator };
|