sf-agent-framework
Version:
AI Agent Orchestration Framework for Salesforce Development - Two-phase architecture with 70% context reduction
1,367 lines (1,175 loc) ⢠72.2 kB
JavaScript
const path = require('node:path');
const fileManager = require('./file-manager');
const configLoader = require('./config-loader');
const ideSetup = require('./ide-setup');
const { extractYamlFromAgent } = require('../../lib/yaml-utils');
// Dynamic imports for ES modules
let chalk, ora, inquirer;
// Initialize ES modules
async function initializeModules() {
if (!chalk) {
chalk = (await import('chalk')).default;
ora = (await import('ora')).default;
inquirer = (await import('inquirer')).default;
}
}
class Installer {
async getCoreVersion() {
const yaml = require('js-yaml');
const fs = require('fs-extra');
const coreConfigPath = path.join(__dirname, '../../../sf-core/core-config.yaml');
try {
const coreConfigContent = await fs.readFile(coreConfigPath, 'utf8');
const coreConfig = yaml.load(coreConfigContent);
return coreConfig.version || 'unknown';
} catch (error) {
console.warn("Could not read version from core-config.yaml, using 'unknown'");
return 'unknown';
}
}
async install(config) {
// Initialize ES modules
await initializeModules();
const spinner = ora('Analyzing installation directory...').start();
try {
// Store the original CWD where npx was executed
const originalCwd = process.env.INIT_CWD || process.env.PWD || process.cwd();
// Resolve installation directory relative to where the user ran the command
let installDir = path.isAbsolute(config.directory)
? config.directory
: path.resolve(originalCwd, config.directory);
if (path.basename(installDir) === '.sf-core') {
// If user points directly to .sf-core, treat its parent as the project root
installDir = path.dirname(installDir);
}
// Log resolved path for clarity
if (!path.isAbsolute(config.directory)) {
spinner.text = `Resolving "${config.directory}" to: ${installDir}`;
}
// Check if directory exists and handle non-existent directories
if (!(await fileManager.pathExists(installDir))) {
spinner.stop();
console.log(chalk.yellow(`\nThe directory ${chalk.bold(installDir)} does not exist.`));
const { action } = await inquirer.prompt([
{
type: 'list',
name: 'action',
message: 'What would you like to do?',
choices: [
{
name: 'Create the directory and continue',
value: 'create',
},
{
name: 'Choose a different directory',
value: 'change',
},
{
name: 'Cancel installation',
value: 'cancel',
},
],
},
]);
if (action === 'cancel') {
console.log(chalk.red('Installation cancelled.'));
process.exit(0);
} else if (action === 'change') {
const { newDirectory } = await inquirer.prompt([
{
type: 'input',
name: 'newDirectory',
message: 'Enter the new directory path:',
validate: (input) => {
if (!input.trim()) {
return 'Please enter a valid directory path';
}
return true;
},
},
]);
// Preserve the original CWD for the recursive call
config.directory = newDirectory;
return await this.install(config); // Recursive call with new directory
} else if (action === 'create') {
try {
await fileManager.ensureDirectory(installDir);
console.log(chalk.green(`â Created directory: ${installDir}`));
} catch (error) {
console.error(chalk.red(`Failed to create directory: ${error.message}`));
console.error(
chalk.yellow('You may need to check permissions or use a different path.')
);
process.exit(1);
}
}
spinner.start('Analyzing installation directory...');
}
// If this is an update request from early detection, handle it directly
if (config.installType === 'update') {
const state = await this.detectInstallationState(installDir);
if (state.type === 'v4_existing') {
return await this.performUpdate(config, installDir, state.manifest, spinner);
} else {
spinner.fail('No existing v4 installation found to update');
throw new Error('No existing v4 installation found');
}
}
// Detect current state
const state = await this.detectInstallationState(installDir);
// Handle different states
switch (state.type) {
case 'clean':
return await this.performFreshInstall(config, installDir, spinner);
case 'v4_existing':
return await this.handleExistingV4Installation(config, installDir, state, spinner);
case 'v3_existing':
return await this.handleV3Installation(config, installDir, state, spinner);
case 'unknown_existing':
return await this.handleUnknownInstallation(config, installDir, state, spinner);
}
} catch (error) {
spinner.fail('Installation failed');
throw error;
}
}
async detectInstallationState(installDir) {
// Ensure modules are initialized
await initializeModules();
const state = {
type: 'clean',
hasV4Manifest: false,
hasV3Structure: false,
hasBmadCore: false,
hasOtherFiles: false,
manifest: null,
expansionPacks: {},
};
// Check if directory exists
if (!(await fileManager.pathExists(installDir))) {
return state; // clean install
}
// Check for V4 installation (has .sf-core with manifest)
const sfAgentCorePath = path.join(installDir, '.sf-core');
const manifestPath = path.join(sfAgentCorePath, 'install-manifest.yaml');
if (await fileManager.pathExists(manifestPath)) {
state.type = 'v4_existing';
state.hasV4Manifest = true;
state.hasBmadCore = true;
state.manifest = await fileManager.readManifest(installDir);
return state;
}
// Check for V3 installation (has sf-agent-agent directory)
const sfAgentAgentPath = path.join(installDir, 'sf-agent-agent');
if (await fileManager.pathExists(sfAgentAgentPath)) {
state.type = 'v3_existing';
state.hasV3Structure = true;
return state;
}
// Check for .sf-core without manifest (broken V4 or manual copy)
if (await fileManager.pathExists(sfAgentCorePath)) {
state.type = 'unknown_existing';
state.hasBmadCore = true;
return state;
}
// Check if directory has other files
const glob = require('glob');
const files = glob.sync('**/*', {
cwd: installDir,
nodir: true,
ignore: ['**/.git/**', '**/node_modules/**'],
});
if (files.length > 0) {
// Directory has other files, but no SF-Agent installation.
// Treat as clean install but record that it isn't empty.
state.hasOtherFiles = true;
}
// Check for expansion packs (folders starting with .)
const expansionPacks = await this.detectExpansionPacks(installDir);
state.expansionPacks = expansionPacks;
return state; // clean install
}
async performFreshInstall(config, installDir, spinner, options = {}) {
// Ensure modules are initialized
await initializeModules();
spinner.text = 'Installing SF-Agent Framework...';
let files = [];
if (config.installType === 'full') {
// Full installation - copy entire .sf-core folder as a subdirectory
spinner.text = 'Copying complete .sf-core folder...';
const sourceDir = configLoader.getBmadCorePath();
const sfAgentCoreDestDir = path.join(installDir, '.sf-core');
await fileManager.copyDirectoryWithRootReplacement(sourceDir, sfAgentCoreDestDir, '.sf-core');
// Copy common/ items to .sf-core
spinner.text = 'Copying common utilities...';
await this.copyCommonItems(installDir, '.sf-core', spinner);
// Get list of all files for manifest
const glob = require('glob');
files = glob
.sync('**/*', {
cwd: sfAgentCoreDestDir,
nodir: true,
ignore: ['**/.git/**', '**/node_modules/**'],
})
.map((file) => path.join('.sf-core', file));
} else if (config.installType === 'single-agent') {
// Single agent installation
spinner.text = `Installing ${config.agent} agent...`;
// Copy agent file with {root} replacement
const agentPath = configLoader.getAgentPath(config.agent);
const destAgentPath = path.join(installDir, '.sf-core', 'agents', `${config.agent}.md`);
await fileManager.copyFileWithRootReplacement(agentPath, destAgentPath, '.sf-core');
files.push(`.sf-core/agents/${config.agent}.md`);
// Copy dependencies
const dependencies = await configLoader.getAgentDependencies(config.agent);
const sourceBase = configLoader.getBmadCorePath();
for (const dep of dependencies) {
spinner.text = `Copying dependency: ${dep}`;
if (dep.includes('*')) {
// Handle glob patterns with {root} replacement
const copiedFiles = await fileManager.copyGlobPattern(
dep.replace('.sf-core/', ''),
sourceBase,
path.join(installDir, '.sf-core'),
'.sf-core'
);
files.push(...copiedFiles.map((f) => `.sf-core/${f}`));
} else {
// Handle single files with {root} replacement if needed
const sourcePath = path.join(sourceBase, dep.replace('.sf-core/', ''));
const destPath = path.join(installDir, dep);
const needsRootReplacement =
dep.endsWith('.md') || dep.endsWith('.yaml') || dep.endsWith('.yml');
let success = false;
if (needsRootReplacement) {
success = await fileManager.copyFileWithRootReplacement(
sourcePath,
destPath,
'.sf-core'
);
} else {
success = await fileManager.copyFile(sourcePath, destPath);
}
if (success) {
files.push(dep);
}
}
}
// Copy common/ items to .sf-core
spinner.text = 'Copying common utilities...';
const commonFiles = await this.copyCommonItems(installDir, '.sf-core', spinner);
files.push(...commonFiles);
} else if (config.installType === 'team') {
// Team installation
spinner.text = `Installing ${config.team} team...`;
// Get team dependencies
const teamDependencies = await configLoader.getTeamDependencies(config.team);
const sourceBase = configLoader.getBmadCorePath();
// Install all team dependencies
for (const dep of teamDependencies) {
spinner.text = `Copying team dependency: ${dep}`;
if (dep.includes('*')) {
// Handle glob patterns with {root} replacement
const copiedFiles = await fileManager.copyGlobPattern(
dep.replace('.sf-core/', ''),
sourceBase,
path.join(installDir, '.sf-core'),
'.sf-core'
);
files.push(...copiedFiles.map((f) => `.sf-core/${f}`));
} else {
// Handle single files with {root} replacement if needed
const sourcePath = path.join(sourceBase, dep.replace('.sf-core/', ''));
const destPath = path.join(installDir, dep);
const needsRootReplacement =
dep.endsWith('.md') || dep.endsWith('.yaml') || dep.endsWith('.yml');
let success = false;
if (needsRootReplacement) {
success = await fileManager.copyFileWithRootReplacement(
sourcePath,
destPath,
'.sf-core'
);
} else {
success = await fileManager.copyFile(sourcePath, destPath);
}
if (success) {
files.push(dep);
}
}
}
// Copy common/ items to .sf-core
spinner.text = 'Copying common utilities...';
const commonFiles = await this.copyCommonItems(installDir, '.sf-core', spinner);
files.push(...commonFiles);
} else if (config.installType === 'expansion-only') {
// Expansion-only installation - DO NOT create .sf-core
// Only install expansion packs
spinner.text = 'Installing expansion packs only...';
}
// Install expansion packs if requested
const expansionFiles = await this.installExpansionPacks(
installDir,
config.expansionPacks,
spinner,
config
);
files.push(...expansionFiles);
// Install web bundles if requested
if (config.includeWebBundles && config.webBundlesDirectory) {
spinner.text = 'Installing web bundles...';
// Resolve web bundles directory using the same logic as the main installation directory
const originalCwd = process.env.INIT_CWD || process.env.PWD || process.cwd();
let resolvedWebBundlesDir = path.isAbsolute(config.webBundlesDirectory)
? config.webBundlesDirectory
: path.resolve(originalCwd, config.webBundlesDirectory);
await this.installWebBundles(resolvedWebBundlesDir, config, spinner);
}
// Set up IDE integration if requested
const ides = config.ides || (config.ide ? [config.ide] : []);
if (ides.length > 0) {
for (const ide of ides) {
spinner.text = `Setting up ${ide} integration...`;
const preConfiguredSettings = ide === 'github-copilot' ? config.githubCopilotConfig : null;
await ideSetup.setup(ide, installDir, config.agent, spinner, preConfiguredSettings);
}
}
// Modify core-config.yaml if sharding preferences were provided
if (
config.installType !== 'expansion-only' &&
(config.prdSharded !== undefined || config.architectureSharded !== undefined)
) {
spinner.text = 'Configuring document sharding settings...';
await fileManager.modifyCoreConfig(installDir, config);
}
// Create manifest (skip for expansion-only installations)
if (config.installType !== 'expansion-only') {
spinner.text = 'Creating installation manifest...';
await fileManager.createManifest(installDir, config, files);
}
spinner.succeed('Installation complete!');
this.showSuccessMessage(config, installDir, options);
}
async handleExistingV4Installation(config, installDir, state, spinner) {
// Ensure modules are initialized
await initializeModules();
spinner.stop();
const currentVersion = state.manifest.version;
const newVersion = await this.getCoreVersion();
const versionCompare = this.compareVersions(currentVersion, newVersion);
console.log(chalk.yellow('\nđ Found existing SF-Agent v4 installation'));
console.log(` Directory: ${installDir}`);
console.log(` Current version: ${currentVersion}`);
console.log(` Available version: ${newVersion}`);
console.log(` Installed: ${new Date(state.manifest.installed_at).toLocaleDateString()}`);
// Check file integrity
spinner.start('Checking installation integrity...');
const integrity = await fileManager.checkFileIntegrity(installDir, state.manifest);
spinner.stop();
const hasMissingFiles = integrity.missing.length > 0;
const hasModifiedFiles = integrity.modified.length > 0;
const hasIntegrityIssues = hasMissingFiles || hasModifiedFiles;
if (hasIntegrityIssues) {
console.log(chalk.red('\nâ ď¸ Installation issues detected:'));
if (hasMissingFiles) {
console.log(chalk.red(` Missing files: ${integrity.missing.length}`));
if (integrity.missing.length <= 5) {
integrity.missing.forEach((file) => console.log(chalk.dim(` - ${file}`)));
}
}
if (hasModifiedFiles) {
console.log(chalk.yellow(` Modified files: ${integrity.modified.length}`));
if (integrity.modified.length <= 5) {
integrity.modified.forEach((file) => console.log(chalk.dim(` - ${file}`)));
}
}
}
// Show existing expansion packs
if (Object.keys(state.expansionPacks).length > 0) {
console.log(chalk.cyan('\nđŚ Installed expansion packs:'));
for (const [packId, packInfo] of Object.entries(state.expansionPacks)) {
if (packInfo.hasManifest && packInfo.manifest) {
console.log(` - ${packId} (v${packInfo.manifest.version || 'unknown'})`);
} else {
console.log(` - ${packId} (no manifest)`);
}
}
}
let choices = [];
if (versionCompare < 0) {
console.log(chalk.cyan('\nâŹď¸ Upgrade available for SF-Agent core'));
choices.push({
name: `Upgrade SF-Agent core (v${currentVersion} â v${newVersion})`,
value: 'upgrade',
});
} else if (versionCompare === 0) {
if (hasIntegrityIssues) {
// Offer repair option when files are missing or modified
choices.push({
name: 'Repair installation (restore missing/modified files)',
value: 'repair',
});
}
console.log(chalk.yellow('\nâ ď¸ Same version already installed'));
choices.push({
name: `Force reinstall SF-Agent core (v${currentVersion} - reinstall)`,
value: 'reinstall',
});
} else {
console.log(chalk.yellow('\nâŹď¸ Installed version is newer than available'));
choices.push({
name: `Downgrade SF-Agent core (v${currentVersion} â v${newVersion})`,
value: 'reinstall',
});
}
choices.push(
{ name: 'Add/update expansion packs only', value: 'expansions' },
{ name: 'Cancel', value: 'cancel' }
);
const { action } = await inquirer.prompt([
{
type: 'list',
name: 'action',
message: 'What would you like to do?',
choices: choices,
},
]);
switch (action) {
case 'upgrade':
return await this.performUpdate(config, installDir, state.manifest, spinner);
case 'repair':
// For repair, restore missing/modified files while backing up modified ones
return await this.performRepair(config, installDir, state.manifest, integrity, spinner);
case 'reinstall':
// For reinstall, don't check for modifications - just overwrite
return await this.performReinstall(config, installDir, spinner);
case 'expansions':
// Ask which expansion packs to install
const availableExpansionPacks = await this.getAvailableExpansionPacks();
if (availableExpansionPacks.length === 0) {
console.log(chalk.yellow('No expansion packs available.'));
return;
}
const { selectedPacks } = await inquirer.prompt([
{
type: 'checkbox',
name: 'selectedPacks',
message: 'Select expansion packs to install/update:',
choices: availableExpansionPacks.map((pack) => ({
name: `${pack.name} v${pack.version} - ${pack.description}`,
value: pack.id,
checked: state.expansionPacks[pack.id] !== undefined,
})),
},
]);
if (selectedPacks.length === 0) {
console.log(chalk.yellow('No expansion packs selected.'));
return;
}
spinner.start('Installing expansion packs...');
const expansionFiles = await this.installExpansionPacks(
installDir,
selectedPacks,
spinner,
{ ides: config.ides || [] }
);
spinner.succeed('Expansion packs installed successfully!');
console.log(chalk.green('\nâ Installation complete!'));
console.log(chalk.green(`â Expansion packs installed/updated:`));
for (const packId of selectedPacks) {
console.log(chalk.green(` - ${packId} â .${packId}/`));
}
return;
case 'cancel':
console.log('Installation cancelled.');
return;
}
}
async handleV3Installation(config, installDir, state, spinner) {
// Ensure modules are initialized
await initializeModules();
spinner.stop();
console.log(chalk.yellow('\nđ Found SF-Agent v3 installation (sf-agent-agent/ directory)'));
console.log(` Directory: ${installDir}`);
const { action } = await inquirer.prompt([
{
type: 'list',
name: 'action',
message: 'What would you like to do?',
choices: [
{ name: 'Upgrade from v3 to v4 (recommended)', value: 'upgrade' },
{ name: 'Install v4 alongside v3', value: 'alongside' },
{ name: 'Cancel', value: 'cancel' },
],
},
]);
switch (action) {
case 'upgrade': {
console.log(chalk.cyan('\nđŚ Starting v3 to v4 upgrade process...'));
const V3ToV4Upgrader = require('../../upgraders/v3-to-v4-upgrader');
const upgrader = new V3ToV4Upgrader();
return await upgrader.upgrade({
projectPath: installDir,
ides: config.ides || [], // Pass IDE selections from initial config
});
}
case 'alongside':
return await this.performFreshInstall(config, installDir, spinner);
case 'cancel':
console.log('Installation cancelled.');
return;
}
}
async handleUnknownInstallation(config, installDir, state, spinner) {
// Ensure modules are initialized
await initializeModules();
spinner.stop();
console.log(chalk.yellow('\nâ ď¸ Directory contains existing files'));
console.log(` Directory: ${installDir}`);
if (state.hasBmadCore) {
console.log(' Found: .sf-core directory (but no manifest)');
}
if (state.hasOtherFiles) {
console.log(' Found: Other files in directory');
}
const { action } = await inquirer.prompt([
{
type: 'list',
name: 'action',
message: 'What would you like to do?',
choices: [
{ name: 'Install anyway (may overwrite files)', value: 'force' },
{ name: 'Choose different directory', value: 'different' },
{ name: 'Cancel', value: 'cancel' },
],
},
]);
switch (action) {
case 'force':
return await this.performFreshInstall(config, installDir, spinner);
case 'different': {
const { newDir } = await inquirer.prompt([
{
type: 'input',
name: 'newDir',
message: 'Enter new installation directory:',
default: path.join(path.dirname(installDir), 'sf-agent-project'),
},
]);
config.directory = newDir;
return await this.install(config);
}
case 'cancel':
console.log('Installation cancelled.');
return;
}
}
async performUpdate(newConfig, installDir, manifest, spinner) {
spinner.start('Checking for updates...');
try {
// Get current and new versions
const currentVersion = manifest.version;
const newVersion = await this.getCoreVersion();
const versionCompare = this.compareVersions(currentVersion, newVersion);
// Only check for modified files if it's an actual version upgrade
let modifiedFiles = [];
if (versionCompare !== 0) {
spinner.text = 'Checking for modified files...';
modifiedFiles = await fileManager.checkModifiedFiles(installDir, manifest);
}
if (modifiedFiles.length > 0) {
spinner.warn('Found modified files');
console.log(chalk.yellow('\nThe following files have been modified:'));
for (const file of modifiedFiles) {
console.log(` - ${file}`);
}
const { action } = await inquirer.prompt([
{
type: 'list',
name: 'action',
message: 'How would you like to proceed?',
choices: [
{ name: 'Backup and overwrite modified files', value: 'backup' },
{ name: 'Skip modified files', value: 'skip' },
{ name: 'Cancel update', value: 'cancel' },
],
},
]);
if (action === 'cancel') {
console.log('Update cancelled.');
return;
}
if (action === 'backup') {
spinner.start('Backing up modified files...');
for (const file of modifiedFiles) {
const filePath = path.join(installDir, file);
const backupPath = await fileManager.backupFile(filePath);
console.log(chalk.dim(` Backed up: ${file} â ${path.basename(backupPath)}`));
}
}
}
// Perform update by re-running installation
spinner.text = versionCompare === 0 ? 'Reinstalling files...' : 'Updating files...';
const config = {
installType: manifest.install_type,
agent: manifest.agent,
directory: installDir,
ides: newConfig?.ides || manifest.ides_setup || [],
};
await this.performFreshInstall(config, installDir, spinner, { isUpdate: true });
// Clean up .yml files that now have .yaml counterparts
spinner.text = 'Cleaning up legacy .yml files...';
await this.cleanupLegacyYmlFiles(installDir, spinner);
} catch (error) {
spinner.fail('Update failed');
throw error;
}
}
async performRepair(config, installDir, manifest, integrity, spinner) {
spinner.start('Preparing to repair installation...');
try {
// Back up modified files
if (integrity.modified.length > 0) {
spinner.text = 'Backing up modified files...';
for (const file of integrity.modified) {
const filePath = path.join(installDir, file);
if (await fileManager.pathExists(filePath)) {
const backupPath = await fileManager.backupFile(filePath);
console.log(chalk.dim(` Backed up: ${file} â ${path.basename(backupPath)}`));
}
}
}
// Restore missing and modified files
spinner.text = 'Restoring files...';
const sourceBase = configLoader.getBmadCorePath();
const filesToRestore = [...integrity.missing, ...integrity.modified];
for (const file of filesToRestore) {
// Skip the manifest file itself
if (file.endsWith('install-manifest.yaml')) continue;
const relativePath = file.replace('.sf-core/', '');
const destPath = path.join(installDir, file);
// Check if this is a common/ file that needs special processing
const commonBase = path.dirname(path.dirname(path.dirname(path.dirname(__filename))));
const commonSourcePath = path.join(commonBase, 'common', relativePath);
if (await fileManager.pathExists(commonSourcePath)) {
// This is a common/ file - needs template processing
const fs = require('fs').promises;
const content = await fs.readFile(commonSourcePath, 'utf8');
const updatedContent = content.replace(/\{root\}/g, '.sf-core');
await fileManager.ensureDirectory(path.dirname(destPath));
await fs.writeFile(destPath, updatedContent, 'utf8');
spinner.text = `Restored: ${file}`;
} else {
// Regular file from sf-core
const sourcePath = path.join(sourceBase, relativePath);
if (await fileManager.pathExists(sourcePath)) {
await fileManager.copyFile(sourcePath, destPath);
spinner.text = `Restored: ${file}`;
// If this is a .yaml file, check for and remove corresponding .yml file
if (file.endsWith('.yaml')) {
const ymlFile = file.replace(/\.yaml$/, '.yml');
const ymlPath = path.join(installDir, ymlFile);
if (await fileManager.pathExists(ymlPath)) {
const fs = require('fs').promises;
await fs.unlink(ymlPath);
console.log(chalk.dim(` Removed legacy: ${ymlFile} (replaced by ${file})`));
}
}
} else {
console.warn(chalk.yellow(` Warning: Source file not found: ${file}`));
}
}
}
// Clean up .yml files that now have .yaml counterparts
spinner.text = 'Cleaning up legacy .yml files...';
await this.cleanupLegacyYmlFiles(installDir, spinner);
spinner.succeed('Repair completed successfully!');
// Show summary
console.log(chalk.green('\nâ Installation repaired!'));
if (integrity.missing.length > 0) {
console.log(chalk.green(` Restored ${integrity.missing.length} missing files`));
}
if (integrity.modified.length > 0) {
console.log(
chalk.green(` Restored ${integrity.modified.length} modified files (backups created)`)
);
}
// Warning for Cursor custom modes if agents were repaired
const ides = manifest.ides_setup || [];
if (ides.includes('cursor')) {
console.log(chalk.yellow.bold('\nâ ď¸ IMPORTANT: Cursor Custom Modes Update Required'));
console.log(
chalk.yellow(
'Since agent files have been repaired, you need to update any custom agent modes configured in the Cursor custom agent GUI per the Cursor docs.'
)
);
}
} catch (error) {
spinner.fail('Repair failed');
throw error;
}
}
async performReinstall(config, installDir, spinner) {
spinner.start('Preparing to reinstall SF-Agent Framework...');
// Remove existing .sf-core
const sfAgentCorePath = path.join(installDir, '.sf-core');
if (await fileManager.pathExists(sfAgentCorePath)) {
spinner.text = 'Removing existing installation...';
await fileManager.removeDirectory(sfAgentCorePath);
}
spinner.text = 'Installing fresh copy...';
const result = await this.performFreshInstall(config, installDir, spinner, { isUpdate: true });
// Clean up .yml files that now have .yaml counterparts
spinner.text = 'Cleaning up legacy .yml files...';
await this.cleanupLegacyYmlFiles(installDir, spinner);
return result;
}
showSuccessMessage(config, installDir, options = {}) {
console.log(chalk.green('\nâ SF-Agent Framework installed successfully!\n'));
const ides = config.ides || (config.ide ? [config.ide] : []);
if (ides.length > 0) {
for (const ide of ides) {
const ideConfig = configLoader.getIdeConfiguration(ide);
if (ideConfig?.instructions) {
console.log(chalk.bold(`To use SF-Agent agents in ${ideConfig.name}:`));
console.log(ideConfig.instructions);
}
}
} else {
console.log(chalk.yellow('No IDE configuration was set up.'));
console.log('You can manually configure your IDE using the agent files in:', installDir);
}
// Information about installation components
console.log(chalk.bold('\nđŻ Installation Summary:'));
if (config.installType !== 'expansion-only') {
console.log(chalk.green('â .sf-core framework installed with all agents and workflows'));
}
if (config.expansionPacks && config.expansionPacks.length > 0) {
console.log(chalk.green(`â Expansion packs installed:`));
for (const packId of config.expansionPacks) {
console.log(chalk.green(` - ${packId} â .${packId}/`));
}
}
if (config.includeWebBundles && config.webBundlesDirectory) {
const bundleInfo = this.getWebBundleInfo(config);
// Resolve the web bundles directory for display
const originalCwd = process.env.INIT_CWD || process.env.PWD || process.cwd();
const resolvedWebBundlesDir = path.isAbsolute(config.webBundlesDirectory)
? config.webBundlesDirectory
: path.resolve(originalCwd, config.webBundlesDirectory);
console.log(
chalk.green(`â Web bundles (${bundleInfo}) installed to: ${resolvedWebBundlesDir}`)
);
}
if (ides.length > 0) {
const ideNames = ides
.map((ide) => {
const ideConfig = configLoader.getIdeConfiguration(ide);
return ideConfig?.name || ide;
})
.join(', ');
console.log(chalk.green(`â IDE rules and configurations set up for: ${ideNames}`));
}
// Information about web bundles
if (!config.includeWebBundles) {
console.log(chalk.bold('\nđŚ Web Bundles Available:'));
console.log('Pre-built web bundles are available and can be added later:');
console.log(chalk.cyan(' Run the installer again to add them to your project'));
console.log('These bundles work independently and can be shared, moved, or used');
console.log('in other projects as standalone files.');
}
if (config.installType === 'single-agent') {
console.log(
chalk.dim('\nNeed other agents? Run: npx sf-agent-framework install --agent=<name>')
);
console.log(chalk.dim('Need everything? Run: npx sf-agent-framework install --full'));
}
// Warning for Cursor custom modes if agents were updated
if (options.isUpdate && ides.includes('cursor')) {
console.log(chalk.yellow.bold('\nâ ď¸ IMPORTANT: Cursor Custom Modes Update Required'));
console.log(
chalk.yellow(
'Since agents have been updated, you need to update any custom agent modes configured in the Cursor custom agent GUI per the Cursor docs.'
)
);
}
}
// Legacy method for backward compatibility
async update() {
// Initialize ES modules
await initializeModules();
console.log(chalk.yellow('The "update" command is deprecated.'));
console.log(
'Please use "install" instead - it will detect and offer to update existing installations.'
);
const installDir = await this.findInstallation();
if (installDir) {
const config = {
installType: 'full',
directory: path.dirname(installDir),
ide: null,
};
return await this.install(config);
}
console.log(chalk.red('No SF-Agent installation found.'));
}
async listAgents() {
// Initialize ES modules
await initializeModules();
const agents = await configLoader.getAvailableAgents();
console.log(chalk.bold('\nAvailable SF-Agent Agents:\n'));
for (const agent of agents) {
console.log(chalk.cyan(` ${agent.id.padEnd(20)}`), agent.description);
}
console.log(chalk.dim('\nInstall with: npx sf-agent-framework install --agent=<id>\n'));
}
async listExpansionPacks() {
// Initialize ES modules
await initializeModules();
const expansionPacks = await this.getAvailableExpansionPacks();
console.log(chalk.bold('\nAvailable SF-Agent Expansion Packs:\n'));
if (expansionPacks.length === 0) {
console.log(chalk.yellow('No expansion packs found.'));
return;
}
for (const pack of expansionPacks) {
console.log(chalk.cyan(` ${pack.id.padEnd(20)}`), `${pack.name} v${pack.version}`);
console.log(chalk.dim(` ${' '.repeat(22)}${pack.description}`));
if (pack.author && pack.author !== 'Unknown') {
console.log(chalk.dim(` ${' '.repeat(22)}by ${pack.author}`));
}
console.log();
}
console.log(
chalk.dim('Install with: npx sf-agent-framework install --full --expansion-packs <id>\n')
);
}
async showStatus() {
// Initialize ES modules
await initializeModules();
const installDir = await this.findInstallation();
if (!installDir) {
console.log(chalk.yellow('No SF-Agent installation found in current directory tree'));
return;
}
const manifest = await fileManager.readManifest(installDir);
if (!manifest) {
console.log(chalk.red('Invalid installation - manifest not found'));
return;
}
console.log(chalk.bold('\nSF-Agent Installation Status:\n'));
console.log(` Directory: ${installDir}`);
console.log(` Version: ${manifest.version}`);
console.log(` Installed: ${new Date(manifest.installed_at).toLocaleDateString()}`);
console.log(` Type: ${manifest.install_type}`);
if (manifest.agent) {
console.log(` Agent: ${manifest.agent}`);
}
if (manifest.ides_setup && manifest.ides_setup.length > 0) {
console.log(` IDE Setup: ${manifest.ides_setup.join(', ')}`);
}
console.log(` Total Files: ${manifest.files.length}`);
// Check for modifications
const modifiedFiles = await fileManager.checkModifiedFiles(installDir, manifest);
if (modifiedFiles.length > 0) {
console.log(chalk.yellow(` Modified Files: ${modifiedFiles.length}`));
}
console.log('');
}
async getAvailableAgents() {
return configLoader.getAvailableAgents();
}
async getAvailableExpansionPacks() {
return configLoader.getAvailableExpansionPacks();
}
async getAvailableTeams() {
return configLoader.getAvailableTeams();
}
async installExpansionPacks(installDir, selectedPacks, spinner, config = {}) {
if (!selectedPacks || selectedPacks.length === 0) {
return [];
}
const installedFiles = [];
const glob = require('glob');
for (const packId of selectedPacks) {
spinner.text = `Installing expansion pack: ${packId}...`;
try {
const expansionPacks = await this.getAvailableExpansionPacks();
const pack = expansionPacks.find((p) => p.id === packId);
if (!pack) {
console.warn(`Expansion pack ${packId} not found, skipping...`);
continue;
}
// Check if expansion pack already exists
let expansionDotFolder = path.join(installDir, `.${packId}`);
const existingManifestPath = path.join(expansionDotFolder, 'install-manifest.yaml');
if (await fileManager.pathExists(existingManifestPath)) {
spinner.stop();
const existingManifest = await fileManager.readExpansionPackManifest(installDir, packId);
console.log(chalk.yellow(`\nđ Found existing ${pack.name} installation`));
console.log(` Current version: ${existingManifest.version || 'unknown'}`);
console.log(` New version: ${pack.version}`);
// Check integrity of existing expansion pack
const packIntegrity = await fileManager.checkFileIntegrity(installDir, existingManifest);
const hasPackIntegrityIssues =
packIntegrity.missing.length > 0 || packIntegrity.modified.length > 0;
if (hasPackIntegrityIssues) {
console.log(chalk.red(' â ď¸ Installation issues detected:'));
if (packIntegrity.missing.length > 0) {
console.log(chalk.red(` Missing files: ${packIntegrity.missing.length}`));
}
if (packIntegrity.modified.length > 0) {
console.log(chalk.yellow(` Modified files: ${packIntegrity.modified.length}`));
}
}
const versionCompare = this.compareVersions(
existingManifest.version || '0.0.0',
pack.version
);
if (versionCompare === 0) {
console.log(chalk.yellow(' â ď¸ Same version already installed'));
const choices = [];
if (hasPackIntegrityIssues) {
choices.push({ name: 'Repair (restore missing/modified files)', value: 'repair' });
}
choices.push(
{ name: 'Force reinstall (overwrite)', value: 'overwrite' },
{ name: 'Skip this expansion pack', value: 'skip' },
{ name: 'Cancel installation', value: 'cancel' }
);
const { action } = await inquirer.prompt([
{
type: 'list',
name: 'action',
message: `${pack.name} v${pack.version} is already installed. What would you like to do?`,
choices: choices,
},
]);
if (action === 'skip') {
spinner.start();
continue;
} else if (action === 'cancel') {
console.log(chalk.red('Installation cancelled.'));
process.exit(0);
} else if (action === 'repair') {
// Repair the expansion pack
await this.repairExpansionPack(installDir, packId, pack, packIntegrity, spinner);
continue;
}
} else if (versionCompare < 0) {
console.log(chalk.cyan(' âŹď¸ Upgrade available'));
const { proceed } = await inquirer.prompt([
{
type: 'confirm',
name: 'proceed',
message: `Upgrade ${pack.name} from v${existingManifest.version} to v${pack.version}?`,
default: true,
},
]);
if (!proceed) {
spinner.start();
continue;
}
} else {
console.log(chalk.yellow(' âŹď¸ Installed version is newer than available version'));
const { action } = await inquirer.prompt([
{
type: 'list',
name: 'action',
message: 'What would you like to do?',
choices: [
{ name: 'Keep current version', value: 'skip' },
{ name: 'Downgrade to available version', value: 'downgrade' },
{ name: 'Cancel installation', value: 'cancel' },
],
},
]);
if (action === 'skip') {
spinner.start();
continue;
} else if (action === 'cancel') {
console.log(chalk.red('Installation cancelled.'));
process.exit(0);
}
}
// If we get here, we're proceeding with installation
spinner.start(`Removing old ${pack.name} installation...`);
await fileManager.removeDirectory(expansionDotFolder);
}
const expansionPackDir = pack.packPath;
// Ensure dedicated dot folder exists for this expansion pack
expansionDotFolder = path.join(installDir, `.${packId}`);
await fileManager.ensureDirectory(expansionDotFolder);
// Define the folders to copy from expansion packs
const foldersToSync = [
'agents',
'agent-teams',
'templates',
'tasks',
'checklists',
'workflows',
'data',
'utils',
'schemas',
];
// Copy each folder if it exists
for (const folder of foldersToSync) {
const sourceFolder = path.join(expansionPackDir, folder);
// Check if folder exists in expansion pack
if (await fileManager.pathExists(sourceFolder)) {
// Get all files in this folder
const files = glob.sync('**/*', {
cwd: sourceFolder,
nodir: true,
});
// Copy each file to the expansion pack's dot folder with {root} replacement
for (const file of files) {
const sourcePath = path.join(sourceFolder, file);
const destPath = path.join(expansionDotFolder, folder, file);
const needsRootReplacement =
file.endsWith('.md') || file.endsWith('.yaml') || file.endsWith('.yml');
let success = false;
if (needsRootReplacement) {
success = await fileManager.copyFileWithRootReplacement(
sourcePath,
destPath,
`.${packId}`
);
} else {
success = await fileManager.copyFile(sourcePath, destPath);
}
if (success) {
installedFiles.push(path.join(`.${packId}`, folder, file));
}
}
}
}
// Copy config.yaml with {root} replacement
const configPath = path.join(expansionPackDir, 'config.yaml');
if (await fileManager.pathExists(configPath)) {
const configDestPath = path.join(expansionDotFolder, 'config.yaml');
if (
await fileManager.copyFileWithRootReplacement(configPath, configDestPath, `.${packId}`)
) {
installedFiles.push(path.join(`.${packId}`, 'config.yaml'));
}
}
// Copy README if it exists with {root} replacement
const readmePath = path.join(expansionPackDir, 'README.md');
if (await fileManager.pathExists(readmePath)) {
const readmeDestPath = path.join(expansionDotFolder, 'README.md');
if (
await fileManager.copyFileWithRootReplacement(readmePath, readmeDestPath, `.${packId}`)
) {
installedFiles.push(path.join(`.${packId}`, 'README.md'));
}
}
// Copy common/ items to expansion pack folder
spinner.text = `Copying common utilities to ${packId}...`;
await this.copyCommonItems(installDir, `.${packId}`, spinner);
// Check and resolve core dependencies
await this.resolveExpansionPackCoreDependencies(
installDir,
expansionDotFolder,
packId,
spinner
);
// Check and resolve core agents referenced by teams
await this.resolveExpansionPackCoreAgents(installDir, expansionDotFolder, packId, spinner);
// Create manifest for this expansion pack
spinner.text = `Creating manifest for ${packId}...`;
const expansionConfig = {
installType: 'expansion-pack',
expansionPackId: packId,
expansionPackName: pack.name,
expansionPackVersion: pack.version,
ides: config.ides || [], // Use ides_setup instead of ide_setup
};
// Get all files installed in this expansion pack
const expansionPackFiles = glob
.sync('**/*', {
cwd: expansionDotFolder,
nodir: true,
})
.map((f) => path.join(`.${packId}`, f));
await fileManager.createExpansionPackManifest(
installDir,
packId,
expansionConfig,
expansionPackFiles
);
console.log(chalk.green(`â Installed expansion pack: ${pack.name} to ${`.${packId}`}`));
} catch (error) {
console.error(chalk.red(`Failed to install expansion pack ${packId}: ${error.message}`));
console.error(chalk.red(`Stack trace: ${error.stack}`));
}
}
return installedFiles;
}
async resolveExpansionPackCoreDependencies(installDir, expansionDotFolder, packId, spinner) {
const glob = require('glob');
const yaml = require('js-yaml');
const fs = require('fs').promises;
// Find all agent files in the expansion pack
const agentFiles = glob.sync('agents/*.md', {
cwd: expansionDotFolder,
});
for (const agentFile of agentFiles) {
const agentPath = path.join(expansionDotFolder, agentFile);
const agentContent = await fs.readFile(agentPath, 'utf8');
// Extract YAML frontmatter to check dependencies
const yamlContent = extractYamlFromAgent(agentContent);
if (yamlContent) {
try {
const agentConfig = yaml.load(yamlContent);
const dependencies = agentConfig.dependencies || {};
// Check for core dependencies (those that don't exist in the expansion pack)
for (const depType of [
'tasks',
'templates',
'checklists',
'workflows',
'utils',
'data',
]) {
const deps = dependencies[depType] || [];
for (const dep of deps) {
let depFileName = dep;
let expansionDepPath, coreDepPath;
// If dependency doesn't have an extension, try multiple extensions
if (!dep.includes('.')) {
// For templates, try both .yaml and .md
if (depType === 'templates') {
// Try .yaml first
depFileName = `${dep}.yaml`;
expansionDepPath = path.join(expansionDotFolder, depType, depFileName);
if (!(await fileManager.pathExists(expansionDepPath))) {
// Try .md if .yaml doesn't exist
depFileName = `${dep}.md`;
expansionDepPath = path.join(expansionDotFolder, depType, depFileName);
}
} else {
// For other types, default to .md
depFileName = `${dep}.md`;
expansionDepPath = path.join(expansionDotFolder, depType, depFileName);
}
} else {
// Dependency already has extension
expansionDepPath = path.join(expansionDotFolder, depType, depFileName);
}
// Check if dependency exists in expansion pack
if (!(await fileManager.pathExists(expansionDepPath))) {
// Try to find it in core with same logic
if (!dep.includes('.') && depType === 'templates') {
// Try .yaml first in core
depFileName = `${dep}.yaml`;
coreDepPath = path.join(configLoader.getBmadCorePath(), depType, depFileName);
if (!(await fileManager.pathExists(coreDepPath))) {
// Try .md if .yaml doesn't exist in core
depFileName = `${dep}.md`;
coreDepPath = path.join(configLoader.getBmadCorePath(), depType, depFileName);
}
} else {
coreDepPath = path.join(configLoader.getBmadCorePath(), depType, depFileName);
}
if (await fileManager.pathExists(coreDepPath)) {