setup-roo-memory
Version:
A utility to set up Roo memory files for your project.
369 lines (325 loc) • 16.7 kB
JavaScript
const fs = require('fs');
const path = require('path');
const chalk = require('chalk'); // chalk@4.1.2 for CommonJS
// --- Configuration ---
const IGNORED_FILES = ['.DS_Store'];
// --- Helper Functions ---
/**
* Recursively copies files and directories, reporting changes.
* @param {string} src - Source path.
* @param {string} dest - Destination path.
* @param {object} options - Options object.
* @param {boolean} options.override - Whether to override existing files.
* @param {string} options.baseSrcForReporting - Base source path for reporting relative paths (unused here but good for generic helpers).
* @param {string} options.baseDestForReporting - Base destination path for reporting relative paths.
* @param {object} options.report - Report object to mutate { added: string[], overridden: string[], existingSkipped: string[] }.
* @returns {object} - The mutated report object.
*/
function copyRecursiveSync(src, dest, {
override = true,
baseDestForReporting = dest, // Paths in report will be relative to this
report = { added: [], overridden: [], existingSkipped: [] }
} = {}) {
if (!fs.existsSync(src) || IGNORED_FILES.includes(path.basename(src))) {
return report;
}
const stats = fs.statSync(src);
const isDirectory = stats.isDirectory();
if (isDirectory) {
const relativeDestDirPath = path.relative(baseDestForReporting, dest);
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
// Add directory to report if it's not the base reporting directory itself
if (relativeDestDirPath && !report.added.includes(relativeDestDirPath + '/')) {
report.added.push(relativeDestDirPath + '/');
}
}
fs.readdirSync(src).forEach((childItemName) => {
if (IGNORED_FILES.includes(childItemName)) return;
copyRecursiveSync(
path.join(src, childItemName),
path.join(dest, childItemName),
{ override, baseDestForReporting, report }
);
});
} else { // Is a file
const relativeDestFilePath = path.relative(baseDestForReporting, dest);
if (fs.existsSync(dest)) {
if (override) {
fs.copyFileSync(src, dest);
if (!report.overridden.includes(relativeDestFilePath)) {
report.overridden.push(relativeDestFilePath);
}
// If it was previously marked as added (e.g. parent dir created, then file), remove from added
const addedIndex = report.added.indexOf(relativeDestFilePath);
if (addedIndex > -1) report.added.splice(addedIndex, 1);
} else {
if (!report.existingSkipped.includes(relativeDestFilePath)) {
report.existingSkipped.push(relativeDestFilePath);
}
}
} else {
const destDir = path.dirname(dest);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
const relativeParentDirPath = path.relative(baseDestForReporting, destDir);
if (relativeParentDirPath && !report.added.includes(relativeParentDirPath + '/')) {
report.added.push(relativeParentDirPath + '/');
}
}
fs.copyFileSync(src, dest);
if (!report.added.includes(relativeDestFilePath)) {
report.added.push(relativeDestFilePath);
}
}
}
return report;
}
// --- Main Logic Functions ---
function manageRooFolder(sourceRooDir, targetRooDir) {
console.log(chalk.cyan('\nProcessing .roo/ folder...'));
if (!fs.existsSync(sourceRooDir)) {
console.log(chalk.yellow(`Source .roo/ directory (${sourceRooDir}) not found. Skipping.`));
return;
}
const report = { added: [], overridden: [], existingSkipped: [] };
const isNewFolder = !fs.existsSync(targetRooDir);
if (isNewFolder) {
fs.mkdirSync(targetRooDir, { recursive: true }); // Ensure .roo itself is created
}
copyRecursiveSync(sourceRooDir, targetRooDir, {
override: true,
baseDestForReporting: targetRooDir, // Report paths relative to target .roo/
report
});
// Sort for consistent output
report.added.sort((a,b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
report.overridden.sort((a,b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
if (isNewFolder) {
console.log(chalk.green('A new .roo/ folder has been created.'));
if (report.added.length > 0) {
console.log(chalk.dim('Contents:'));
report.added.forEach(item => console.log(chalk.dim(` ${item}`)));
} else {
// This case should ideally not happen if sourceRooDir has content
const actualContents = fs.readdirSync(targetRooDir);
if (actualContents.filter(f => !IGNORED_FILES.includes(f)).length === 0) {
console.log(chalk.dim(' (empty or contains only ignored files)'));
} else {
console.log(chalk.dim(' (populated, listing via report)'));
// Fallback or re-list if report.added is unexpectedly empty
}
}
} else {
if (report.added.length === 0 && report.overridden.length === 0) {
console.log(chalk.green('.roo/ folder is already up to date.'));
} else {
console.log(chalk.yellow('.roo/ folder updated.'));
if (report.added.length > 0) {
console.log(chalk.dim('New items added:'));
report.added.forEach(item => console.log(chalk.dim(` ${item}`)));
}
if (report.overridden.length > 0) {
console.log(chalk.dim('Items overridden:'));
report.overridden.forEach(item => console.log(chalk.dim(` ${item}`)));
}
}
}
}
function manageLlmdocsFolder(sourceLlmdocsDir, targetLlmdocsDir) {
console.log(chalk.cyan('\nProcessing llmdocs/ folder...'));
if (!fs.existsSync(sourceLlmdocsDir)) {
console.log(chalk.yellow(`Source llmdocs/ directory (${sourceLlmdocsDir}) not found. Skipping.`));
return;
}
const report = { added: [], overridden: [], existingSkipped: [] };
const isNewFolder = !fs.existsSync(targetLlmdocsDir);
if (isNewFolder) {
fs.mkdirSync(targetLlmdocsDir, { recursive: true }); // Ensure llmdocs itself is created
}
copyRecursiveSync(sourceLlmdocsDir, targetLlmdocsDir, {
override: false, // Do not override existing files
baseDestForReporting: targetLlmdocsDir, // Report paths relative to target llmdocs/
report
});
report.added.sort((a,b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
if (isNewFolder) {
console.log(chalk.green('A new llmdocs/ folder has been created.'));
if (report.added.length > 0) {
console.log(chalk.dim('Contents:'));
report.added.forEach(item => console.log(chalk.dim(` ${item}`)));
} else {
const actualContents = fs.readdirSync(targetLlmdocsDir);
if (actualContents.filter(f => !IGNORED_FILES.includes(f)).length === 0) {
console.log(chalk.dim(' (empty or contains only ignored files)'));
} else {
console.log(chalk.dim(' (populated, listing via report)'));
}
}
} else {
if (report.added.length === 0) {
console.log(chalk.green('llmdocs/ folder checked, no missing files to add.'));
} else {
console.log(chalk.yellow('llmdocs/ folder updated.'));
console.log(chalk.dim('New items added:'));
report.added.forEach(item => console.log(chalk.dim(` ${item}`)));
}
}
}
function manageChangelogFile(targetChangelogFile) {
console.log(chalk.cyan('\nProcessing CHANGELOG.md...'));
if (!fs.existsSync(targetChangelogFile)) {
fs.writeFileSync(targetChangelogFile, '', 'utf8');
console.log(chalk.green('A new CHANGELOG.md file has been created.'));
} else {
console.log(chalk.yellow('CHANGELOG.md already exists.'));
}
}
function manageRoomodesFile(sourceFile, targetFile) {
console.log(chalk.cyan('\nProcessing .roomodes file...'));
if (!fs.existsSync(sourceFile)) {
console.log(chalk.yellow(`Source .roomodes file (${sourceFile}) not found. Skipping.`));
return;
}
const newMemoryMode = {
"slug": "memory",
"name": "🧠 Memory",
"roleDefinition": "You are a technical writer specializing in creating and maintaining comprehensive documentation based on the project's codebase and structure.",
"groups": [
"read",
["edit", {
"fileRegex": "\\.md$",
"description": "Markdown files only, suitable for README.md and other documentation."
}]
],
"customInstructions": "Using the changes made by the task that called you, update all the relevant files under the 'docs' folder located in this project's root directory."
};
const newChangelogMode = {
"slug": "changelog",
"name": "📝 Changelog",
"roleDefinition": "You are a technical writer focused on maintaining an accurate and concise changelog based on the most recent changes.",
"groups": [
"read",
["edit", {
"fileRegex": "CHANGELOG.md",
"description": "CHANGELOG Markdown file"
}]
],
"customInstructions": "Using the current project version and the latest changes from the triggering task, determine the appropriate SemVer version bump and apply changelog best practices to summarize and document the updates."
};
if (fs.existsSync(targetFile)) {
console.log(chalk.yellow(`Target .roomodes file (${targetFile}) already exists. Attempting to merge...`));
let targetContent;
try {
targetContent = fs.readFileSync(targetFile, 'utf8');
} catch (readError) {
console.error(chalk.red(`Error reading target .roomodes file (${targetFile}): ${readError.message}. Proceeding with overwrite.`));
try {
fs.copyFileSync(sourceFile, targetFile);
console.log(chalk.green(`Overwrote target .roomodes file with source due to read error.`));
} catch (copyErr) {
console.error(chalk.red(`Failed to overwrite target .roomodes file (${targetFile}) after read error: ${copyErr.message}`));
}
return;
}
let targetJson;
try {
targetJson = JSON.parse(targetContent);
} catch (parseError) {
console.error(chalk.red(`Error parsing target .roomodes file (${targetFile}): ${parseError.message}. Proceeding with overwrite.`));
try {
fs.copyFileSync(sourceFile, targetFile);
console.log(chalk.green(`Overwrote target .roomodes file with source due to parsing error.`));
} catch (copyErr) {
console.error(chalk.red(`Failed to overwrite target .roomodes file (${targetFile}) after parse error: ${copyErr.message}`));
}
return;
}
if (typeof targetJson !== 'object' || targetJson === null || Array.isArray(targetJson)) {
console.error(chalk.red(`Target .roomodes content in ${targetFile} is not a JSON object. Proceeding with overwrite.`));
try {
fs.copyFileSync(sourceFile, targetFile);
console.log(chalk.green(`Overwrote target .roomodes file with source as content was not a JSON object.`));
} catch (copyErr) {
console.error(chalk.red(`Failed to overwrite target .roomodes file (${targetFile}) after content validation error: ${copyErr.message}`));
}
return;
}
// Ensure customModes is an array
if (!Array.isArray(targetJson.customModes)) {
if (Object.hasOwn(targetJson, 'customModes')) { // It existed but was not an array
console.warn(chalk.yellow(`Target .roomodes file (${targetFile}) has an invalid customModes property (was ${typeof targetJson.customModes}). Initializing as empty array.`));
}
// If customModes was missing or invalid, initialize it as an empty array.
targetJson.customModes = [];
}
const customModesArray = targetJson.customModes; // Work with the (potentially initialized) array
const memoryModeExists = customModesArray.some(mode => mode && typeof mode.slug === 'string' && mode.slug === "memory");
if (memoryModeExists) {
console.warn(chalk.yellow("A custom mode with slug 'memory' already exists in the target .roomodes file."));
// Do not modify the file further if the mode already exists,
// even if customModes was re-initialized due to being malformed.
} else {
customModesArray.push(newMemoryMode);
}
const changelogModeExists = customModesArray.some(mode => mode && typeof mode.slug === 'string' && mode.slug === "changelog");
if (changelogModeExists) {
console.warn(chalk.yellow("A custom mode with slug 'changelog' already exists in the target .roomodes file."));
// Do not modify the file further if the mode already exists,
// even if customModes was re-initialized due to being malformed.
} else {
customModesArray.push(newChangelogMode);
}
if (!memoryModeExists || !changelogModeExists) {
const msg = !memoryModeExists && !changelogModeExists
? `new custom modes 'memory' and 'changelog'` : !memoryModeExists
? `new custom mode 'memory'` : `new custom mode 'changelog'`
try {
fs.writeFileSync(targetFile, JSON.stringify(targetJson, null, 2), 'utf8');
console.log(chalk.green(`Added ${msg} to the target .roomodes file (${targetFile}).`));
} catch (writeError) {
console.error(chalk.red(`Error writing updated .roomodes file (${targetFile}): ${writeError.message}.`));
// As per instructions, no explicit fallback to overwrite here if the write itself fails.
}
}
} else {
// Target file does not exist, proceed with original behavior
try {
fs.copyFileSync(sourceFile, targetFile);
console.log(chalk.green(`.roomodes file copied from ${sourceFile} to ${targetFile} as target did not exist.`));
} catch (copyError) {
console.error(chalk.red(`Error copying source .roomodes file (${sourceFile}) to ${targetFile}: ${copyError.message}`));
}
}
}
// --- Main Execution ---
function main() {
try {
console.log(chalk.bold.blue('Roo Memory Setup Initializing...'));
// __dirname is the directory of the currently executing script (i.e., .../setup-roo-memory/index.js)
// This is correct for finding `./files` relative to the script, even when run via npx.
const sourceFilesDir = path.join(__dirname, 'files');
const targetDir = process.cwd(); // Target project's root directory
const sourceRooDir = path.join(sourceFilesDir, '.roo');
const sourceLlmdocsDir = path.join(sourceFilesDir, 'llmdocs');
const sourceRoomodesFile = path.join(sourceFilesDir, '.roomodes');
const targetRooDir = path.join(targetDir, '.roo');
const targetLlmdocsDir = path.join(targetDir, 'llmdocs');
const targetChangelogFile = path.join(targetDir, 'CHANGELOG.md');
const targetRoomodesFile = path.join(targetDir, '.roomodes');
manageRooFolder(sourceRooDir, targetRooDir);
manageLlmdocsFolder(sourceLlmdocsDir, targetLlmdocsDir);
manageChangelogFile(targetChangelogFile);
manageRoomodesFile(sourceRoomodesFile, targetRoomodesFile);
console.log(chalk.bold.green('\nSetup complete!'));
} catch (error) {
console.error(chalk.red.bold('\nAn error occurred during setup:'));
console.error(chalk.red(error.message));
if (error.stack) {
console.error(chalk.dim(error.stack));
}
process.exit(1);
}
}
main();