|
|
@@ -558,49 +558,55 @@ export class CustomModesManager {
|
|
|
*/
|
|
|
public async checkRulesDirectoryHasContent(slug: string): Promise<boolean> {
|
|
|
try {
|
|
|
- // Get workspace path
|
|
|
- const workspacePath = getWorkspacePath()
|
|
|
- if (!workspacePath) {
|
|
|
- return false
|
|
|
- }
|
|
|
+ // First, find the mode to determine its source
|
|
|
+ const allModes = await this.getCustomModes()
|
|
|
+ const mode = allModes.find((m) => m.slug === slug)
|
|
|
|
|
|
- // Check if .roomodes file exists and contains this mode
|
|
|
- // This ensures we can only consolidate rules for modes that have been customized
|
|
|
- const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
|
|
|
- try {
|
|
|
- const roomodesExists = await fileExistsAtPath(roomodesPath)
|
|
|
- if (roomodesExists) {
|
|
|
- const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
|
|
|
- const roomodesData = yaml.parse(roomodesContent)
|
|
|
- const roomodesModes = roomodesData?.customModes || []
|
|
|
-
|
|
|
- // Check if this specific mode exists in .roomodes
|
|
|
- const modeInRoomodes = roomodesModes.find((m: any) => m.slug === slug)
|
|
|
- if (!modeInRoomodes) {
|
|
|
- return false // Mode not customized in .roomodes, cannot consolidate
|
|
|
- }
|
|
|
- } else {
|
|
|
- // If no .roomodes file exists, check if it's in global custom modes
|
|
|
- const allModes = await this.getCustomModes()
|
|
|
- const mode = allModes.find((m) => m.slug === slug)
|
|
|
+ if (!mode) {
|
|
|
+ // If not in custom modes, check if it's in .roomodes (project-specific)
|
|
|
+ const workspacePath = getWorkspacePath()
|
|
|
+ if (!workspacePath) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
|
|
|
- if (!mode) {
|
|
|
- return false // Not a custom mode, cannot consolidate
|
|
|
+ const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
|
|
|
+ try {
|
|
|
+ const roomodesExists = await fileExistsAtPath(roomodesPath)
|
|
|
+ if (roomodesExists) {
|
|
|
+ const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
|
|
|
+ const roomodesData = yaml.parse(roomodesContent)
|
|
|
+ const roomodesModes = roomodesData?.customModes || []
|
|
|
+
|
|
|
+ // Check if this specific mode exists in .roomodes
|
|
|
+ const modeInRoomodes = roomodesModes.find((m: any) => m.slug === slug)
|
|
|
+ if (!modeInRoomodes) {
|
|
|
+ return false // Mode not found anywhere
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ return false // No .roomodes file and not in custom modes
|
|
|
}
|
|
|
+ } catch (error) {
|
|
|
+ return false // Cannot read .roomodes and not in custom modes
|
|
|
}
|
|
|
- } catch (error) {
|
|
|
- // If we can't read .roomodes, fall back to checking custom modes
|
|
|
- const allModes = await this.getCustomModes()
|
|
|
- const mode = allModes.find((m) => m.slug === slug)
|
|
|
+ }
|
|
|
|
|
|
- if (!mode) {
|
|
|
- return false // Not a custom mode, cannot consolidate
|
|
|
+ // Determine the correct rules directory based on mode source
|
|
|
+ let modeRulesDir: string
|
|
|
+ const isGlobalMode = mode?.source === "global"
|
|
|
+
|
|
|
+ if (isGlobalMode) {
|
|
|
+ // For global modes, check in global .roo directory
|
|
|
+ const globalRooDir = getGlobalRooDirectory()
|
|
|
+ modeRulesDir = path.join(globalRooDir, `rules-${slug}`)
|
|
|
+ } else {
|
|
|
+ // For project modes, check in workspace .roo directory
|
|
|
+ const workspacePath = getWorkspacePath()
|
|
|
+ if (!workspacePath) {
|
|
|
+ return false
|
|
|
}
|
|
|
+ modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`)
|
|
|
}
|
|
|
|
|
|
- // Check for .roo/rules-{slug}/ directory
|
|
|
- const modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`)
|
|
|
-
|
|
|
try {
|
|
|
const stats = await fs.stat(modeRulesDir)
|
|
|
if (!stats.isDirectory()) {
|
|
|
@@ -655,24 +661,23 @@ export class CustomModesManager {
|
|
|
|
|
|
// If mode not found in custom modes, check if it's a built-in mode that has been customized
|
|
|
if (!mode) {
|
|
|
+ // Only check workspace-based modes if workspace is available
|
|
|
const workspacePath = getWorkspacePath()
|
|
|
- if (!workspacePath) {
|
|
|
- return { success: false, error: "No workspace found" }
|
|
|
- }
|
|
|
-
|
|
|
- const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
|
|
|
- try {
|
|
|
- const roomodesExists = await fileExistsAtPath(roomodesPath)
|
|
|
- if (roomodesExists) {
|
|
|
- const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
|
|
|
- const roomodesData = yaml.parse(roomodesContent)
|
|
|
- const roomodesModes = roomodesData?.customModes || []
|
|
|
-
|
|
|
- // Find the mode in .roomodes
|
|
|
- mode = roomodesModes.find((m: any) => m.slug === slug)
|
|
|
+ if (workspacePath) {
|
|
|
+ const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
|
|
|
+ try {
|
|
|
+ const roomodesExists = await fileExistsAtPath(roomodesPath)
|
|
|
+ if (roomodesExists) {
|
|
|
+ const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
|
|
|
+ const roomodesData = yaml.parse(roomodesContent)
|
|
|
+ const roomodesModes = roomodesData?.customModes || []
|
|
|
+
|
|
|
+ // Find the mode in .roomodes
|
|
|
+ mode = roomodesModes.find((m: any) => m.slug === slug)
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ // Continue to check built-in modes
|
|
|
}
|
|
|
- } catch (error) {
|
|
|
- // Continue to check built-in modes
|
|
|
}
|
|
|
|
|
|
// If still not found, check if it's a built-in mode
|
|
|
@@ -687,14 +692,25 @@ export class CustomModesManager {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Get workspace path
|
|
|
- const workspacePath = getWorkspacePath()
|
|
|
- if (!workspacePath) {
|
|
|
- return { success: false, error: "No workspace found" }
|
|
|
+ // Determine the base directory based on mode source
|
|
|
+ const isGlobalMode = mode.source === "global"
|
|
|
+ let baseDir: string
|
|
|
+ if (isGlobalMode) {
|
|
|
+ // For global modes, use the global .roo directory
|
|
|
+ baseDir = getGlobalRooDirectory()
|
|
|
+ } else {
|
|
|
+ // For project modes, use the workspace directory
|
|
|
+ const workspacePath = getWorkspacePath()
|
|
|
+ if (!workspacePath) {
|
|
|
+ return { success: false, error: "No workspace found" }
|
|
|
+ }
|
|
|
+ baseDir = workspacePath
|
|
|
}
|
|
|
|
|
|
- // Check for .roo/rules-{slug}/ directory
|
|
|
- const modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`)
|
|
|
+ // Check for .roo/rules-{slug}/ directory (or rules-{slug}/ for global)
|
|
|
+ const modeRulesDir = isGlobalMode
|
|
|
+ ? path.join(baseDir, `rules-${slug}`)
|
|
|
+ : path.join(baseDir, ".roo", `rules-${slug}`)
|
|
|
|
|
|
let rulesFiles: RuleFile[] = []
|
|
|
try {
|
|
|
@@ -709,8 +725,10 @@ export class CustomModesManager {
|
|
|
const filePath = path.join(modeRulesDir, entry.name)
|
|
|
const content = await fs.readFile(filePath, "utf-8")
|
|
|
if (content.trim()) {
|
|
|
- // Calculate relative path from .roo directory
|
|
|
- const relativePath = path.relative(path.join(workspacePath, ".roo"), filePath)
|
|
|
+ // Calculate relative path based on mode source
|
|
|
+ const relativePath = isGlobalMode
|
|
|
+ ? path.relative(baseDir, filePath)
|
|
|
+ : path.relative(path.join(baseDir, ".roo"), filePath)
|
|
|
rulesFiles.push({ relativePath, content: content.trim() })
|
|
|
}
|
|
|
}
|
|
|
@@ -755,6 +773,77 @@ export class CustomModesManager {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Helper method to import rules files for a mode
|
|
|
+ * @param importMode - The mode being imported
|
|
|
+ * @param rulesFiles - The rules files to import
|
|
|
+ * @param source - The import source ("global" or "project")
|
|
|
+ */
|
|
|
+ private async importRulesFiles(
|
|
|
+ importMode: ExportedModeConfig,
|
|
|
+ rulesFiles: RuleFile[],
|
|
|
+ source: "global" | "project",
|
|
|
+ ): Promise<void> {
|
|
|
+ // Determine base directory and rules folder path based on source
|
|
|
+ let baseDir: string
|
|
|
+ let rulesFolderPath: string
|
|
|
+
|
|
|
+ if (source === "global") {
|
|
|
+ baseDir = getGlobalRooDirectory()
|
|
|
+ rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`)
|
|
|
+ } else {
|
|
|
+ const workspacePath = getWorkspacePath()
|
|
|
+ baseDir = path.join(workspacePath, ".roo")
|
|
|
+ rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Always remove the existing rules folder for this mode if it exists
|
|
|
+ // This ensures that if the imported mode has no rules, the folder is cleaned up
|
|
|
+ try {
|
|
|
+ await fs.rm(rulesFolderPath, { recursive: true, force: true })
|
|
|
+ logger.info(`Removed existing ${source} rules folder for mode ${importMode.slug}`)
|
|
|
+ } catch (error) {
|
|
|
+ // It's okay if the folder doesn't exist
|
|
|
+ logger.debug(`No existing ${source} rules folder to remove for mode ${importMode.slug}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Only proceed with file creation if there are rules files to import
|
|
|
+ if (!rulesFiles || !Array.isArray(rulesFiles) || rulesFiles.length === 0) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Import the new rules files with path validation
|
|
|
+ for (const ruleFile of rulesFiles) {
|
|
|
+ if (ruleFile.relativePath && ruleFile.content) {
|
|
|
+ // Validate the relative path to prevent path traversal attacks
|
|
|
+ const normalizedRelativePath = path.normalize(ruleFile.relativePath)
|
|
|
+
|
|
|
+ // Ensure the path doesn't contain traversal sequences
|
|
|
+ if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) {
|
|
|
+ logger.error(`Invalid file path detected: ${ruleFile.relativePath}`)
|
|
|
+ continue // Skip this file but continue with others
|
|
|
+ }
|
|
|
+
|
|
|
+ const targetPath = path.join(baseDir, normalizedRelativePath)
|
|
|
+ const normalizedTargetPath = path.normalize(targetPath)
|
|
|
+ const expectedBasePath = path.normalize(baseDir)
|
|
|
+
|
|
|
+ // Ensure the resolved path stays within the base directory
|
|
|
+ if (!normalizedTargetPath.startsWith(expectedBasePath)) {
|
|
|
+ logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`)
|
|
|
+ continue // Skip this file but continue with others
|
|
|
+ }
|
|
|
+
|
|
|
+ // Ensure directory exists
|
|
|
+ const targetDir = path.dirname(targetPath)
|
|
|
+ await fs.mkdir(targetDir, { recursive: true })
|
|
|
+
|
|
|
+ // Write the file
|
|
|
+ await fs.writeFile(targetPath, ruleFile.content, "utf-8")
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Imports modes from YAML content, including their associated rules files
|
|
|
* @param yamlContent - The YAML content containing mode configurations
|
|
|
@@ -821,100 +910,8 @@ export class CustomModesManager {
|
|
|
source: source, // Use the provided source parameter
|
|
|
})
|
|
|
|
|
|
- // Handle project-level imports
|
|
|
- if (source === "project") {
|
|
|
- const workspacePath = getWorkspacePath()
|
|
|
-
|
|
|
- // Always remove the existing rules folder for this mode if it exists
|
|
|
- // This ensures that if the imported mode has no rules, the folder is cleaned up
|
|
|
- const rulesFolderPath = path.join(workspacePath, ".roo", `rules-${importMode.slug}`)
|
|
|
- try {
|
|
|
- await fs.rm(rulesFolderPath, { recursive: true, force: true })
|
|
|
- logger.info(`Removed existing rules folder for mode ${importMode.slug}`)
|
|
|
- } catch (error) {
|
|
|
- // It's okay if the folder doesn't exist
|
|
|
- logger.debug(`No existing rules folder to remove for mode ${importMode.slug}`)
|
|
|
- }
|
|
|
-
|
|
|
- // Only create new rules files if they exist in the import
|
|
|
- if (rulesFiles && Array.isArray(rulesFiles) && rulesFiles.length > 0) {
|
|
|
- // Import the new rules files with path validation
|
|
|
- for (const ruleFile of rulesFiles) {
|
|
|
- if (ruleFile.relativePath && ruleFile.content) {
|
|
|
- // Validate the relative path to prevent path traversal attacks
|
|
|
- const normalizedRelativePath = path.normalize(ruleFile.relativePath)
|
|
|
-
|
|
|
- // Ensure the path doesn't contain traversal sequences
|
|
|
- if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) {
|
|
|
- logger.error(`Invalid file path detected: ${ruleFile.relativePath}`)
|
|
|
- continue // Skip this file but continue with others
|
|
|
- }
|
|
|
-
|
|
|
- const targetPath = path.join(workspacePath, ".roo", normalizedRelativePath)
|
|
|
- const normalizedTargetPath = path.normalize(targetPath)
|
|
|
- const expectedBasePath = path.normalize(path.join(workspacePath, ".roo"))
|
|
|
-
|
|
|
- // Ensure the resolved path stays within the .roo directory
|
|
|
- if (!normalizedTargetPath.startsWith(expectedBasePath)) {
|
|
|
- logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`)
|
|
|
- continue // Skip this file but continue with others
|
|
|
- }
|
|
|
-
|
|
|
- // Ensure directory exists
|
|
|
- const targetDir = path.dirname(targetPath)
|
|
|
- await fs.mkdir(targetDir, { recursive: true })
|
|
|
-
|
|
|
- // Write the file
|
|
|
- await fs.writeFile(targetPath, ruleFile.content, "utf-8")
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- } else if (source === "global" && rulesFiles && Array.isArray(rulesFiles)) {
|
|
|
- // For global imports, preserve the rules files structure in the global .roo directory
|
|
|
- const globalRooDir = getGlobalRooDirectory()
|
|
|
-
|
|
|
- // Always remove the existing rules folder for this mode if it exists
|
|
|
- // This ensures that if the imported mode has no rules, the folder is cleaned up
|
|
|
- const rulesFolderPath = path.join(globalRooDir, `rules-${importMode.slug}`)
|
|
|
- try {
|
|
|
- await fs.rm(rulesFolderPath, { recursive: true, force: true })
|
|
|
- logger.info(`Removed existing global rules folder for mode ${importMode.slug}`)
|
|
|
- } catch (error) {
|
|
|
- // It's okay if the folder doesn't exist
|
|
|
- logger.debug(`No existing global rules folder to remove for mode ${importMode.slug}`)
|
|
|
- }
|
|
|
-
|
|
|
- // Import the new rules files with path validation
|
|
|
- for (const ruleFile of rulesFiles) {
|
|
|
- if (ruleFile.relativePath && ruleFile.content) {
|
|
|
- // Validate the relative path to prevent path traversal attacks
|
|
|
- const normalizedRelativePath = path.normalize(ruleFile.relativePath)
|
|
|
-
|
|
|
- // Ensure the path doesn't contain traversal sequences
|
|
|
- if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) {
|
|
|
- logger.error(`Invalid file path detected: ${ruleFile.relativePath}`)
|
|
|
- continue // Skip this file but continue with others
|
|
|
- }
|
|
|
-
|
|
|
- const targetPath = path.join(globalRooDir, normalizedRelativePath)
|
|
|
- const normalizedTargetPath = path.normalize(targetPath)
|
|
|
- const expectedBasePath = path.normalize(globalRooDir)
|
|
|
-
|
|
|
- // Ensure the resolved path stays within the global .roo directory
|
|
|
- if (!normalizedTargetPath.startsWith(expectedBasePath)) {
|
|
|
- logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`)
|
|
|
- continue // Skip this file but continue with others
|
|
|
- }
|
|
|
-
|
|
|
- // Ensure directory exists
|
|
|
- const targetDir = path.dirname(targetPath)
|
|
|
- await fs.mkdir(targetDir, { recursive: true })
|
|
|
-
|
|
|
- // Write the file
|
|
|
- await fs.writeFile(targetPath, ruleFile.content, "utf-8")
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
+ // Import rules files (this also handles cleanup of existing rules folders)
|
|
|
+ await this.importRulesFiles(importMode, rulesFiles || [], source)
|
|
|
}
|
|
|
|
|
|
// Refresh the modes after import
|