diff --git a/package-lock.json b/package-lock.json index 71c73066668..4a121d39846 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@mistralai/mistralai": "^1.3.6", "@modelcontextprotocol/sdk": "^1.7.0", "@types/clone-deep": "^4.0.4", + "@types/js-yaml": "^4.0.9", "@types/pdf-parse": "^1.1.4", "@types/tmp": "^0.2.6", "@types/turndown": "^5.0.5", @@ -39,6 +40,7 @@ "i18next": "^24.2.2", "isbinaryfile": "^5.0.2", "js-tiktoken": "^1.0.19", + "js-yaml": "^4.1.0", "mammoth": "^1.8.0", "monaco-vscode-textmate-theme-converter": "^0.1.7", "node-ipc": "^12.0.0", @@ -8912,6 +8914,11 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==" + }, "node_modules/@types/minimatch": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", @@ -9841,8 +9848,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-buffer-byte-length": { "version": "1.0.1", @@ -15385,7 +15391,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, diff --git a/package.json b/package.json index aa427513275..27310af328d 100644 --- a/package.json +++ b/package.json @@ -399,6 +399,7 @@ "@mistralai/mistralai": "^1.3.6", "@modelcontextprotocol/sdk": "^1.7.0", "@types/clone-deep": "^4.0.4", + "@types/js-yaml": "^4.0.9", "@types/pdf-parse": "^1.1.4", "@types/tmp": "^0.2.6", "@types/turndown": "^5.0.5", @@ -421,6 +422,7 @@ "i18next": "^24.2.2", "isbinaryfile": "^5.0.2", "js-tiktoken": "^1.0.19", + "js-yaml": "^4.1.0", "mammoth": "^1.8.0", "monaco-vscode-textmate-theme-converter": "^0.1.7", "node-ipc": "^12.0.0", diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index efa3366aee2..7427903ff9a 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -3,12 +3,14 @@ import * as path from "path" import * as fs from "fs/promises" import { customModesSettingsSchema } from "../../schemas" import { ModeConfig } from "../../shared/modes" -import { fileExistsAtPath } from "../../utils/fs" +import { fileExistsAtPath, ensureDirectory } from "../../utils/fs" import { arePathsEqual, getWorkspacePath } from "../../utils/path" import { logger } from "../../utils/logging" -import { GlobalFileNames } from "../../shared/globalFileNames" +import { GlobalFileNames, ModeFileLocations } from "../../shared/globalFileNames" +import { readYamlFile, writeYamlFile } from "../../utils/yaml" +import { loadModeFromYaml, loadAllModesFromYaml, saveModeToYaml } from "../../utils/modes" -const ROOMODES_FILENAME = ".roomodes" +const ROOMODES_FILENAME = ModeFileLocations.LEGACY_ROOMODES export class CustomModesManager { private disposables: vscode.Disposable[] = [] @@ -20,7 +22,130 @@ export class CustomModesManager { private readonly onUpdate: () => Promise, ) { // TODO: We really shouldn't have async methods in the constructor. - this.watchCustomModesFiles() + // First migrate legacy modes to YAML, then watch for changes + this.migrateToYaml() + .then(() => { + this.watchCustomModesFiles() + }) + .catch((error) => { + console.error("Failed to migrate modes to YAML:", error) + // Fall back to standard initialization + this.watchCustomModesFiles() + }) + } + + /** + * Automatically migrate modes from JSON to YAML format + */ + private async migrateToYaml(): Promise { + // First try to migrate workspace-specific modes + await this.migrateWorkspaceModes() + + // Then migrate global modes + await this.migrateGlobalModes() + } + + /** + * Migrate workspace-specific modes from .roomodes to YAML + */ + private async migrateWorkspaceModes(): Promise { + const roomodesPath = await this.getWorkspaceRoomodes() + + // Skip if no .roomodes file exists + if (!roomodesPath) return + + // Check if we've already migrated (based on a flag in globalState) + const isMigrated = this.context.globalState.get(`migrated-workspace-${roomodesPath}`, false) + if (isMigrated) return + + try { + // Read and parse .roomodes + const modesJson = await this.loadModesFromFile(roomodesPath) + if (modesJson.length === 0) return + + // Create .roo/modes directory + const workspaceRoot = getWorkspacePath() + const modesDir = path.join(workspaceRoot, ModeFileLocations.MODES_DIRECTORY) + await ensureDirectory(modesDir) + + let migratedCount = 0 + + // Process each mode + for (const mode of modesJson) { + // Look for corresponding rules file + const rulesPath = path.join(workspaceRoot, `.roorules-${mode.slug}`) + let rules = "" + + if (await fileExistsAtPath(rulesPath)) { + rules = await fs.readFile(rulesPath, "utf-8") + } + + // Write YAML file - don't include rules in the YAML file since we're keeping separate rules files + await saveModeToYaml(mode, this.context.globalStorageUri.fsPath) + migratedCount++ + + // No need to migrate rules since we're keeping them in separate files + } + + // Set flag to avoid migrating again + await this.context.globalState.update(`migrated-workspace-${roomodesPath}`, true) + + if (migratedCount > 0) { + // Notify user (subtle notification) + vscode.window.showInformationMessage( + `Successfully migrated ${migratedCount} workspace custom modes to YAML format in .roo/modes/`, + ) + } + } catch (error) { + console.error("Error during workspace mode migration:", error) + // Don't throw - we'll fall back to legacy formats + } + } + + /** + * Migrate global modes from JSON settings to YAML + */ + private async migrateGlobalModes(): Promise { + // Check if we've already migrated global modes + const isMigrated = this.context.globalState.get("migrated-global-modes", false) + if (isMigrated) return + + try { + // Get the global JSON modes file + const settingsPath = await this.getCustomModesFilePath() + const globalModes = await this.loadModesFromFile(settingsPath) + + if (globalModes.length === 0) return + + // Create global modes directory + const globalModesDir = path.join(this.context.globalStorageUri.fsPath, "modes") + await ensureDirectory(globalModesDir) + + let migratedCount = 0 + + // Process each mode + for (const mode of globalModes) { + // Skip if already in project mode + if (mode.source === "project") continue + + // Write YAML file + await saveModeToYaml(mode, this.context.globalStorageUri.fsPath) + migratedCount++ + } + + // Set flag to avoid migrating again + await this.context.globalState.update("migrated-global-modes", true) + + if (migratedCount > 0) { + // Notify user (subtle notification) + vscode.window.showInformationMessage( + `Successfully migrated ${migratedCount} global custom modes to YAML format`, + ) + } + } catch (error) { + console.error("Error during global mode migration:", error) + // Don't throw - we'll fall back to legacy formats + } } private async queueWrite(operation: () => Promise): Promise { @@ -183,37 +308,40 @@ export class CustomModesManager { } async getCustomModes(): Promise { - // Get modes from settings file + // First check for modes in YAML format + const yamlProjectModes = await loadAllModesFromYaml(true) // Workspace YAML modes + const yamlGlobalModes = await loadAllModesFromYaml(false, this.context.globalStorageUri.fsPath) // Global YAML modes + + // Then check legacy formats const settingsPath = await this.getCustomModesFilePath() const settingsModes = await this.loadModesFromFile(settingsPath) - // Get modes from .roomodes if it exists const roomodesPath = await this.getWorkspaceRoomodes() const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] - // Create maps to store modes by source - const projectModes = new Map() - const globalModes = new Map() + // Create a map to merge modes with correct precedence + // Order of precedence: YAML project > JSON project > YAML global > JSON global + const slugsMap = new Map() + + // Add in reverse order of precedence + for (const mode of settingsModes) { + slugsMap.set(mode.slug, { ...mode, source: "global" }) + } + + for (const mode of yamlGlobalModes) { + slugsMap.set(mode.slug, mode) + } - // Add project modes (they take precedence) for (const mode of roomodesModes) { - projectModes.set(mode.slug, { ...mode, source: "project" as const }) + slugsMap.set(mode.slug, { ...mode, source: "project" }) } - // Add global modes - for (const mode of settingsModes) { - if (!projectModes.has(mode.slug)) { - globalModes.set(mode.slug, { ...mode, source: "global" as const }) - } + for (const mode of yamlProjectModes) { + slugsMap.set(mode.slug, mode) } - // Combine modes in the correct order: project modes first, then global modes - const mergedModes = [ - ...roomodesModes.map((mode) => ({ ...mode, source: "project" as const })), - ...settingsModes - .filter((mode) => !projectModes.has(mode.slug)) - .map((mode) => ({ ...mode, source: "global" as const })), - ] + // Convert map back to array + const mergedModes = Array.from(slugsMap.values()) await this.context.globalState.update("customModes", mergedModes) return mergedModes @@ -221,7 +349,6 @@ export class CustomModesManager { async updateCustomMode(slug: string, config: ModeConfig): Promise { try { const isProjectMode = config.source === "project" - let targetPath: string if (isProjectMode) { const workspaceFolders = vscode.workspace.workspaceFolders @@ -229,22 +356,30 @@ export class CustomModesManager { logger.error("Failed to update project mode: No workspace folder found", { slug }) throw new Error("No workspace folder found for project-specific mode") } - const workspaceRoot = getWorkspacePath() - targetPath = path.join(workspaceRoot, ROOMODES_FILENAME) - const exists = await fileExistsAtPath(targetPath) - logger.info(`${exists ? "Updating" : "Creating"} project mode in ${ROOMODES_FILENAME}`, { - slug, - workspace: workspaceRoot, - }) - } else { - targetPath = await this.getCustomModesFilePath() + } + + // Ensure source is set correctly + const modeWithSource = { + ...config, + source: isProjectMode ? ("project" as const) : ("global" as const), } await this.queueWrite(async () => { - // Ensure source is set correctly based on target file - const modeWithSource = { - ...config, - source: isProjectMode ? ("project" as const) : ("global" as const), + // Save to YAML format + await saveModeToYaml(modeWithSource, this.context.globalStorageUri.fsPath) + + // Also update the legacy format for backward compatibility + let targetPath: string + + if (isProjectMode) { + const workspaceRoot = getWorkspacePath() + targetPath = path.join(workspaceRoot, ROOMODES_FILENAME) + logger.info(`Updating ${slug} in both YAML and legacy JSON formats`, { + slug, + workspace: workspaceRoot, + }) + } else { + targetPath = await this.getCustomModesFilePath() } await this.updateModesInFile(targetPath, (modes) => { @@ -282,12 +417,39 @@ export class CustomModesManager { } private async refreshMergedState(): Promise { - const settingsPath = await this.getCustomModesFilePath() - const roomodesPath = await this.getWorkspaceRoomodes() + // Get all modes from both formats + const yamlProjectModes = await loadAllModesFromYaml(true) // Workspace YAML modes + const yamlGlobalModes = await loadAllModesFromYaml(false, this.context.globalStorageUri.fsPath) // Global YAML modes + // Legacy JSON formats + const settingsPath = await this.getCustomModesFilePath() const settingsModes = await this.loadModesFromFile(settingsPath) + + const roomodesPath = await this.getWorkspaceRoomodes() const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] - const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes) + + // Use the same merging logic as in getCustomModes + const slugsMap = new Map() + + // Add in reverse order of precedence + for (const mode of settingsModes) { + slugsMap.set(mode.slug, { ...mode, source: "global" }) + } + + for (const mode of yamlGlobalModes) { + slugsMap.set(mode.slug, mode) + } + + for (const mode of roomodesModes) { + slugsMap.set(mode.slug, { ...mode, source: "project" }) + } + + for (const mode of yamlProjectModes) { + slugsMap.set(mode.slug, mode) + } + + // Convert map back to array + const mergedModes = Array.from(slugsMap.values()) await this.context.globalState.update("customModes", mergedModes) await this.onUpdate() @@ -295,27 +457,46 @@ export class CustomModesManager { async deleteCustomMode(slug: string): Promise { try { + // Check legacy JSON formats const settingsPath = await this.getCustomModesFilePath() const roomodesPath = await this.getWorkspaceRoomodes() const settingsModes = await this.loadModesFromFile(settingsPath) const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] - // Find the mode in either file + // Find the mode in either JSON file const projectMode = roomodesModes.find((m) => m.slug === slug) const globalMode = settingsModes.find((m) => m.slug === slug) - if (!projectMode && !globalMode) { - throw new Error("Write error: Mode not found") + // Also check YAML formats + const workspaceRoot = getWorkspacePath() + const projectYamlPath = path.join(workspaceRoot, ModeFileLocations.MODES_DIRECTORY, `${slug}.yaml`) + const globalYamlPath = path.join(this.context.globalStorageUri.fsPath, "modes", `${slug}.yaml`) + + const projectYamlExists = await fileExistsAtPath(projectYamlPath) + const globalYamlExists = await fileExistsAtPath(globalYamlPath) + + if (!projectMode && !globalMode && !projectYamlExists && !globalYamlExists) { + throw new Error("Write error: Mode not found in any format") } await this.queueWrite(async () => { - // Delete from project first if it exists there + // Delete from YAML formats first + if (projectYamlExists) { + await fs.unlink(projectYamlPath) + logger.info(`Deleted YAML mode file: ${projectYamlPath}`) + } + + if (globalYamlExists) { + await fs.unlink(globalYamlPath) + logger.info(`Deleted YAML mode file: ${globalYamlPath}`) + } + + // Delete from legacy JSON formats for backward compatibility if (projectMode && roomodesPath) { await this.updateModesInFile(roomodesPath, (modes) => modes.filter((m) => m.slug !== slug)) } - // Delete from global settings if it exists there if (globalMode) { await this.updateModesInFile(settingsPath, (modes) => modes.filter((m) => m.slug !== slug)) } @@ -323,9 +504,9 @@ export class CustomModesManager { await this.refreshMergedState() }) } catch (error) { - vscode.window.showErrorMessage( - `Failed to delete custom mode: ${error instanceof Error ? error.message : String(error)}`, - ) + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error("Failed to delete custom mode", { slug, error: errorMessage }) + vscode.window.showErrorMessage(`Failed to delete custom mode: ${errorMessage}`) } } @@ -337,14 +518,50 @@ export class CustomModesManager { async resetCustomModes(): Promise { try { + // Clear legacy JSON format const filePath = await this.getCustomModesFilePath() await fs.writeFile(filePath, JSON.stringify({ customModes: [] }, null, 2)) + + // Clear global YAML modes + try { + const globalModesDir = path.join(this.context.globalStorageUri.fsPath, "modes") + if (await fileExistsAtPath(globalModesDir)) { + const files = await fs.readdir(globalModesDir) + for (const file of files) { + if (file.endsWith(".yaml") || file.endsWith(".yml")) { + await fs.unlink(path.join(globalModesDir, file)) + logger.info(`Deleted global YAML mode: ${file}`) + } + } + } + } catch (yamlError) { + console.error("Error clearing global YAML modes:", yamlError) + } + + // Clear workspace YAML modes + try { + const workspaceRoot = getWorkspacePath() + const workspaceModesDir = path.join(workspaceRoot, ModeFileLocations.MODES_DIRECTORY) + if (await fileExistsAtPath(workspaceModesDir)) { + const files = await fs.readdir(workspaceModesDir) + for (const file of files) { + if (file.endsWith(".yaml") || file.endsWith(".yml")) { + await fs.unlink(path.join(workspaceModesDir, file)) + logger.info(`Deleted workspace YAML mode: ${file}`) + } + } + } + } catch (yamlError) { + console.error("Error clearing workspace YAML modes:", yamlError) + } + + // Update global state await this.context.globalState.update("customModes", []) await this.onUpdate() } catch (error) { - vscode.window.showErrorMessage( - `Failed to reset custom modes: ${error instanceof Error ? error.message : String(error)}`, - ) + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error("Failed to reset custom modes", { error: errorMessage }) + vscode.window.showErrorMessage(`Failed to reset custom modes: ${errorMessage}`) } } diff --git a/src/shared/globalFileNames.ts b/src/shared/globalFileNames.ts index f26174d224c..f1b61cc14f0 100644 --- a/src/shared/globalFileNames.ts +++ b/src/shared/globalFileNames.ts @@ -8,3 +8,11 @@ export const GlobalFileNames = { unboundModels: "unbound_models.json", customModes: "custom_modes.json", } + +/** + * File locations for mode configuration + */ +export enum ModeFileLocations { + LEGACY_ROOMODES = ".roomodes", + MODES_DIRECTORY = ".roo/modes", +} diff --git a/src/utils/fs.ts b/src/utils/fs.ts index 9f7af84e4af..b891f926cb9 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -45,3 +45,13 @@ export async function fileExistsAtPath(filePath: string): Promise { return false } } + +/** + * Ensures a directory exists, creating it if it doesn't + * + * @param dirPath - The path to the directory to ensure exists + * @returns A promise that resolves when the directory exists + */ +export async function ensureDirectory(dirPath: string): Promise { + await fs.mkdir(dirPath, { recursive: true }) +} diff --git a/src/utils/modes.ts b/src/utils/modes.ts new file mode 100644 index 00000000000..465fbc479f9 --- /dev/null +++ b/src/utils/modes.ts @@ -0,0 +1,126 @@ +import * as fs from "fs/promises" +import * as path from "path" +import { ModeConfig } from "../shared/modes" +import { ModeFileLocations } from "../shared/globalFileNames" +import { fileExistsAtPath, ensureDirectory } from "./fs" +import { readYamlFile, writeYamlFile } from "./yaml" +import { getWorkspacePath } from "./path" + +/** + * Get the directory path for storing mode YAML files + * + * @param workspace - Whether to use workspace or global directory + * @returns The path to the modes directory + */ +export async function getModesDirectory(workspace = false, globalStoragePath?: string): Promise { + if (workspace) { + const workspaceRoot = getWorkspacePath() + const modesDir = path.join(workspaceRoot, ModeFileLocations.MODES_DIRECTORY) + await ensureDirectory(modesDir) + return modesDir + } else if (globalStoragePath) { + const modesDir = path.join(globalStoragePath, "modes") + await ensureDirectory(modesDir) + return modesDir + } else { + throw new Error("Global storage path is required for global modes directory") + } +} + +/** + * Get the file path for a specific mode's YAML file + * + * @param slug - The slug of the mode + * @param workspace - Whether to use workspace or global directory + * @returns The path to the mode YAML file + */ +export async function getModeYamlPath(slug: string, workspace = false, globalStoragePath?: string): Promise { + const modesDir = await getModesDirectory(workspace, globalStoragePath) + return path.join(modesDir, `${slug}.yaml`) +} + +/** + * Load a mode from its YAML file + * + * @param slug - The slug of the mode to load + * @param workspace - Whether to load from workspace or global directory + * @returns The loaded mode or null if not found + */ +export async function loadModeFromYaml( + slug: string, + workspace = false, + globalStoragePath?: string, +): Promise { + const yamlPath = await getModeYamlPath(slug, workspace, globalStoragePath) + try { + const exists = await fileExistsAtPath(yamlPath) + if (!exists) return null + + const modeData = await readYamlFile>(yamlPath, {} as Omit) + if (modeData) { + return { + ...modeData, + source: workspace ? "project" : "global", + } + } + } catch (error) { + console.error(`Failed to load mode from YAML: ${yamlPath}`, error) + } + return null +} + +/** + * Save a mode to its YAML file + * + * @param mode - The mode to save + * @param globalStoragePath - The path to global storage (required for global modes) + */ +export async function saveModeToYaml(mode: ModeConfig, globalStoragePath?: string): Promise { + const workspace = mode.source === "project" + const yamlPath = await getModeYamlPath(mode.slug, workspace, globalStoragePath) + + // Extract source and rules from the mode data + const { source, ...modeData } = mode + + await writeYamlFile(yamlPath, modeData) +} + +/** + * Load all YAML mode files from a directory + * + * @param workspace - Whether to load from workspace or global directory + * @returns Array of loaded modes + */ +export async function loadAllModesFromYaml(workspace: boolean, globalStoragePath?: string): Promise { + const modes: ModeConfig[] = [] + try { + const modesDir = await getModesDirectory(workspace, globalStoragePath) + const exists = await fileExistsAtPath(modesDir) + + if (exists) { + const files = await fs.readdir(modesDir) + for (const file of files) { + if (file.endsWith(".yaml") || file.endsWith(".yml")) { + const slug = path.basename(file, path.extname(file)) + const mode = await loadModeFromYaml(slug, workspace, globalStoragePath) + if (mode) modes.push(mode) + } + } + } + } catch (error) { + console.error(`Error loading YAML modes from ${workspace ? "workspace" : "global"} directory:`, error) + } + + return modes +} + +/** + * Migrate a mode from JSON to YAML format + * + * @param mode - The mode to migrate + * @param globalStoragePath - The path to global storage (required for global modes) + */ +export async function migrateModeToYaml(mode: ModeConfig, globalStoragePath?: string): Promise { + // Simply save the mode to YAML format + await saveModeToYaml(mode, globalStoragePath) +} diff --git a/src/utils/yaml.ts b/src/utils/yaml.ts new file mode 100644 index 00000000000..ef74ef99e5e --- /dev/null +++ b/src/utils/yaml.ts @@ -0,0 +1,40 @@ +import fs from "fs/promises" +import yaml from "js-yaml" +import { fileExistsAtPath } from "./fs" + +/** + * Read a YAML file and parse its contents + * @param filePath Path to the YAML file + * @param defaultValue Default value to return if the file doesn't exist or can't be parsed + * @returns Parsed YAML content or the default value + */ +export async function readYamlFile(filePath: string, defaultValue: T): Promise { + try { + if (await fileExistsAtPath(filePath)) { + const content = await fs.readFile(filePath, "utf-8") + return yaml.load(content) as T + } + } catch (error) { + console.error(`Error reading YAML file ${filePath}:`, error) + } + return defaultValue +} + +/** + * Write data to a YAML file + * @param filePath Path to the YAML file + * @param data Data to write + */ +export async function writeYamlFile(filePath: string, data: T): Promise { + try { + const content = yaml.dump(data, { + lineWidth: -1, // Don't wrap lines + noRefs: true, // Don't use references + indent: 2, // 2-space indentation + }) + await fs.writeFile(filePath, content, "utf-8") + } catch (error) { + console.error(`Error writing YAML file ${filePath}:`, error) + throw error + } +}