Bladeren bron

Use yaml as default custom modes format (#3749)

Matt Rubens 7 maanden geleden
bovenliggende
commit
737bad6061

+ 5 - 0
.changeset/gold-meals-tell.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Use YAML as default custom modes format

+ 165 - 26
src/__tests__/migrateSettings.test.ts

@@ -7,7 +7,13 @@ import { migrateSettings } from "../utils/migrateSettings"
 
 // Mock dependencies
 jest.mock("vscode")
-jest.mock("fs/promises")
+jest.mock("fs/promises", () => ({
+	mkdir: jest.fn().mockResolvedValue(undefined),
+	readFile: jest.fn(),
+	writeFile: jest.fn().mockResolvedValue(undefined),
+	rename: jest.fn().mockResolvedValue(undefined),
+	unlink: jest.fn().mockResolvedValue(undefined),
+}))
 jest.mock("fs")
 jest.mock("../utils/fs")
 
@@ -18,11 +24,12 @@ describe("Settings Migration", () => {
 	const mockSettingsDir = path.join(mockStoragePath, "settings")
 
 	// Legacy file names
-	const legacyCustomModesPath = path.join(mockSettingsDir, "cline_custom_modes.json")
+	const legacyCustomModesJson = path.join(mockSettingsDir, "custom_modes.json")
+	const legacyClineCustomModesPath = path.join(mockSettingsDir, "cline_custom_modes.json")
 	const legacyMcpSettingsPath = path.join(mockSettingsDir, "cline_mcp_settings.json")
 
 	// New file names
-	const newCustomModesPath = path.join(mockSettingsDir, GlobalFileNames.customModes)
+	const newCustomModesYaml = path.join(mockSettingsDir, GlobalFileNames.customModes)
 	const newMcpSettingsPath = path.join(mockSettingsDir, GlobalFileNames.mcpSettings)
 
 	beforeEach(() => {
@@ -43,49 +50,66 @@ describe("Settings Migration", () => {
 			globalStorageUri: { fsPath: mockStoragePath },
 		} as unknown as vscode.ExtensionContext
 
-		// The fs/promises mock is already set up in src/__mocks__/fs/promises.ts
-		// We don't need to manually mock these methods
-
 		// Set global outputChannel for all tests
 		;(global as any).outputChannel = mockOutputChannel
 	})
 
 	it("should migrate custom modes file if old file exists and new file doesn't", async () => {
-		// Mock file existence checks
+		// Clear all previous mocks to ensure clean test environment
+		jest.clearAllMocks()
+
+		// Setup mock for rename function
+		const mockRename = (fs.rename as jest.Mock).mockResolvedValue(undefined)
+
+		// Mock file existence checks - only return true for paths we want to exist
 		;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
 			if (path === mockSettingsDir) return true
-			if (path === legacyCustomModesPath) return true
-			if (path === newCustomModesPath) return false
-			return false
+			if (path === legacyClineCustomModesPath) return true
+			return false // All other paths don't exist, including destination files
 		})
 
+		// Run the migration
 		await migrateSettings(mockContext, mockOutputChannel)
 
-		// Verify file was renamed
-		expect(fs.rename).toHaveBeenCalledWith(legacyCustomModesPath, newCustomModesPath)
+		// Verify expected rename call - cline_custom_modes.json should be renamed to custom_modes.json
+		expect(mockRename).toHaveBeenCalledWith(legacyClineCustomModesPath, legacyCustomModesJson)
 	})
 
 	it("should migrate MCP settings file if old file exists and new file doesn't", async () => {
-		// Mock file existence checks
+		// Clear all previous mocks to ensure clean test environment
+		jest.clearAllMocks()
+
+		// Setup mock for rename function
+		const mockRename = (fs.rename as jest.Mock).mockResolvedValue(undefined)
+
+		// Ensure the other files don't interfere with this test
 		;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
 			if (path === mockSettingsDir) return true
 			if (path === legacyMcpSettingsPath) return true
-			if (path === newMcpSettingsPath) return false
-			return false
+			if (path === legacyClineCustomModesPath) return false // Ensure this file doesn't exist
+			if (path === legacyCustomModesJson) return false // Ensure this file doesn't exist
+			return false // All other paths don't exist, including destination files
 		})
 
+		// Run the migration
 		await migrateSettings(mockContext, mockOutputChannel)
 
-		// Verify file was renamed
-		expect(fs.rename).toHaveBeenCalledWith(legacyMcpSettingsPath, newMcpSettingsPath)
+		// Verify expected rename call
+		expect(mockRename).toHaveBeenCalledWith(legacyMcpSettingsPath, newMcpSettingsPath)
 	})
 
 	it("should not migrate if new file already exists", async () => {
-		// Mock file existence checks
+		// Clear all previous mocks to ensure clean test environment
+		jest.clearAllMocks()
+
+		// Setup mock for rename function
+		const mockRename = (fs.rename as jest.Mock).mockResolvedValue(undefined)
+
+		// Mock file existence checks - both source and destination exist
 		;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
 			if (path === mockSettingsDir) return true
-			if (path === legacyCustomModesPath) return true
-			if (path === newCustomModesPath) return true
+			if (path === legacyClineCustomModesPath) return true
+			if (path === legacyCustomModesJson) return true // Destination already exists
 			if (path === legacyMcpSettingsPath) return true
 			if (path === newMcpSettingsPath) return true
 			return false
@@ -93,16 +117,16 @@ describe("Settings Migration", () => {
 
 		await migrateSettings(mockContext, mockOutputChannel)
 
-		// Verify no files were renamed
-		expect(fs.rename).not.toHaveBeenCalled()
+		// Verify rename was not called since destination files exist
+		expect(mockRename).not.toHaveBeenCalled()
 	})
 
 	it("should handle errors gracefully", async () => {
-		// Mock file existence checks to throw an error
-		;(fileExistsAtPath as jest.Mock).mockRejectedValue(new Error("Test error"))
+		// Clear mocks
+		jest.clearAllMocks()
 
-		// Set the global outputChannel for the test
-		;(global as any).outputChannel = mockOutputChannel
+		// Mock file existence to throw error
+		;(fileExistsAtPath as jest.Mock).mockRejectedValue(new Error("Test error"))
 
 		await migrateSettings(mockContext, mockOutputChannel)
 
@@ -111,4 +135,119 @@ describe("Settings Migration", () => {
 			expect.stringContaining("Error migrating settings files"),
 		)
 	})
+
+	it("should convert custom_modes.json to YAML format", async () => {
+		// Clear all previous mocks to ensure clean test environment
+		jest.clearAllMocks()
+
+		const testJsonContent = JSON.stringify({ customModes: [{ slug: "test-mode", name: "Test Mode" }] })
+
+		// Setup mock functions
+		const mockWrite = (fs.writeFile as jest.Mock).mockResolvedValue(undefined)
+		const mockUnlink = (fs.unlink as jest.Mock).mockResolvedValue(undefined)
+
+		// Mock file read to return JSON content
+		;(fs.readFile as jest.Mock).mockImplementation(async (path: any) => {
+			if (path === legacyCustomModesJson) {
+				return testJsonContent
+			}
+			throw new Error("File not found: " + path)
+		})
+
+		// Isolate this test by making sure only the specific JSON file exists
+		;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
+			if (path === mockSettingsDir) return true
+			if (path === legacyCustomModesJson) return true
+			if (path === legacyClineCustomModesPath) return false
+			if (path === legacyMcpSettingsPath) return false
+			return false
+		})
+
+		await migrateSettings(mockContext, mockOutputChannel)
+
+		// Verify file operations
+		expect(mockWrite).toHaveBeenCalledWith(newCustomModesYaml, expect.any(String), "utf-8")
+		// We don't delete the original JSON file to allow for rollback
+		expect(mockUnlink).not.toHaveBeenCalled()
+
+		// Verify log message mentions preservation of original file
+		expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(
+			expect.stringContaining("original JSON file preserved for rollback purposes"),
+		)
+	})
+
+	it("should handle corrupt JSON gracefully", async () => {
+		// Clear all previous mocks to ensure clean test environment
+		jest.clearAllMocks()
+
+		// Setup mock functions
+		const mockWrite = (fs.writeFile as jest.Mock).mockResolvedValue(undefined)
+		const mockUnlink = (fs.unlink as jest.Mock).mockResolvedValue(undefined)
+
+		// Mock file read to return corrupt JSON
+		;(fs.readFile as jest.Mock).mockImplementation(async (path: any) => {
+			if (path === legacyCustomModesJson) {
+				return "{ invalid json content" // This will cause an error when parsed
+			}
+			throw new Error("File not found: " + path)
+		})
+
+		// Isolate this test
+		;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
+			if (path === mockSettingsDir) return true
+			if (path === legacyCustomModesJson) return true
+			if (path === legacyClineCustomModesPath) return false
+			if (path === legacyMcpSettingsPath) return false
+			return false
+		})
+
+		await migrateSettings(mockContext, mockOutputChannel)
+
+		// Verify error was logged
+		expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(
+			expect.stringContaining("Error parsing custom_modes.json"),
+		)
+
+		// Verify no write/unlink operations were performed
+		expect(mockWrite).not.toHaveBeenCalled()
+		expect(mockUnlink).not.toHaveBeenCalled()
+	})
+
+	it("should skip migration when YAML file already exists", async () => {
+		// Clear all previous mocks to ensure clean test environment
+		jest.clearAllMocks()
+
+		// Setup mock functions
+		const mockWrite = (fs.writeFile as jest.Mock).mockResolvedValue(undefined)
+		const mockUnlink = (fs.unlink as jest.Mock).mockResolvedValue(undefined)
+
+		// Mock file read
+		;(fs.readFile as jest.Mock).mockImplementation(async (path: any) => {
+			if (path === legacyCustomModesJson) {
+				return JSON.stringify({ customModes: [] })
+			}
+			throw new Error("File not found: " + path)
+		})
+
+		// Mock file existence checks - both source and yaml destination exist
+		;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
+			if (path === mockSettingsDir) return true
+			if (path === legacyCustomModesJson) return true
+			if (path === newCustomModesYaml) return true // YAML already exists
+			if (path === legacyClineCustomModesPath) return false
+			if (path === legacyMcpSettingsPath) return false
+			return false
+		})
+
+		await migrateSettings(mockContext, mockOutputChannel)
+
+		// Verify skip message was logged
+		expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(
+			"custom_modes.yaml already exists, skipping migration",
+		)
+
+		// Verify no file operations occurred
+		expect(mockWrite).not.toHaveBeenCalled()
+		expect(mockUnlink).not.toHaveBeenCalled()
+	})
 })

+ 6 - 6
src/core/config/CustomModesManager.ts

@@ -120,7 +120,7 @@ export class CustomModesManager {
 		const fileExists = await fileExistsAtPath(filePath)
 
 		if (!fileExists) {
-			await this.queueWrite(() => fs.writeFile(filePath, JSON.stringify({ customModes: [] }, null, 2)))
+			await this.queueWrite(() => fs.writeFile(filePath, yaml.stringify({ customModes: [] })))
 		}
 
 		return filePath
@@ -136,7 +136,7 @@ export class CustomModesManager {
 					const content = await fs.readFile(settingsPath, "utf-8")
 
 					const errorMessage =
-						"Invalid custom modes format. Please ensure your settings follow the correct JSON format."
+						"Invalid custom modes format. Please ensure your settings follow the correct YAML format."
 
 					let config: any
 
@@ -291,7 +291,7 @@ export class CustomModesManager {
 			content = await fs.readFile(filePath, "utf-8")
 		} catch (error) {
 			// File might not exist yet.
-			content = JSON.stringify({ customModes: [] })
+			content = yaml.stringify({ customModes: [] })
 		}
 
 		let settings
@@ -299,12 +299,12 @@ export class CustomModesManager {
 		try {
 			settings = yaml.parse(content)
 		} catch (error) {
-			console.error(`[CustomModesManager] Failed to parse JSON from ${filePath}:`, error)
+			console.error(`[CustomModesManager] Failed to parse YAML from ${filePath}:`, error)
 			settings = { customModes: [] }
 		}
 
 		settings.customModes = operation(settings.customModes || [])
-		await fs.writeFile(filePath, JSON.stringify(settings, null, 2), "utf-8")
+		await fs.writeFile(filePath, yaml.stringify(settings), "utf-8")
 	}
 
 	private async refreshMergedState(): Promise<void> {
@@ -369,7 +369,7 @@ export class CustomModesManager {
 	public async resetCustomModes(): Promise<void> {
 		try {
 			const filePath = await this.getCustomModesFilePath()
-			await fs.writeFile(filePath, JSON.stringify({ customModes: [] }, null, 2))
+			await fs.writeFile(filePath, yaml.stringify({ customModes: [] }))
 			await this.context.globalState.update("customModes", [])
 			this.clearCache()
 			await this.onUpdate()

+ 32 - 35
src/core/config/__tests__/CustomModesManager.test.ts

@@ -48,7 +48,7 @@ describe("CustomModesManager", () => {
 		;(fs.mkdir as jest.Mock).mockResolvedValue(undefined)
 		;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 			if (path === mockSettingsPath) {
-				return JSON.stringify({ customModes: [] })
+				return yaml.stringify({ customModes: [] })
 			}
 			throw new Error("File not found")
 		})
@@ -68,7 +68,7 @@ describe("CustomModesManager", () => {
 
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify({ customModes: settingsModes })
+					return yaml.stringify({ customModes: settingsModes })
 				}
 				if (path === mockRoomodes) {
 					return yaml.stringify({ customModes: roomodesModes })
@@ -94,10 +94,10 @@ describe("CustomModesManager", () => {
 
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify({ customModes: settingsModes })
+					return yaml.stringify({ customModes: settingsModes })
 				}
 				if (path === mockRoomodes) {
-					return JSON.stringify({ customModes: roomodesModes })
+					return yaml.stringify({ customModes: roomodesModes })
 				}
 				throw new Error("File not found")
 			})
@@ -122,7 +122,7 @@ describe("CustomModesManager", () => {
 			})
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify({ customModes: settingsModes })
+					return yaml.stringify({ customModes: settingsModes })
 				}
 				throw new Error("File not found")
 			})
@@ -133,15 +133,15 @@ describe("CustomModesManager", () => {
 			expect(modes[0].slug).toBe("mode1")
 		})
 
-		it("should handle invalid JSON in .roomodes", async () => {
+		it("should handle invalid YAML in .roomodes", async () => {
 			const settingsModes = [{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] }]
 
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify({ customModes: settingsModes })
+					return yaml.stringify({ customModes: settingsModes })
 				}
 				if (path === mockRoomodes) {
-					return "invalid json"
+					return "invalid yaml content"
 				}
 				throw new Error("File not found")
 			})
@@ -158,7 +158,7 @@ describe("CustomModesManager", () => {
 			const settingsModes = [{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] }]
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify({ customModes: settingsModes })
+					return yaml.stringify({ customModes: settingsModes })
 				}
 				throw new Error("File not found")
 			})
@@ -180,7 +180,7 @@ describe("CustomModesManager", () => {
 			})
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify({ customModes: settingsModes })
+					return yaml.stringify({ customModes: settingsModes })
 				}
 				throw new Error("File not found")
 			})
@@ -200,7 +200,7 @@ describe("CustomModesManager", () => {
 			const settingsModes = [{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] }]
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify({ customModes: settingsModes })
+					return yaml.stringify({ customModes: settingsModes })
 				}
 				throw new Error("File not found")
 			})
@@ -225,7 +225,7 @@ describe("CustomModesManager", () => {
 			const updatedSettingsModes = [updatedMode]
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify({ customModes: updatedSettingsModes })
+					return yaml.stringify({ customModes: updatedSettingsModes })
 				}
 				throw new Error("File not found")
 			})
@@ -246,7 +246,7 @@ describe("CustomModesManager", () => {
 			const settingsModes = [{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] }]
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify({ customModes: settingsModes })
+					return yaml.stringify({ customModes: settingsModes })
 				}
 				throw new Error("File not found")
 			})
@@ -264,7 +264,7 @@ describe("CustomModesManager", () => {
 			// Mock the updated file content (empty)
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify({ customModes: [] })
+					return yaml.stringify({ customModes: [] })
 				}
 				throw new Error("File not found")
 			})
@@ -282,7 +282,7 @@ describe("CustomModesManager", () => {
 			const settingsModes = [{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] }]
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify({ customModes: settingsModes })
+					return yaml.stringify({ customModes: settingsModes })
 				}
 				throw new Error("File not found")
 			})
@@ -310,7 +310,7 @@ describe("CustomModesManager", () => {
 			const updatedSettingsModes = [updatedMode]
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify({ customModes: updatedSettingsModes })
+					return yaml.stringify({ customModes: updatedSettingsModes })
 				}
 				throw new Error("File not found")
 			})
@@ -328,7 +328,7 @@ describe("CustomModesManager", () => {
 			})
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify({ customModes: updatedSettingsModes })
+					return yaml.stringify({ customModes: updatedSettingsModes })
 				}
 				throw new Error("File not found")
 			})
@@ -343,7 +343,7 @@ describe("CustomModesManager", () => {
 			const settingsModes = [{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] }]
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify({ customModes: settingsModes })
+					return yaml.stringify({ customModes: settingsModes })
 				}
 				throw new Error("File not found")
 			})
@@ -369,7 +369,7 @@ describe("CustomModesManager", () => {
 				})
 				;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 					if (path === mockSettingsPath) {
-						return JSON.stringify({ customModes: settingsModes })
+						return yaml.stringify({ customModes: settingsModes })
 					}
 					throw new Error("File not found")
 				})
@@ -390,7 +390,7 @@ describe("CustomModesManager", () => {
 				})
 				;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 					if (path === mockSettingsPath) {
-						return JSON.stringify({ customModes: settingsModes })
+						return yaml.stringify({ customModes: settingsModes })
 					}
 					throw new Error("File not found")
 				})
@@ -434,10 +434,10 @@ describe("CustomModesManager", () => {
 
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockRoomodes) {
-					return JSON.stringify(roomodesContent)
+					return yaml.stringify(roomodesContent)
 				}
 				if (path === mockSettingsPath) {
-					return JSON.stringify(settingsContent)
+					return yaml.stringify(settingsContent)
 				}
 				throw new Error("File not found")
 			})
@@ -502,13 +502,13 @@ describe("CustomModesManager", () => {
 			})
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify({ customModes: [] })
+					return yaml.stringify({ customModes: [] })
 				}
 				if (path === mockRoomodes) {
 					if (!roomodesContent) {
 						throw new Error("File not found")
 					}
-					return JSON.stringify(roomodesContent)
+					return yaml.stringify(roomodesContent)
 				}
 				throw new Error("File not found")
 			})
@@ -564,7 +564,7 @@ describe("CustomModesManager", () => {
 			let settingsContent = { customModes: [] }
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify(settingsContent)
+					return yaml.stringify(settingsContent)
 				}
 				throw new Error("File not found")
 			})
@@ -629,16 +629,13 @@ describe("CustomModesManager", () => {
 
 			await manager.getCustomModesFilePath()
 
-			expect(fs.writeFile).toHaveBeenCalledWith(
-				settingsPath,
-				expect.stringMatching(/^\{\s+"customModes":\s+\[\s*\]\s*\}$/),
-			)
+			expect(fs.writeFile).toHaveBeenCalledWith(settingsPath, expect.stringMatching(/^customModes: \[\]/))
 		})
 
 		it("watches file for changes", async () => {
 			const configPath = path.join(mockStoragePath, "settings", GlobalFileNames.customModes)
 
-			;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
+			;(fs.readFile as jest.Mock).mockResolvedValue(yaml.stringify({ customModes: [] }))
 			;(arePathsEqual as jest.Mock).mockImplementation((path1: string, path2: string) => {
 				return path.normalize(path1) === path.normalize(path2)
 			})
@@ -673,7 +670,7 @@ describe("CustomModesManager", () => {
 			let settingsContent = { customModes: [existingMode] }
 			;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
 				if (path === mockSettingsPath) {
-					return JSON.stringify(settingsContent)
+					return yaml.stringify(settingsContent)
 				}
 				throw new Error("File not found")
 			})
@@ -718,9 +715,9 @@ describe("CustomModesManager", () => {
 	})
 
 	describe("updateModesInFile", () => {
-		it("handles corrupted JSON content gracefully", async () => {
-			const corruptedJson = "{ invalid json content"
-			;(fs.readFile as jest.Mock).mockResolvedValue(corruptedJson)
+		it("handles corrupted YAML content gracefully", async () => {
+			const corruptedYaml = "customModes: [invalid yaml content"
+			;(fs.readFile as jest.Mock).mockResolvedValue(corruptedYaml)
 
 			const newMode: ModeConfig = {
 				slug: "test-mode",
@@ -732,7 +729,7 @@ describe("CustomModesManager", () => {
 
 			await manager.updateCustomMode("test-mode", newMode)
 
-			// Verify that a valid JSON structure was written
+			// Verify that a valid YAML structure was written
 			const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
 			const writtenContent = yaml.parse(writeCall[1])
 			expect(writtenContent).toEqual({

+ 26 - 21
src/core/prompts/instructions/create-mode.ts

@@ -31,25 +31,30 @@ If asked to create a project mode, create it in .roomodes in the workspace root.
 
 - For multi-line text, include newline characters in the string like "This is the first line.\\nThis is the next line.\\n\\nThis is a double line break."
 
-Both files should follow this structure:
-{
- "customModes": [
-   {
-     "slug": "designer", // Required: unique slug with lowercase letters, numbers, and hyphens
-     "name": "Designer", // Required: mode display name
-     "roleDefinition": "You are Roo, a UI/UX expert specializing in design systems and frontend development. Your expertise includes:\\n- Creating and maintaining design systems\\n- Implementing responsive and accessible web interfaces\\n- Working with CSS, HTML, and modern frontend frameworks\\n- Ensuring consistent user experiences across platforms", // Required: non-empty
-     "whenToUse": "Use this mode when creating or modifying UI components, implementing design systems, or ensuring responsive web interfaces. This mode is especially effective with CSS, HTML, and modern frontend frameworks.", // Optional but recommended
-     "groups": [ // Required: array of tool groups (can be empty)
-       "read",    // Read files group (read_file, fetch_instructions, search_files, list_files, list_code_definition_names)
-       "edit",    // Edit files group (apply_diff, write_to_file) - allows editing any file
-       // Or with file restrictions:
-       // ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }],  // Edit group that only allows editing markdown files
-       "browser", // Browser group (browser_action)
-       "command", // Command group (execute_command)
-       "mcp"     // MCP group (use_mcp_tool, access_mcp_resource)
-     ],
-     "customInstructions": "Additional instructions for the Designer mode" // Optional
-    }
-  ]
-}`
+Both files should follow this structure (in YAML format):
+
+customModes:
+  - slug: designer  # Required: unique slug with lowercase letters, numbers, and hyphens
+    name: Designer  # Required: mode display name
+    roleDefinition: >-
+      You are Roo, a UI/UX expert specializing in design systems and frontend development. Your expertise includes:
+      - Creating and maintaining design systems
+      - Implementing responsive and accessible web interfaces
+      - Working with CSS, HTML, and modern frontend frameworks
+      - Ensuring consistent user experiences across platforms  # Required: non-empty
+    whenToUse: >-
+      Use this mode when creating or modifying UI components, implementing design systems, 
+      or ensuring responsive web interfaces. This mode is especially effective with CSS, 
+      HTML, and modern frontend frameworks.  # Optional but recommended
+    groups:  # Required: array of tool groups (can be empty)
+      - read     # Read files group (read_file, fetch_instructions, search_files, list_files, list_code_definition_names)
+      - edit     # Edit files group (apply_diff, write_to_file) - allows editing any file
+      # Or with file restrictions:
+      # - - edit
+      #   - fileRegex: \\.md$
+      #     description: Markdown files only  # Edit group that only allows editing markdown files
+      - browser  # Browser group (browser_action)
+      - command  # Command group (execute_command)
+      - mcp      # MCP group (use_mcp_tool, access_mcp_resource)
+    customInstructions: Additional instructions for the Designer mode  # Optional`
 }

+ 1 - 1
src/shared/globalFileNames.ts

@@ -2,6 +2,6 @@ export const GlobalFileNames = {
 	apiConversationHistory: "api_conversation_history.json",
 	uiMessages: "ui_messages.json",
 	mcpSettings: "mcp_settings.json",
-	customModes: "custom_modes.json",
+	customModes: "custom_modes.yaml",
 	taskMetadata: "task_metadata.json",
 }

+ 79 - 17
src/utils/migrateSettings.ts

@@ -3,6 +3,9 @@ import * as path from "path"
 import * as fs from "fs/promises"
 import { fileExistsAtPath } from "./fs"
 import { GlobalFileNames } from "../shared/globalFileNames"
+import * as yaml from "yaml"
+
+const deprecatedCustomModesJSONFilename = "custom_modes.json"
 
 /**
  * Migrates old settings files to new file names
@@ -15,7 +18,8 @@ export async function migrateSettings(
 ): Promise<void> {
 	// Legacy file names that need to be migrated to the new names in GlobalFileNames
 	const fileMigrations = [
-		{ oldName: "cline_custom_modes.json", newName: GlobalFileNames.customModes },
+		// custom_modes.json to custom_modes.yaml is handled separately below
+		{ oldName: "cline_custom_modes.json", newName: deprecatedCustomModesJSONFilename },
 		{ oldName: "cline_mcp_settings.json", newName: GlobalFileNames.mcpSettings },
 	]
 
@@ -29,25 +33,83 @@ export async function migrateSettings(
 		}
 
 		// Process each file migration
-		for (const migration of fileMigrations) {
-			const oldPath = path.join(settingsDir, migration.oldName)
-			const newPath = path.join(settingsDir, migration.newName)
-
-			// Only migrate if old file exists and new file doesn't exist yet
-			// This ensures we don't overwrite any existing new files
-			const oldFileExists = await fileExistsAtPath(oldPath)
-			const newFileExists = await fileExistsAtPath(newPath)
-
-			if (oldFileExists && !newFileExists) {
-				await fs.rename(oldPath, newPath)
-				outputChannel.appendLine(`Renamed ${migration.oldName} to ${migration.newName}`)
-			} else {
-				outputChannel.appendLine(
-					`Skipping migration of ${migration.oldName} to ${migration.newName}: ${oldFileExists ? "new file already exists" : "old file not found"}`,
-				)
+		try {
+			for (const migration of fileMigrations) {
+				const oldPath = path.join(settingsDir, migration.oldName)
+				const newPath = path.join(settingsDir, migration.newName)
+
+				// Only migrate if old file exists and new file doesn't exist yet
+				// This ensures we don't overwrite any existing new files
+				const oldFileExists = await fileExistsAtPath(oldPath)
+				const newFileExists = await fileExistsAtPath(newPath)
+
+				if (oldFileExists && !newFileExists) {
+					await fs.rename(oldPath, newPath)
+					outputChannel.appendLine(`Renamed ${migration.oldName} to ${migration.newName}`)
+				} else {
+					outputChannel.appendLine(
+						`Skipping migration of ${migration.oldName} to ${migration.newName}: ${oldFileExists ? "new file already exists" : "old file not found"}`,
+					)
+				}
 			}
+
+			// Special migration for custom_modes.json to custom_modes.yaml with content transformation
+			await migrateCustomModesToYaml(settingsDir, outputChannel)
+		} catch (error) {
+			outputChannel.appendLine(`Error in file migrations: ${error}`)
 		}
 	} catch (error) {
 		outputChannel.appendLine(`Error migrating settings files: ${error}`)
 	}
 }
+
+/**
+ * Special migration function to convert custom_modes.json to YAML format
+ */
+async function migrateCustomModesToYaml(settingsDir: string, outputChannel: vscode.OutputChannel): Promise<void> {
+	const oldJsonPath = path.join(settingsDir, deprecatedCustomModesJSONFilename)
+	const newYamlPath = path.join(settingsDir, GlobalFileNames.customModes)
+
+	// Only proceed if JSON exists and YAML doesn't
+	const jsonExists = await fileExistsAtPath(oldJsonPath)
+	const yamlExists = await fileExistsAtPath(newYamlPath)
+
+	if (!jsonExists) {
+		outputChannel.appendLine("No custom_modes.json found, skipping YAML migration")
+		return
+	}
+
+	if (yamlExists) {
+		outputChannel.appendLine("custom_modes.yaml already exists, skipping migration")
+		return
+	}
+
+	try {
+		// Read JSON content
+		const jsonContent = await fs.readFile(oldJsonPath, "utf-8")
+
+		try {
+			// Parse JSON to object (using the yaml library just to be safe/consistent)
+			const customModesData = yaml.parse(jsonContent)
+
+			// Convert to YAML
+			const yamlContent = yaml.stringify(customModesData)
+
+			// Write YAML file
+			await fs.writeFile(newYamlPath, yamlContent, "utf-8")
+
+			// Keeping the old JSON file for backward compatibility
+			// This allows users to roll back if needed
+			outputChannel.appendLine(
+				"Successfully migrated custom_modes.json to YAML format (original JSON file preserved for rollback purposes)",
+			)
+		} catch (parseError) {
+			// Handle corrupt JSON file
+			outputChannel.appendLine(
+				`Error parsing custom_modes.json: ${parseError}. File might be corrupted. Skipping migration.`,
+			)
+		}
+	} catch (fileError) {
+		outputChannel.appendLine(`Error reading custom_modes.json: ${fileError}. Skipping migration.`)
+	}
+}