11import * as vscode from "vscode"
22import * as path from "path"
33import * as fs from "fs/promises"
4+ import * as yaml from "js-yaml"
45import { customModesSettingsSchema } from "../../schemas"
56import { ModeConfig } from "../../shared/modes"
67import { fileExistsAtPath } from "../../utils/fs"
@@ -9,6 +10,9 @@ import { logger } from "../../utils/logging"
910import { GlobalFileNames } from "../../shared/globalFileNames"
1011
1112const ROOMODES_FILENAME = ".roomodes"
13+ const ROO_DIR = ".roo"
14+ const MODES_DIR = "modes"
15+ const YAML_EXTENSION = ".yaml"
1216
1317export class CustomModesManager {
1418 private disposables : vscode . Disposable [ ] = [ ]
@@ -59,6 +63,97 @@ export class CustomModesManager {
5963 return exists ? roomodesPath : undefined
6064 }
6165
66+ /**
67+ * Get the path to the project modes directory (.roo/modes/)
68+ * Returns undefined if the directory doesn't exist
69+ */
70+ private async getProjectModesDirectory ( ) : Promise < string | undefined > {
71+ const workspaceFolders = vscode . workspace . workspaceFolders
72+ if ( ! workspaceFolders || workspaceFolders . length === 0 ) {
73+ return undefined
74+ }
75+
76+ const workspaceRoot = getWorkspacePath ( )
77+ const rooDir = path . join ( workspaceRoot , ROO_DIR )
78+ const modesDir = path . join ( rooDir , MODES_DIR )
79+
80+ try {
81+ const exists = await fileExistsAtPath ( modesDir )
82+ return exists ? modesDir : undefined
83+ } catch ( error ) {
84+ logger . error ( `Failed to check if project modes directory exists: ${ error } ` )
85+ return undefined
86+ }
87+ }
88+
89+ /**
90+ * Load a mode configuration from a YAML file
91+ *
92+ * @param filePath - Path to the YAML file
93+ * @returns The mode configuration or null if invalid
94+ */
95+ private async loadModeFromYamlFile ( filePath : string ) : Promise < ModeConfig | null > {
96+ try {
97+ // Read and parse the YAML file
98+ const content = await fs . readFile ( filePath , "utf-8" )
99+ const data = yaml . load ( content ) as unknown
100+
101+ // Validate the loaded data against the schema
102+ const result = customModesSettingsSchema . safeParse ( { customModes : [ data ] } )
103+ if ( ! result . success ) {
104+ logger . error ( `Invalid mode configuration in ${ filePath } : ${ result . error . message } ` )
105+ return null
106+ }
107+
108+ // Extract and validate the slug from the filename
109+ const fileName = path . basename ( filePath , YAML_EXTENSION )
110+ if ( ! / ^ [ a - z A - Z 0 - 9 - ] + $ / . test ( fileName ) ) {
111+ logger . error (
112+ `Invalid mode slug in filename: ${ fileName } . Slugs must contain only alphanumeric characters and hyphens.` ,
113+ )
114+ return null
115+ }
116+
117+ // Create the complete mode config with slug and source
118+ const modeConfig : ModeConfig = {
119+ ...result . data . customModes [ 0 ] ,
120+ slug : fileName ,
121+ source : "project" as const ,
122+ }
123+
124+ return modeConfig
125+ } catch ( error ) {
126+ const errorMessage = error instanceof Error ? error . message : String ( error )
127+ logger . error ( `Failed to load mode from ${ filePath } ` , { error : errorMessage } )
128+ return null
129+ }
130+ }
131+
132+ /**
133+ * Load modes from a directory of YAML files
134+ *
135+ * @param dirPath - Path to the directory containing mode YAML files
136+ * @returns Array of valid mode configurations
137+ */
138+ private async loadModesFromYamlDirectory ( dirPath : string ) : Promise < ModeConfig [ ] > {
139+ try {
140+ const files = await fs . readdir ( dirPath )
141+ const yamlFiles = files . filter ( ( file ) => file . endsWith ( YAML_EXTENSION ) )
142+
143+ const modePromises = yamlFiles . map ( async ( file ) => {
144+ const filePath = path . join ( dirPath , file )
145+ return await this . loadModeFromYamlFile ( filePath )
146+ } )
147+
148+ const modes = await Promise . all ( modePromises )
149+ return modes . filter ( ( mode ) : mode is ModeConfig => mode !== null )
150+ } catch ( error ) {
151+ const errorMessage = error instanceof Error ? error . message : String ( error )
152+ logger . error ( `Failed to load modes from directory ${ dirPath } ` , { error : errorMessage } )
153+ return [ ]
154+ }
155+ }
156+
62157 private async loadModesFromFile ( filePath : string ) : Promise < ModeConfig [ ] > {
63158 try {
64159 const content = await fs . readFile ( filePath , "utf-8" )
@@ -152,14 +247,7 @@ export class CustomModesManager {
152247 return
153248 }
154249
155- // Get modes from .roomodes if it exists (takes precedence)
156- const roomodesPath = await this . getWorkspaceRoomodes ( )
157- const roomodesModes = roomodesPath ? await this . loadModesFromFile ( roomodesPath ) : [ ]
158-
159- // Merge modes from both sources (.roomodes takes precedence)
160- const mergedModes = await this . mergeCustomModes ( roomodesModes , result . data . customModes )
161- await this . context . globalState . update ( "customModes" , mergedModes )
162- await this . onUpdate ( )
250+ await this . refreshMergedState ( )
163251 }
164252 } ) ,
165253 )
@@ -170,48 +258,69 @@ export class CustomModesManager {
170258 this . disposables . push (
171259 vscode . workspace . onDidSaveTextDocument ( async ( document ) => {
172260 if ( arePathsEqual ( document . uri . fsPath , roomodesPath ) ) {
173- const settingsModes = await this . loadModesFromFile ( settingsPath )
174- const roomodesModes = await this . loadModesFromFile ( roomodesPath )
175- // .roomodes takes precedence
176- const mergedModes = await this . mergeCustomModes ( roomodesModes , settingsModes )
177- await this . context . globalState . update ( "customModes" , mergedModes )
178- await this . onUpdate ( )
261+ await this . refreshMergedState ( )
262+ }
263+ } ) ,
264+ )
265+ }
266+
267+ // Watch .roo/modes/*.yaml files if the directory exists
268+ const projectModesDir = await this . getProjectModesDirectory ( )
269+ if ( projectModesDir ) {
270+ this . disposables . push (
271+ vscode . workspace . onDidSaveTextDocument ( async ( document ) => {
272+ const filePath = document . uri . fsPath
273+ if ( filePath . startsWith ( projectModesDir ) && filePath . endsWith ( YAML_EXTENSION ) ) {
274+ await this . refreshMergedState ( )
179275 }
180276 } ) ,
181277 )
182278 }
183279 }
184280
185281 async getCustomModes ( ) : Promise < ModeConfig [ ] > {
186- // Get modes from settings file
282+ // Get modes from settings file (global modes)
187283 const settingsPath = await this . getCustomModesFilePath ( )
188284 const settingsModes = await this . loadModesFromFile ( settingsPath )
189285
190- // Get modes from .roomodes if it exists
286+ // Get project modes - first check if .roomodes exists
191287 const roomodesPath = await this . getWorkspaceRoomodes ( )
192- const roomodesModes = roomodesPath ? await this . loadModesFromFile ( roomodesPath ) : [ ]
288+ let projectModes : ModeConfig [ ] = [ ]
289+
290+ if ( roomodesPath ) {
291+ // If .roomodes exists, load modes from there
292+ projectModes = await this . loadModesFromFile ( roomodesPath )
293+ projectModes = projectModes . map ( ( mode ) => ( { ...mode , source : "project" as const } ) )
294+ } else {
295+ // If .roomodes doesn't exist, check for .roo/modes/ directory
296+ const projectModesDir = await this . getProjectModesDirectory ( )
297+ if ( projectModesDir ) {
298+ // If .roo/modes/ exists, load modes from YAML files
299+ projectModes = await this . loadModesFromYamlDirectory ( projectModesDir )
300+ }
301+ }
193302
194303 // Create maps to store modes by source
195- const projectModes = new Map < string , ModeConfig > ( )
196- const globalModes = new Map < string , ModeConfig > ( )
304+ const projectModesMap = new Map < string , ModeConfig > ( )
305+ const globalModesMap = new Map < string , ModeConfig > ( )
197306
198307 // Add project modes (they take precedence)
199- for ( const mode of roomodesModes ) {
200- projectModes . set ( mode . slug , { ...mode , source : "project" as const } )
308+ for ( const mode of projectModes ) {
309+ projectModesMap . set ( mode . slug , { ...mode , source : "project" as const } )
201310 }
202311
203312 // Add global modes
204313 for ( const mode of settingsModes ) {
205- if ( ! projectModes . has ( mode . slug ) ) {
206- globalModes . set ( mode . slug , { ...mode , source : "global" as const } )
314+ if ( ! projectModesMap . has ( mode . slug ) ) {
315+ globalModesMap . set ( mode . slug , { ...mode , source : "global" as const } )
207316 }
208317 }
209318
210319 // Combine modes in the correct order: project modes first, then global modes
211320 const mergedModes = [
212- ...roomodesModes . map ( ( mode ) => ( { ...mode , source : "project" as const } ) ) ,
321+ ...projectModes . map ( ( mode ) => ( { ...mode , source : "project" as const } ) ) ,
213322 ...settingsModes
214- . filter ( ( mode ) => ! projectModes . has ( mode . slug ) )
323+ . filter ( ( mode ) => ! projectModesMap . has ( mode . slug ) )
215324 . map ( ( mode ) => ( { ...mode , source : "global" as const } ) ) ,
216325 ]
217326
@@ -221,40 +330,88 @@ export class CustomModesManager {
221330 async updateCustomMode ( slug : string , config : ModeConfig ) : Promise < void > {
222331 try {
223332 const isProjectMode = config . source === "project"
224- let targetPath : string
225333
226334 if ( isProjectMode ) {
227335 const workspaceFolders = vscode . workspace . workspaceFolders
228336 if ( ! workspaceFolders || workspaceFolders . length === 0 ) {
229337 logger . error ( "Failed to update project mode: No workspace folder found" , { slug } )
230338 throw new Error ( "No workspace folder found for project-specific mode" )
231339 }
340+
232341 const workspaceRoot = getWorkspacePath ( )
233- targetPath = path . join ( workspaceRoot , ROOMODES_FILENAME )
234- const exists = await fileExistsAtPath ( targetPath )
235- logger . info ( `${ exists ? "Updating" : "Creating" } project mode in ${ ROOMODES_FILENAME } ` , {
236- slug,
237- workspace : workspaceRoot ,
238- } )
239- } else {
240- targetPath = await this . getCustomModesFilePath ( )
241- }
242342
243- await this . queueWrite ( async ( ) => {
244- // Ensure source is set correctly based on target file
245- const modeWithSource = {
246- ...config ,
247- source : isProjectMode ? ( "project" as const ) : ( "global" as const ) ,
343+ // First check if .roomodes exists
344+ const roomodesPath = path . join ( workspaceRoot , ROOMODES_FILENAME )
345+ const roomodesExists = await fileExistsAtPath ( roomodesPath )
346+
347+ if ( roomodesExists ) {
348+ // If .roomodes exists, use it
349+ logger . info ( `${ roomodesExists ? "Updating" : "Creating" } project mode in ${ ROOMODES_FILENAME } ` , {
350+ slug,
351+ workspace : workspaceRoot ,
352+ } )
353+
354+ await this . queueWrite ( async ( ) => {
355+ // Ensure source is set correctly
356+ const modeWithSource = {
357+ ...config ,
358+ source : "project" as const ,
359+ }
360+
361+ await this . updateModesInFile ( roomodesPath , ( modes ) => {
362+ const updatedModes = modes . filter ( ( m ) => m . slug !== slug )
363+ updatedModes . push ( modeWithSource )
364+ return updatedModes
365+ } )
366+
367+ await this . refreshMergedState ( )
368+ } )
369+ } else {
370+ // If .roomodes doesn't exist, use .roo/modes/${slug}.yaml
371+ const rooDir = path . join ( workspaceRoot , ROO_DIR )
372+ const modesDir = path . join ( rooDir , MODES_DIR )
373+
374+ // Ensure the .roo/modes directory exists
375+ await fs . mkdir ( modesDir , { recursive : true } )
376+
377+ const yamlPath = path . join ( modesDir , `${ slug } ${ YAML_EXTENSION } ` )
378+
379+ logger . info ( `Saving project mode to ${ yamlPath } ` , {
380+ slug,
381+ workspace : workspaceRoot ,
382+ } )
383+
384+ await this . queueWrite ( async ( ) => {
385+ // Remove slug and source from the config for YAML file
386+ const { slug : _ , source : __ , ...modeData } = config
387+
388+ // Convert to YAML and write to file
389+ const yamlContent = yaml . dump ( modeData , { lineWidth : - 1 } )
390+ await fs . writeFile ( yamlPath , yamlContent , "utf-8" )
391+
392+ await this . refreshMergedState ( )
393+ } )
248394 }
395+ } else {
396+ // Global mode - save to global settings file
397+ const targetPath = await this . getCustomModesFilePath ( )
398+
399+ await this . queueWrite ( async ( ) => {
400+ // Ensure source is set correctly
401+ const modeWithSource = {
402+ ...config ,
403+ source : "global" as const ,
404+ }
249405
250- await this . updateModesInFile ( targetPath , ( modes ) => {
251- const updatedModes = modes . filter ( ( m ) => m . slug !== slug )
252- updatedModes . push ( modeWithSource )
253- return updatedModes
254- } )
406+ await this . updateModesInFile ( targetPath , ( modes ) => {
407+ const updatedModes = modes . filter ( ( m ) => m . slug !== slug )
408+ updatedModes . push ( modeWithSource )
409+ return updatedModes
410+ } )
255411
256- await this . refreshMergedState ( )
257- } )
412+ await this . refreshMergedState ( )
413+ } )
414+ }
258415 } catch ( error ) {
259416 const errorMessage = error instanceof Error ? error . message : String ( error )
260417 logger . error ( "Failed to update custom mode" , { slug, error : errorMessage } )
@@ -284,10 +441,20 @@ export class CustomModesManager {
284441 private async refreshMergedState ( ) : Promise < void > {
285442 const settingsPath = await this . getCustomModesFilePath ( )
286443 const roomodesPath = await this . getWorkspaceRoomodes ( )
444+ const projectModesDir = await this . getProjectModesDirectory ( )
287445
288446 const settingsModes = await this . loadModesFromFile ( settingsPath )
289- const roomodesModes = roomodesPath ? await this . loadModesFromFile ( roomodesPath ) : [ ]
290- const mergedModes = await this . mergeCustomModes ( roomodesModes , settingsModes )
447+ let projectModes : ModeConfig [ ] = [ ]
448+
449+ if ( roomodesPath ) {
450+ // If .roomodes exists, load modes from there
451+ projectModes = await this . loadModesFromFile ( roomodesPath )
452+ } else if ( projectModesDir ) {
453+ // If .roomodes doesn't exist but .roo/modes/ does, load modes from YAML files
454+ projectModes = await this . loadModesFromYamlDirectory ( projectModesDir )
455+ }
456+
457+ const mergedModes = await this . mergeCustomModes ( projectModes , settingsModes )
291458
292459 await this . context . globalState . update ( "customModes" , mergedModes )
293460 await this . onUpdate ( )
@@ -297,24 +464,39 @@ export class CustomModesManager {
297464 try {
298465 const settingsPath = await this . getCustomModesFilePath ( )
299466 const roomodesPath = await this . getWorkspaceRoomodes ( )
467+ const projectModesDir = await this . getProjectModesDirectory ( )
300468
301469 const settingsModes = await this . loadModesFromFile ( settingsPath )
302470 const roomodesModes = roomodesPath ? await this . loadModesFromFile ( roomodesPath ) : [ ]
303471
472+ // Check if the mode exists in .roo/modes directory
473+ let yamlModeExists = false
474+ let yamlModePath : string | undefined
475+
476+ if ( projectModesDir ) {
477+ yamlModePath = path . join ( projectModesDir , `${ slug } ${ YAML_EXTENSION } ` )
478+ yamlModeExists = await fileExistsAtPath ( yamlModePath )
479+ }
480+
304481 // Find the mode in either file
305- const projectMode = roomodesModes . find ( ( m ) => m . slug === slug )
482+ const roomodesMode = roomodesModes . find ( ( m ) => m . slug === slug )
306483 const globalMode = settingsModes . find ( ( m ) => m . slug === slug )
307484
308- if ( ! projectMode && ! globalMode ) {
485+ if ( ! roomodesMode && ! globalMode && ! yamlModeExists ) {
309486 throw new Error ( "Write error: Mode not found" )
310487 }
311488
312489 await this . queueWrite ( async ( ) => {
313- // Delete from project first if it exists there
314- if ( projectMode && roomodesPath ) {
490+ // Delete from .roomodes if it exists there
491+ if ( roomodesMode && roomodesPath ) {
315492 await this . updateModesInFile ( roomodesPath , ( modes ) => modes . filter ( ( m ) => m . slug !== slug ) )
316493 }
317494
495+ // Delete from .roo/modes if it exists there
496+ if ( yamlModeExists && yamlModePath ) {
497+ await fs . unlink ( yamlModePath )
498+ }
499+
318500 // Delete from global settings if it exists there
319501 if ( globalMode ) {
320502 await this . updateModesInFile ( settingsPath , ( modes ) => modes . filter ( ( m ) => m . slug !== slug ) )
0 commit comments