@@ -6,6 +6,8 @@ import { ModeConfig } from "../../shared/modes"
6
6
import { fileExistsAtPath } from "../../utils/fs"
7
7
import { arePathsEqual } from "../../utils/path"
8
8
9
+ const ROOMODES_FILENAME = ".roomodes"
10
+
9
11
export class CustomModesManager {
10
12
private disposables : vscode . Disposable [ ] = [ ]
11
13
private isWriting = false
@@ -15,7 +17,7 @@ export class CustomModesManager {
15
17
private readonly context : vscode . ExtensionContext ,
16
18
private readonly onUpdate : ( ) => Promise < void > ,
17
19
) {
18
- this . watchCustomModesFile ( )
20
+ this . watchCustomModesFiles ( )
19
21
}
20
22
21
23
private async queueWrite ( operation : ( ) => Promise < void > ) : Promise < void > {
@@ -43,6 +45,73 @@ export class CustomModesManager {
43
45
}
44
46
}
45
47
48
+ private async getWorkspaceRoomodes ( ) : Promise < string | undefined > {
49
+ const workspaceFolders = vscode . workspace . workspaceFolders
50
+ if ( ! workspaceFolders || workspaceFolders . length === 0 ) {
51
+ return undefined
52
+ }
53
+ const workspaceRoot = workspaceFolders [ 0 ] . uri . fsPath
54
+ const roomodesPath = path . join ( workspaceRoot , ROOMODES_FILENAME )
55
+ const exists = await fileExistsAtPath ( roomodesPath )
56
+ return exists ? roomodesPath : undefined
57
+ }
58
+
59
+ private async loadModesFromFile ( filePath : string ) : Promise < ModeConfig [ ] > {
60
+ try {
61
+ const content = await fs . readFile ( filePath , "utf-8" )
62
+ const settings = JSON . parse ( content )
63
+ const result = CustomModesSettingsSchema . safeParse ( settings )
64
+ if ( ! result . success ) {
65
+ const errorMsg = `Schema validation failed for ${ filePath } `
66
+ console . error ( `[CustomModesManager] ${ errorMsg } :` , result . error )
67
+ return [ ]
68
+ }
69
+
70
+ // Determine source based on file path
71
+ const isRoomodes = filePath . endsWith ( ROOMODES_FILENAME )
72
+ const source = isRoomodes ? ( "project" as const ) : ( "global" as const )
73
+
74
+ // Add source to each mode
75
+ return result . data . customModes . map ( ( mode ) => ( {
76
+ ...mode ,
77
+ source,
78
+ } ) )
79
+ } catch ( error ) {
80
+ const errorMsg = `Failed to load modes from ${ filePath } : ${ error instanceof Error ? error . message : String ( error ) } `
81
+ console . error ( `[CustomModesManager] ${ errorMsg } ` )
82
+ return [ ]
83
+ }
84
+ }
85
+
86
+ private async mergeCustomModes ( projectModes : ModeConfig [ ] , globalModes : ModeConfig [ ] ) : Promise < ModeConfig [ ] > {
87
+ const slugs = new Set < string > ( )
88
+ const merged : ModeConfig [ ] = [ ]
89
+
90
+ // Add project mode (takes precedence)
91
+ for ( const mode of projectModes ) {
92
+ if ( ! slugs . has ( mode . slug ) ) {
93
+ slugs . add ( mode . slug )
94
+ merged . push ( {
95
+ ...mode ,
96
+ source : "project" ,
97
+ } )
98
+ }
99
+ }
100
+
101
+ // Add non-duplicate global modes
102
+ for ( const mode of globalModes ) {
103
+ if ( ! slugs . has ( mode . slug ) ) {
104
+ slugs . add ( mode . slug )
105
+ merged . push ( {
106
+ ...mode ,
107
+ source : "global" ,
108
+ } )
109
+ }
110
+ }
111
+
112
+ return merged
113
+ }
114
+
46
115
async getCustomModesFilePath ( ) : Promise < string > {
47
116
const settingsDir = await this . ensureSettingsDirectoryExists ( )
48
117
const filePath = path . join ( settingsDir , "cline_custom_modes.json" )
@@ -55,14 +124,17 @@ export class CustomModesManager {
55
124
return filePath
56
125
}
57
126
58
- private async watchCustomModesFile ( ) : Promise < void > {
127
+ private async watchCustomModesFiles ( ) : Promise < void > {
59
128
const settingsPath = await this . getCustomModesFilePath ( )
129
+
130
+ // Watch settings file
60
131
this . disposables . push (
61
132
vscode . workspace . onDidSaveTextDocument ( async ( document ) => {
62
133
if ( arePathsEqual ( document . uri . fsPath , settingsPath ) ) {
63
134
const content = await fs . readFile ( settingsPath , "utf-8" )
64
135
const errorMessage =
65
136
"Invalid custom modes format. Please ensure your settings follow the correct JSON format."
137
+
66
138
let config : any
67
139
try {
68
140
config = JSON . parse ( content )
@@ -71,86 +143,170 @@ export class CustomModesManager {
71
143
vscode . window . showErrorMessage ( errorMessage )
72
144
return
73
145
}
146
+
74
147
const result = CustomModesSettingsSchema . safeParse ( config )
75
148
if ( ! result . success ) {
76
149
vscode . window . showErrorMessage ( errorMessage )
77
150
return
78
151
}
79
- await this . context . globalState . update ( "customModes" , result . data . customModes )
152
+
153
+ // Get modes from .roomodes if it exists (takes precedence)
154
+ const roomodesPath = await this . getWorkspaceRoomodes ( )
155
+ const roomodesModes = roomodesPath ? await this . loadModesFromFile ( roomodesPath ) : [ ]
156
+
157
+ // Merge modes from both sources (.roomodes takes precedence)
158
+ const mergedModes = await this . mergeCustomModes ( roomodesModes , result . data . customModes )
159
+ await this . context . globalState . update ( "customModes" , mergedModes )
80
160
await this . onUpdate ( )
81
161
}
82
162
} ) ,
83
163
)
164
+
165
+ // Watch .roomodes file if it exists
166
+ const roomodesPath = await this . getWorkspaceRoomodes ( )
167
+ if ( roomodesPath ) {
168
+ this . disposables . push (
169
+ vscode . workspace . onDidSaveTextDocument ( async ( document ) => {
170
+ if ( arePathsEqual ( document . uri . fsPath , roomodesPath ) ) {
171
+ const settingsModes = await this . loadModesFromFile ( settingsPath )
172
+ const roomodesModes = await this . loadModesFromFile ( roomodesPath )
173
+ // .roomodes takes precedence
174
+ const mergedModes = await this . mergeCustomModes ( roomodesModes , settingsModes )
175
+ await this . context . globalState . update ( "customModes" , mergedModes )
176
+ await this . onUpdate ( )
177
+ }
178
+ } ) ,
179
+ )
180
+ }
84
181
}
85
182
86
183
async getCustomModes ( ) : Promise < ModeConfig [ ] > {
87
- const modes = await this . context . globalState . get < ModeConfig [ ] > ( "customModes" )
184
+ // Get modes from settings file
185
+ const settingsPath = await this . getCustomModesFilePath ( )
186
+ const settingsModes = await this . loadModesFromFile ( settingsPath )
88
187
89
- // Always read from file to ensure we have the latest
90
- try {
91
- const settingsPath = await this . getCustomModesFilePath ( )
92
- const content = await fs . readFile ( settingsPath , "utf-8" )
188
+ // Get modes from .roomodes if it exists
189
+ const roomodesPath = await this . getWorkspaceRoomodes ( )
190
+ const roomodesModes = roomodesPath ? await this . loadModesFromFile ( roomodesPath ) : [ ]
93
191
94
- const settings = JSON . parse ( content )
95
- const result = CustomModesSettingsSchema . safeParse ( settings )
96
- if ( result . success ) {
97
- await this . context . globalState . update ( "customModes" , result . data . customModes )
98
- return result . data . customModes
192
+ // Create maps to store modes by source
193
+ const projectModes = new Map < string , ModeConfig > ( )
194
+ const globalModes = new Map < string , ModeConfig > ( )
195
+
196
+ // Add project modes (they take precedence)
197
+ for ( const mode of roomodesModes ) {
198
+ projectModes . set ( mode . slug , { ...mode , source : "project" as const } )
199
+ }
200
+
201
+ // Add global modes
202
+ for ( const mode of settingsModes ) {
203
+ if ( ! projectModes . has ( mode . slug ) ) {
204
+ globalModes . set ( mode . slug , { ...mode , source : "global" as const } )
99
205
}
100
- return modes ?? [ ]
101
- } catch ( error ) {
102
- // Return empty array if there's an error reading the file
103
206
}
104
207
105
- return modes ?? [ ]
208
+ // Combine modes in the correct order: project modes first, then global modes
209
+ const mergedModes = [
210
+ ...roomodesModes . map ( ( mode ) => ( { ...mode , source : "project" as const } ) ) ,
211
+ ...settingsModes
212
+ . filter ( ( mode ) => ! projectModes . has ( mode . slug ) )
213
+ . map ( ( mode ) => ( { ...mode , source : "global" as const } ) ) ,
214
+ ]
215
+
216
+ await this . context . globalState . update ( "customModes" , mergedModes )
217
+ return mergedModes
106
218
}
107
219
108
220
async updateCustomMode ( slug : string , config : ModeConfig ) : Promise < void > {
109
221
try {
110
- const settingsPath = await this . getCustomModesFilePath ( )
222
+ const isProjectMode = config . source === "project"
223
+ const targetPath = isProjectMode ? await this . getWorkspaceRoomodes ( ) : await this . getCustomModesFilePath ( )
111
224
112
- await this . queueWrite ( async ( ) => {
113
- // Read and update file
114
- const content = await fs . readFile ( settingsPath , "utf-8" )
115
- const settings = JSON . parse ( content )
116
- const currentModes = settings . customModes || [ ]
117
- const updatedModes = currentModes . filter ( ( m : ModeConfig ) => m . slug !== slug )
118
- updatedModes . push ( config )
119
- settings . customModes = updatedModes
120
-
121
- const newContent = JSON . stringify ( settings , null , 2 )
225
+ if ( isProjectMode && ! targetPath ) {
226
+ throw new Error ( "No workspace folder found for project-specific mode" )
227
+ }
122
228
123
- // Write to file
124
- await fs . writeFile ( settingsPath , newContent )
229
+ await this . queueWrite ( async ( ) => {
230
+ // Ensure source is set correctly based on target file
231
+ const modeWithSource = {
232
+ ...config ,
233
+ source : isProjectMode ? ( "project" as const ) : ( "global" as const ) ,
234
+ }
125
235
126
- // Update global state
127
- await this . context . globalState . update ( "customModes" , updatedModes )
236
+ await this . updateModesInFile ( targetPath ! , ( modes ) => {
237
+ const updatedModes = modes . filter ( ( m ) => m . slug !== slug )
238
+ updatedModes . push ( modeWithSource )
239
+ return updatedModes
240
+ } )
128
241
129
- // Notify about the update
130
- await this . onUpdate ( )
242
+ await this . refreshMergedState ( )
131
243
} )
132
-
133
- // Success, no need for message
134
244
} catch ( error ) {
135
245
vscode . window . showErrorMessage (
136
246
`Failed to update custom mode: ${ error instanceof Error ? error . message : String ( error ) } ` ,
137
247
)
138
248
}
139
249
}
250
+ private async updateModesInFile ( filePath : string , operation : ( modes : ModeConfig [ ] ) => ModeConfig [ ] ) : Promise < void > {
251
+ let content = "{}"
252
+ try {
253
+ content = await fs . readFile ( filePath , "utf-8" )
254
+ } catch ( error ) {
255
+ // File might not exist yet
256
+ content = JSON . stringify ( { customModes : [ ] } )
257
+ }
258
+
259
+ let settings
260
+ try {
261
+ settings = JSON . parse ( content )
262
+ } catch ( error ) {
263
+ console . error ( `[CustomModesManager] Failed to parse JSON from ${ filePath } :` , error )
264
+ settings = { customModes : [ ] }
265
+ }
266
+ settings . customModes = operation ( settings . customModes || [ ] )
267
+ await fs . writeFile ( filePath , JSON . stringify ( settings , null , 2 ) , "utf-8" )
268
+ }
269
+
270
+ private async refreshMergedState ( ) : Promise < void > {
271
+ const settingsPath = await this . getCustomModesFilePath ( )
272
+ const roomodesPath = await this . getWorkspaceRoomodes ( )
273
+
274
+ const settingsModes = await this . loadModesFromFile ( settingsPath )
275
+ const roomodesModes = roomodesPath ? await this . loadModesFromFile ( roomodesPath ) : [ ]
276
+ const mergedModes = await this . mergeCustomModes ( roomodesModes , settingsModes )
277
+
278
+ await this . context . globalState . update ( "customModes" , mergedModes )
279
+ await this . onUpdate ( )
280
+ }
140
281
141
282
async deleteCustomMode ( slug : string ) : Promise < void > {
142
283
try {
143
284
const settingsPath = await this . getCustomModesFilePath ( )
285
+ const roomodesPath = await this . getWorkspaceRoomodes ( )
286
+
287
+ const settingsModes = await this . loadModesFromFile ( settingsPath )
288
+ const roomodesModes = roomodesPath ? await this . loadModesFromFile ( roomodesPath ) : [ ]
289
+
290
+ // Find the mode in either file
291
+ const projectMode = roomodesModes . find ( ( m ) => m . slug === slug )
292
+ const globalMode = settingsModes . find ( ( m ) => m . slug === slug )
293
+
294
+ if ( ! projectMode && ! globalMode ) {
295
+ throw new Error ( "Write error: Mode not found" )
296
+ }
144
297
145
298
await this . queueWrite ( async ( ) => {
146
- const content = await fs . readFile ( settingsPath , "utf-8" )
147
- const settings = JSON . parse ( content )
299
+ // Delete from project first if it exists there
300
+ if ( projectMode && roomodesPath ) {
301
+ await this . updateModesInFile ( roomodesPath , ( modes ) => modes . filter ( ( m ) => m . slug !== slug ) )
302
+ }
148
303
149
- settings . customModes = ( settings . customModes || [ ] ) . filter ( ( m : ModeConfig ) => m . slug !== slug )
150
- await fs . writeFile ( settingsPath , JSON . stringify ( settings , null , 2 ) )
304
+ // Delete from global settings if it exists there
305
+ if ( globalMode ) {
306
+ await this . updateModesInFile ( settingsPath , ( modes ) => modes . filter ( ( m ) => m . slug !== slug ) )
307
+ }
151
308
152
- await this . context . globalState . update ( "customModes" , settings . customModes )
153
- await this . onUpdate ( )
309
+ await this . refreshMergedState ( )
154
310
} )
155
311
} catch ( error ) {
156
312
vscode . window . showErrorMessage (
@@ -165,9 +321,6 @@ export class CustomModesManager {
165
321
return settingsDir
166
322
}
167
323
168
- /**
169
- * Delete the custom modes file and reset to default state
170
- */
171
324
async resetCustomModes ( ) : Promise < void > {
172
325
try {
173
326
const filePath = await this . getCustomModesFilePath ( )
0 commit comments