Skip to content

Commit be9565a

Browse files
committed
Enhance CustomModesManager to support loading modes from YAML files in .roo/modes directory and update create mode instructions for new format
1 parent 8c11fe5 commit be9565a

File tree

3 files changed

+434
-116
lines changed

3 files changed

+434
-116
lines changed

src/core/config/CustomModesManager.ts

Lines changed: 235 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as vscode from "vscode"
22
import * as path from "path"
33
import * as fs from "fs/promises"
4+
import * as yaml from "js-yaml"
45
import { customModesSettingsSchema } from "../../schemas"
56
import { ModeConfig } from "../../shared/modes"
67
import { fileExistsAtPath } from "../../utils/fs"
@@ -9,6 +10,9 @@ import { logger } from "../../utils/logging"
910
import { GlobalFileNames } from "../../shared/globalFileNames"
1011

1112
const ROOMODES_FILENAME = ".roomodes"
13+
const ROO_DIR = ".roo"
14+
const MODES_DIR = "modes"
15+
const YAML_EXTENSION = ".yaml"
1216

1317
export 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-zA-Z0-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

Comments
 (0)