CustomModesManager.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919
  1. import * as vscode from "vscode"
  2. import * as path from "path"
  3. import * as fs from "fs/promises"
  4. import * as yaml from "yaml"
  5. import stripBom from "strip-bom"
  6. import { type ModeConfig, type PromptComponent, customModesSettingsSchema, modeConfigSchema } from "@roo-code/types"
  7. import { fileExistsAtPath } from "../../utils/fs"
  8. import { getWorkspacePath } from "../../utils/path"
  9. import { getGlobalRooDirectory } from "../../services/roo-config"
  10. import { logger } from "../../utils/logging"
  11. import { GlobalFileNames } from "../../shared/globalFileNames"
  12. import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
  13. import { t } from "../../i18n"
  14. const ROOMODES_FILENAME = ".roomodes"
  15. // Type definitions for import/export functionality
  16. interface RuleFile {
  17. relativePath: string
  18. content: string
  19. }
  20. interface ExportedModeConfig extends ModeConfig {
  21. rulesFiles?: RuleFile[]
  22. }
  23. interface ImportData {
  24. customModes: ExportedModeConfig[]
  25. }
  26. interface ExportResult {
  27. success: boolean
  28. yaml?: string
  29. error?: string
  30. }
  31. interface ImportResult {
  32. success: boolean
  33. error?: string
  34. }
  35. export class CustomModesManager {
  36. private static readonly cacheTTL = 10_000
  37. private disposables: vscode.Disposable[] = []
  38. private isWriting = false
  39. private writeQueue: Array<() => Promise<void>> = []
  40. private cachedModes: ModeConfig[] | null = null
  41. private cachedAt: number = 0
  42. constructor(
  43. private readonly context: vscode.ExtensionContext,
  44. private readonly onUpdate: () => Promise<void>,
  45. ) {
  46. this.watchCustomModesFiles().catch((error) => {
  47. console.error("[CustomModesManager] Failed to setup file watchers:", error)
  48. })
  49. }
  50. private async queueWrite(operation: () => Promise<void>): Promise<void> {
  51. this.writeQueue.push(operation)
  52. if (!this.isWriting) {
  53. await this.processWriteQueue()
  54. }
  55. }
  56. private async processWriteQueue(): Promise<void> {
  57. if (this.isWriting || this.writeQueue.length === 0) {
  58. return
  59. }
  60. this.isWriting = true
  61. try {
  62. while (this.writeQueue.length > 0) {
  63. const operation = this.writeQueue.shift()
  64. if (operation) {
  65. await operation()
  66. }
  67. }
  68. } finally {
  69. this.isWriting = false
  70. }
  71. }
  72. private async getWorkspaceRoomodes(): Promise<string | undefined> {
  73. const workspaceFolders = vscode.workspace.workspaceFolders
  74. if (!workspaceFolders || workspaceFolders.length === 0) {
  75. return undefined
  76. }
  77. const workspaceRoot = getWorkspacePath()
  78. const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME)
  79. const exists = await fileExistsAtPath(roomodesPath)
  80. return exists ? roomodesPath : undefined
  81. }
  82. /**
  83. * Regex pattern for problematic characters that need to be cleaned from YAML content
  84. * Includes:
  85. * - \u00A0: Non-breaking space
  86. * - \u200B-\u200D: Zero-width spaces and joiners
  87. * - \u2010-\u2015, \u2212: Various dash characters
  88. * - \u2018-\u2019: Smart single quotes
  89. * - \u201C-\u201D: Smart double quotes
  90. */
  91. private static readonly PROBLEMATIC_CHARS_REGEX =
  92. // eslint-disable-next-line no-misleading-character-class
  93. /[\u00A0\u200B\u200C\u200D\u2010\u2011\u2012\u2013\u2014\u2015\u2212\u2018\u2019\u201C\u201D]/g
  94. /**
  95. * Clean invisible and problematic characters from YAML content
  96. */
  97. private cleanInvisibleCharacters(content: string): string {
  98. // Single pass replacement for all problematic characters
  99. return content.replace(CustomModesManager.PROBLEMATIC_CHARS_REGEX, (match) => {
  100. switch (match) {
  101. case "\u00A0": // Non-breaking space
  102. return " "
  103. case "\u200B": // Zero-width space
  104. case "\u200C": // Zero-width non-joiner
  105. case "\u200D": // Zero-width joiner
  106. return ""
  107. case "\u2018": // Left single quotation mark
  108. case "\u2019": // Right single quotation mark
  109. return "'"
  110. case "\u201C": // Left double quotation mark
  111. case "\u201D": // Right double quotation mark
  112. return '"'
  113. default: // Dash characters (U+2010 through U+2015, U+2212)
  114. return "-"
  115. }
  116. })
  117. }
  118. /**
  119. * Parse YAML content with enhanced error handling and preprocessing
  120. */
  121. private parseYamlSafely(content: string, filePath: string): any {
  122. // Clean the content
  123. let cleanedContent = stripBom(content)
  124. cleanedContent = this.cleanInvisibleCharacters(cleanedContent)
  125. try {
  126. return yaml.parse(cleanedContent)
  127. } catch (yamlError) {
  128. // For .roomodes files, try JSON as fallback
  129. if (filePath.endsWith(ROOMODES_FILENAME)) {
  130. try {
  131. // Try parsing the original content as JSON (not the cleaned content)
  132. return JSON.parse(content)
  133. } catch (jsonError) {
  134. // JSON also failed, show the original YAML error
  135. const errorMsg = yamlError instanceof Error ? yamlError.message : String(yamlError)
  136. console.error(`[CustomModesManager] Failed to parse YAML from ${filePath}:`, errorMsg)
  137. const lineMatch = errorMsg.match(/at line (\d+)/)
  138. const line = lineMatch ? lineMatch[1] : "unknown"
  139. vscode.window.showErrorMessage(t("common:customModes.errors.yamlParseError", { line }))
  140. // Return empty object to prevent duplicate error handling
  141. return {}
  142. }
  143. }
  144. // For non-.roomodes files, just log and return empty object
  145. const errorMsg = yamlError instanceof Error ? yamlError.message : String(yamlError)
  146. console.error(`[CustomModesManager] Failed to parse YAML from ${filePath}:`, errorMsg)
  147. return {}
  148. }
  149. }
  150. private async loadModesFromFile(filePath: string): Promise<ModeConfig[]> {
  151. try {
  152. const content = await fs.readFile(filePath, "utf-8")
  153. const settings = this.parseYamlSafely(content, filePath)
  154. const result = customModesSettingsSchema.safeParse(settings)
  155. if (!result.success) {
  156. console.error(`[CustomModesManager] Schema validation failed for ${filePath}:`, result.error)
  157. // Show user-friendly error for .roomodes files
  158. if (filePath.endsWith(ROOMODES_FILENAME)) {
  159. const issues = result.error.issues
  160. .map((issue) => `• ${issue.path.join(".")}: ${issue.message}`)
  161. .join("\n")
  162. vscode.window.showErrorMessage(t("common:customModes.errors.schemaValidationError", { issues }))
  163. }
  164. return []
  165. }
  166. // Determine source based on file path
  167. const isRoomodes = filePath.endsWith(ROOMODES_FILENAME)
  168. const source = isRoomodes ? ("project" as const) : ("global" as const)
  169. // Add source to each mode
  170. return result.data.customModes.map((mode) => ({ ...mode, source }))
  171. } catch (error) {
  172. // Only log if the error wasn't already handled in parseYamlSafely
  173. if (!(error as any).alreadyHandled) {
  174. const errorMsg = `Failed to load modes from ${filePath}: ${error instanceof Error ? error.message : String(error)}`
  175. console.error(`[CustomModesManager] ${errorMsg}`)
  176. }
  177. return []
  178. }
  179. }
  180. private async mergeCustomModes(projectModes: ModeConfig[], globalModes: ModeConfig[]): Promise<ModeConfig[]> {
  181. const slugs = new Set<string>()
  182. const merged: ModeConfig[] = []
  183. // Add project mode (takes precedence)
  184. for (const mode of projectModes) {
  185. if (!slugs.has(mode.slug)) {
  186. slugs.add(mode.slug)
  187. merged.push({ ...mode, source: "project" })
  188. }
  189. }
  190. // Add non-duplicate global modes
  191. for (const mode of globalModes) {
  192. if (!slugs.has(mode.slug)) {
  193. slugs.add(mode.slug)
  194. merged.push({ ...mode, source: "global" })
  195. }
  196. }
  197. return merged
  198. }
  199. public async getCustomModesFilePath(): Promise<string> {
  200. const settingsDir = await ensureSettingsDirectoryExists(this.context)
  201. const filePath = path.join(settingsDir, GlobalFileNames.customModes)
  202. const fileExists = await fileExistsAtPath(filePath)
  203. if (!fileExists) {
  204. await this.queueWrite(() => fs.writeFile(filePath, yaml.stringify({ customModes: [] }, { lineWidth: 0 })))
  205. }
  206. return filePath
  207. }
  208. private async watchCustomModesFiles(): Promise<void> {
  209. // Skip if test environment is detected
  210. if (process.env.NODE_ENV === "test") {
  211. return
  212. }
  213. const settingsPath = await this.getCustomModesFilePath()
  214. // Watch settings file
  215. const settingsWatcher = vscode.workspace.createFileSystemWatcher(settingsPath)
  216. const handleSettingsChange = async () => {
  217. try {
  218. // Ensure that the settings file exists (especially important for delete events)
  219. await this.getCustomModesFilePath()
  220. const content = await fs.readFile(settingsPath, "utf-8")
  221. const errorMessage = t("common:customModes.errors.invalidFormat")
  222. let config: any
  223. try {
  224. config = this.parseYamlSafely(content, settingsPath)
  225. } catch (error) {
  226. console.error(error)
  227. vscode.window.showErrorMessage(errorMessage)
  228. return
  229. }
  230. const result = customModesSettingsSchema.safeParse(config)
  231. if (!result.success) {
  232. vscode.window.showErrorMessage(errorMessage)
  233. return
  234. }
  235. // Get modes from .roomodes if it exists (takes precedence)
  236. const roomodesPath = await this.getWorkspaceRoomodes()
  237. const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
  238. // Merge modes from both sources (.roomodes takes precedence)
  239. const mergedModes = await this.mergeCustomModes(roomodesModes, result.data.customModes)
  240. await this.context.globalState.update("customModes", mergedModes)
  241. this.clearCache()
  242. await this.onUpdate()
  243. } catch (error) {
  244. console.error(`[CustomModesManager] Error handling settings file change:`, error)
  245. }
  246. }
  247. this.disposables.push(settingsWatcher.onDidChange(handleSettingsChange))
  248. this.disposables.push(settingsWatcher.onDidCreate(handleSettingsChange))
  249. this.disposables.push(settingsWatcher.onDidDelete(handleSettingsChange))
  250. this.disposables.push(settingsWatcher)
  251. // Watch .roomodes file - watch the path even if it doesn't exist yet
  252. const workspaceFolders = vscode.workspace.workspaceFolders
  253. if (workspaceFolders && workspaceFolders.length > 0) {
  254. const workspaceRoot = getWorkspacePath()
  255. const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME)
  256. const roomodesWatcher = vscode.workspace.createFileSystemWatcher(roomodesPath)
  257. const handleRoomodesChange = async () => {
  258. try {
  259. const settingsModes = await this.loadModesFromFile(settingsPath)
  260. const roomodesModes = await this.loadModesFromFile(roomodesPath)
  261. // .roomodes takes precedence
  262. const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
  263. await this.context.globalState.update("customModes", mergedModes)
  264. this.clearCache()
  265. await this.onUpdate()
  266. } catch (error) {
  267. console.error(`[CustomModesManager] Error handling .roomodes file change:`, error)
  268. }
  269. }
  270. this.disposables.push(roomodesWatcher.onDidChange(handleRoomodesChange))
  271. this.disposables.push(roomodesWatcher.onDidCreate(handleRoomodesChange))
  272. this.disposables.push(
  273. roomodesWatcher.onDidDelete(async () => {
  274. // When .roomodes is deleted, refresh with only settings modes
  275. try {
  276. const settingsModes = await this.loadModesFromFile(settingsPath)
  277. await this.context.globalState.update("customModes", settingsModes)
  278. this.clearCache()
  279. await this.onUpdate()
  280. } catch (error) {
  281. console.error(`[CustomModesManager] Error handling .roomodes file deletion:`, error)
  282. }
  283. }),
  284. )
  285. this.disposables.push(roomodesWatcher)
  286. }
  287. }
  288. public async getCustomModes(): Promise<ModeConfig[]> {
  289. // Check if we have a valid cached result.
  290. const now = Date.now()
  291. if (this.cachedModes && now - this.cachedAt < CustomModesManager.cacheTTL) {
  292. return this.cachedModes
  293. }
  294. // Get modes from settings file.
  295. const settingsPath = await this.getCustomModesFilePath()
  296. const settingsModes = await this.loadModesFromFile(settingsPath)
  297. // Get modes from .roomodes if it exists.
  298. const roomodesPath = await this.getWorkspaceRoomodes()
  299. const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
  300. // Create maps to store modes by source.
  301. const projectModes = new Map<string, ModeConfig>()
  302. const globalModes = new Map<string, ModeConfig>()
  303. // Add project modes (they take precedence).
  304. for (const mode of roomodesModes) {
  305. projectModes.set(mode.slug, { ...mode, source: "project" as const })
  306. }
  307. // Add global modes.
  308. for (const mode of settingsModes) {
  309. if (!projectModes.has(mode.slug)) {
  310. globalModes.set(mode.slug, { ...mode, source: "global" as const })
  311. }
  312. }
  313. // Combine modes in the correct order: project modes first, then global modes.
  314. const mergedModes = [
  315. ...roomodesModes.map((mode) => ({ ...mode, source: "project" as const })),
  316. ...settingsModes
  317. .filter((mode) => !projectModes.has(mode.slug))
  318. .map((mode) => ({ ...mode, source: "global" as const })),
  319. ]
  320. await this.context.globalState.update("customModes", mergedModes)
  321. this.cachedModes = mergedModes
  322. this.cachedAt = now
  323. return mergedModes
  324. }
  325. public async updateCustomMode(slug: string, config: ModeConfig): Promise<void> {
  326. try {
  327. const isProjectMode = config.source === "project"
  328. let targetPath: string
  329. if (isProjectMode) {
  330. const workspaceFolders = vscode.workspace.workspaceFolders
  331. if (!workspaceFolders || workspaceFolders.length === 0) {
  332. logger.error("Failed to update project mode: No workspace folder found", { slug })
  333. throw new Error(t("common:customModes.errors.noWorkspaceForProject"))
  334. }
  335. const workspaceRoot = getWorkspacePath()
  336. targetPath = path.join(workspaceRoot, ROOMODES_FILENAME)
  337. const exists = await fileExistsAtPath(targetPath)
  338. logger.info(`${exists ? "Updating" : "Creating"} project mode in ${ROOMODES_FILENAME}`, {
  339. slug,
  340. workspace: workspaceRoot,
  341. })
  342. } else {
  343. targetPath = await this.getCustomModesFilePath()
  344. }
  345. await this.queueWrite(async () => {
  346. // Ensure source is set correctly based on target file.
  347. const modeWithSource = {
  348. ...config,
  349. source: isProjectMode ? ("project" as const) : ("global" as const),
  350. }
  351. await this.updateModesInFile(targetPath, (modes) => {
  352. const updatedModes = modes.filter((m) => m.slug !== slug)
  353. updatedModes.push(modeWithSource)
  354. return updatedModes
  355. })
  356. this.clearCache()
  357. await this.refreshMergedState()
  358. })
  359. } catch (error) {
  360. const errorMessage = error instanceof Error ? error.message : String(error)
  361. logger.error("Failed to update custom mode", { slug, error: errorMessage })
  362. vscode.window.showErrorMessage(t("common:customModes.errors.updateFailed", { error: errorMessage }))
  363. }
  364. }
  365. private async updateModesInFile(filePath: string, operation: (modes: ModeConfig[]) => ModeConfig[]): Promise<void> {
  366. let content = "{}"
  367. try {
  368. content = await fs.readFile(filePath, "utf-8")
  369. } catch (error) {
  370. // File might not exist yet.
  371. content = yaml.stringify({ customModes: [] }, { lineWidth: 0 })
  372. }
  373. let settings
  374. try {
  375. settings = this.parseYamlSafely(content, filePath)
  376. } catch (error) {
  377. // Error already logged in parseYamlSafely
  378. settings = { customModes: [] }
  379. }
  380. settings.customModes = operation(settings.customModes || [])
  381. await fs.writeFile(filePath, yaml.stringify(settings, { lineWidth: 0 }), "utf-8")
  382. }
  383. private async refreshMergedState(): Promise<void> {
  384. const settingsPath = await this.getCustomModesFilePath()
  385. const roomodesPath = await this.getWorkspaceRoomodes()
  386. const settingsModes = await this.loadModesFromFile(settingsPath)
  387. const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
  388. const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
  389. await this.context.globalState.update("customModes", mergedModes)
  390. this.clearCache()
  391. await this.onUpdate()
  392. }
  393. public async deleteCustomMode(slug: string): Promise<void> {
  394. try {
  395. const settingsPath = await this.getCustomModesFilePath()
  396. const roomodesPath = await this.getWorkspaceRoomodes()
  397. const settingsModes = await this.loadModesFromFile(settingsPath)
  398. const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
  399. // Find the mode in either file
  400. const projectMode = roomodesModes.find((m) => m.slug === slug)
  401. const globalMode = settingsModes.find((m) => m.slug === slug)
  402. if (!projectMode && !globalMode) {
  403. throw new Error(t("common:customModes.errors.modeNotFound"))
  404. }
  405. await this.queueWrite(async () => {
  406. // Delete from project first if it exists there
  407. if (projectMode && roomodesPath) {
  408. await this.updateModesInFile(roomodesPath, (modes) => modes.filter((m) => m.slug !== slug))
  409. }
  410. // Delete from global settings if it exists there
  411. if (globalMode) {
  412. await this.updateModesInFile(settingsPath, (modes) => modes.filter((m) => m.slug !== slug))
  413. }
  414. // Clear cache when modes are deleted
  415. this.clearCache()
  416. await this.refreshMergedState()
  417. })
  418. } catch (error) {
  419. const errorMessage = error instanceof Error ? error.message : String(error)
  420. vscode.window.showErrorMessage(t("common:customModes.errors.deleteFailed", { error: errorMessage }))
  421. }
  422. }
  423. public async resetCustomModes(): Promise<void> {
  424. try {
  425. const filePath = await this.getCustomModesFilePath()
  426. await fs.writeFile(filePath, yaml.stringify({ customModes: [] }, { lineWidth: 0 }))
  427. await this.context.globalState.update("customModes", [])
  428. this.clearCache()
  429. await this.onUpdate()
  430. } catch (error) {
  431. const errorMessage = error instanceof Error ? error.message : String(error)
  432. vscode.window.showErrorMessage(t("common:customModes.errors.resetFailed", { error: errorMessage }))
  433. }
  434. }
  435. /**
  436. * Checks if a mode has associated rules files in the .roo/rules-{slug}/ directory
  437. * @param slug - The mode identifier to check
  438. * @returns True if the mode has rules files with content, false otherwise
  439. */
  440. public async checkRulesDirectoryHasContent(slug: string): Promise<boolean> {
  441. try {
  442. // Get workspace path
  443. const workspacePath = getWorkspacePath()
  444. if (!workspacePath) {
  445. return false
  446. }
  447. // Check if .roomodes file exists and contains this mode
  448. // This ensures we can only consolidate rules for modes that have been customized
  449. const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
  450. try {
  451. const roomodesExists = await fileExistsAtPath(roomodesPath)
  452. if (roomodesExists) {
  453. const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
  454. const roomodesData = yaml.parse(roomodesContent)
  455. const roomodesModes = roomodesData?.customModes || []
  456. // Check if this specific mode exists in .roomodes
  457. const modeInRoomodes = roomodesModes.find((m: any) => m.slug === slug)
  458. if (!modeInRoomodes) {
  459. return false // Mode not customized in .roomodes, cannot consolidate
  460. }
  461. } else {
  462. // If no .roomodes file exists, check if it's in global custom modes
  463. const allModes = await this.getCustomModes()
  464. const mode = allModes.find((m) => m.slug === slug)
  465. if (!mode) {
  466. return false // Not a custom mode, cannot consolidate
  467. }
  468. }
  469. } catch (error) {
  470. // If we can't read .roomodes, fall back to checking custom modes
  471. const allModes = await this.getCustomModes()
  472. const mode = allModes.find((m) => m.slug === slug)
  473. if (!mode) {
  474. return false // Not a custom mode, cannot consolidate
  475. }
  476. }
  477. // Check for .roo/rules-{slug}/ directory
  478. const modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`)
  479. try {
  480. const stats = await fs.stat(modeRulesDir)
  481. if (!stats.isDirectory()) {
  482. return false
  483. }
  484. } catch (error) {
  485. return false
  486. }
  487. // Check if directory has any content files
  488. try {
  489. const entries = await fs.readdir(modeRulesDir, { withFileTypes: true })
  490. for (const entry of entries) {
  491. if (entry.isFile()) {
  492. // Use path.join with modeRulesDir and entry.name for compatibility
  493. const filePath = path.join(modeRulesDir, entry.name)
  494. const content = await fs.readFile(filePath, "utf-8")
  495. if (content.trim()) {
  496. return true // Found at least one file with content
  497. }
  498. }
  499. }
  500. return false // No files with content found
  501. } catch (error) {
  502. return false
  503. }
  504. } catch (error) {
  505. logger.error("Failed to check rules directory for mode", {
  506. slug,
  507. error: error instanceof Error ? error.message : String(error),
  508. })
  509. return false
  510. }
  511. }
  512. /**
  513. * Exports a mode configuration with its associated rules files into a shareable YAML format
  514. * @param slug - The mode identifier to export
  515. * @param customPrompts - Optional custom prompts to merge into the export
  516. * @returns Success status with YAML content or error message
  517. */
  518. public async exportModeWithRules(slug: string, customPrompts?: PromptComponent): Promise<ExportResult> {
  519. try {
  520. // Import modes from shared to check built-in modes
  521. const { modes: builtInModes } = await import("../../shared/modes")
  522. // Get all current modes
  523. const allModes = await this.getCustomModes()
  524. let mode = allModes.find((m) => m.slug === slug)
  525. // If mode not found in custom modes, check if it's a built-in mode that has been customized
  526. if (!mode) {
  527. const workspacePath = getWorkspacePath()
  528. if (!workspacePath) {
  529. return { success: false, error: "No workspace found" }
  530. }
  531. const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
  532. try {
  533. const roomodesExists = await fileExistsAtPath(roomodesPath)
  534. if (roomodesExists) {
  535. const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
  536. const roomodesData = yaml.parse(roomodesContent)
  537. const roomodesModes = roomodesData?.customModes || []
  538. // Find the mode in .roomodes
  539. mode = roomodesModes.find((m: any) => m.slug === slug)
  540. }
  541. } catch (error) {
  542. // Continue to check built-in modes
  543. }
  544. // If still not found, check if it's a built-in mode
  545. if (!mode) {
  546. const builtInMode = builtInModes.find((m) => m.slug === slug)
  547. if (builtInMode) {
  548. // Use the built-in mode as the base
  549. mode = { ...builtInMode }
  550. } else {
  551. return { success: false, error: "Mode not found" }
  552. }
  553. }
  554. }
  555. // Get workspace path
  556. const workspacePath = getWorkspacePath()
  557. if (!workspacePath) {
  558. return { success: false, error: "No workspace found" }
  559. }
  560. // Check for .roo/rules-{slug}/ directory
  561. const modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`)
  562. let rulesFiles: RuleFile[] = []
  563. try {
  564. const stats = await fs.stat(modeRulesDir)
  565. if (stats.isDirectory()) {
  566. // Extract content specific to this mode by looking for the mode-specific rules
  567. const entries = await fs.readdir(modeRulesDir, { withFileTypes: true })
  568. for (const entry of entries) {
  569. if (entry.isFile()) {
  570. // Use path.join with modeRulesDir and entry.name for compatibility
  571. const filePath = path.join(modeRulesDir, entry.name)
  572. const content = await fs.readFile(filePath, "utf-8")
  573. if (content.trim()) {
  574. // Calculate relative path from .roo directory
  575. const relativePath = path.relative(path.join(workspacePath, ".roo"), filePath)
  576. rulesFiles.push({ relativePath, content: content.trim() })
  577. }
  578. }
  579. }
  580. }
  581. } catch (error) {
  582. // Directory doesn't exist, which is fine - mode might not have rules
  583. }
  584. // Create an export mode with rules files preserved
  585. const exportMode: ExportedModeConfig = {
  586. ...mode,
  587. // Remove source property for export
  588. source: "project" as const,
  589. }
  590. // Merge custom prompts if provided
  591. if (customPrompts) {
  592. if (customPrompts.roleDefinition) exportMode.roleDefinition = customPrompts.roleDefinition
  593. if (customPrompts.description) exportMode.description = customPrompts.description
  594. if (customPrompts.whenToUse) exportMode.whenToUse = customPrompts.whenToUse
  595. if (customPrompts.customInstructions) exportMode.customInstructions = customPrompts.customInstructions
  596. }
  597. // Add rules files if any exist
  598. if (rulesFiles.length > 0) {
  599. exportMode.rulesFiles = rulesFiles
  600. }
  601. // Generate YAML
  602. const exportData = {
  603. customModes: [exportMode],
  604. }
  605. const yamlContent = yaml.stringify(exportData)
  606. return { success: true, yaml: yamlContent }
  607. } catch (error) {
  608. const errorMessage = error instanceof Error ? error.message : String(error)
  609. logger.error("Failed to export mode with rules", { slug, error: errorMessage })
  610. return { success: false, error: errorMessage }
  611. }
  612. }
  613. /**
  614. * Imports modes from YAML content, including their associated rules files
  615. * @param yamlContent - The YAML content containing mode configurations
  616. * @param source - Target level for import: "global" (all projects) or "project" (current workspace only)
  617. * @returns Success status with optional error message
  618. */
  619. public async importModeWithRules(
  620. yamlContent: string,
  621. source: "global" | "project" = "project",
  622. ): Promise<ImportResult> {
  623. try {
  624. // Parse the YAML content with proper type validation
  625. let importData: ImportData
  626. try {
  627. const parsed = yaml.parse(yamlContent)
  628. // Validate the structure
  629. if (!parsed?.customModes || !Array.isArray(parsed.customModes) || parsed.customModes.length === 0) {
  630. return { success: false, error: "Invalid import format: Expected 'customModes' array in YAML" }
  631. }
  632. importData = parsed as ImportData
  633. } catch (parseError) {
  634. return {
  635. success: false,
  636. error: `Invalid YAML format: ${parseError instanceof Error ? parseError.message : "Failed to parse YAML"}`,
  637. }
  638. }
  639. // Check workspace availability early if importing at project level
  640. if (source === "project") {
  641. const workspacePath = getWorkspacePath()
  642. if (!workspacePath) {
  643. return { success: false, error: "No workspace found" }
  644. }
  645. }
  646. // Process each mode in the import
  647. for (const importMode of importData.customModes) {
  648. const { rulesFiles, ...modeConfig } = importMode
  649. // Validate the mode configuration
  650. const validationResult = modeConfigSchema.safeParse(modeConfig)
  651. if (!validationResult.success) {
  652. logger.error(`Invalid mode configuration for ${modeConfig.slug}`, {
  653. errors: validationResult.error.errors,
  654. })
  655. return {
  656. success: false,
  657. error: `Invalid mode configuration for ${modeConfig.slug}: ${validationResult.error.errors.map((e) => e.message).join(", ")}`,
  658. }
  659. }
  660. // Check for existing mode conflicts
  661. const existingModes = await this.getCustomModes()
  662. const existingMode = existingModes.find((m) => m.slug === importMode.slug)
  663. if (existingMode) {
  664. logger.info(`Overwriting existing mode: ${importMode.slug}`)
  665. }
  666. // Import the mode configuration with the specified source
  667. await this.updateCustomMode(importMode.slug, {
  668. ...modeConfig,
  669. source: source, // Use the provided source parameter
  670. })
  671. // Handle project-level imports
  672. if (source === "project") {
  673. const workspacePath = getWorkspacePath()
  674. // Always remove the existing rules folder for this mode if it exists
  675. // This ensures that if the imported mode has no rules, the folder is cleaned up
  676. const rulesFolderPath = path.join(workspacePath, ".roo", `rules-${importMode.slug}`)
  677. try {
  678. await fs.rm(rulesFolderPath, { recursive: true, force: true })
  679. logger.info(`Removed existing rules folder for mode ${importMode.slug}`)
  680. } catch (error) {
  681. // It's okay if the folder doesn't exist
  682. logger.debug(`No existing rules folder to remove for mode ${importMode.slug}`)
  683. }
  684. // Only create new rules files if they exist in the import
  685. if (rulesFiles && Array.isArray(rulesFiles) && rulesFiles.length > 0) {
  686. // Import the new rules files with path validation
  687. for (const ruleFile of rulesFiles) {
  688. if (ruleFile.relativePath && ruleFile.content) {
  689. // Validate the relative path to prevent path traversal attacks
  690. const normalizedRelativePath = path.normalize(ruleFile.relativePath)
  691. // Ensure the path doesn't contain traversal sequences
  692. if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) {
  693. logger.error(`Invalid file path detected: ${ruleFile.relativePath}`)
  694. continue // Skip this file but continue with others
  695. }
  696. const targetPath = path.join(workspacePath, ".roo", normalizedRelativePath)
  697. const normalizedTargetPath = path.normalize(targetPath)
  698. const expectedBasePath = path.normalize(path.join(workspacePath, ".roo"))
  699. // Ensure the resolved path stays within the .roo directory
  700. if (!normalizedTargetPath.startsWith(expectedBasePath)) {
  701. logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`)
  702. continue // Skip this file but continue with others
  703. }
  704. // Ensure directory exists
  705. const targetDir = path.dirname(targetPath)
  706. await fs.mkdir(targetDir, { recursive: true })
  707. // Write the file
  708. await fs.writeFile(targetPath, ruleFile.content, "utf-8")
  709. }
  710. }
  711. }
  712. } else if (source === "global" && rulesFiles && Array.isArray(rulesFiles)) {
  713. // For global imports, preserve the rules files structure in the global .roo directory
  714. const globalRooDir = getGlobalRooDirectory()
  715. // Always remove the existing rules folder for this mode if it exists
  716. // This ensures that if the imported mode has no rules, the folder is cleaned up
  717. const rulesFolderPath = path.join(globalRooDir, `rules-${importMode.slug}`)
  718. try {
  719. await fs.rm(rulesFolderPath, { recursive: true, force: true })
  720. logger.info(`Removed existing global rules folder for mode ${importMode.slug}`)
  721. } catch (error) {
  722. // It's okay if the folder doesn't exist
  723. logger.debug(`No existing global rules folder to remove for mode ${importMode.slug}`)
  724. }
  725. // Import the new rules files with path validation
  726. for (const ruleFile of rulesFiles) {
  727. if (ruleFile.relativePath && ruleFile.content) {
  728. // Validate the relative path to prevent path traversal attacks
  729. const normalizedRelativePath = path.normalize(ruleFile.relativePath)
  730. // Ensure the path doesn't contain traversal sequences
  731. if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) {
  732. logger.error(`Invalid file path detected: ${ruleFile.relativePath}`)
  733. continue // Skip this file but continue with others
  734. }
  735. const targetPath = path.join(globalRooDir, normalizedRelativePath)
  736. const normalizedTargetPath = path.normalize(targetPath)
  737. const expectedBasePath = path.normalize(globalRooDir)
  738. // Ensure the resolved path stays within the global .roo directory
  739. if (!normalizedTargetPath.startsWith(expectedBasePath)) {
  740. logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`)
  741. continue // Skip this file but continue with others
  742. }
  743. // Ensure directory exists
  744. const targetDir = path.dirname(targetPath)
  745. await fs.mkdir(targetDir, { recursive: true })
  746. // Write the file
  747. await fs.writeFile(targetPath, ruleFile.content, "utf-8")
  748. }
  749. }
  750. }
  751. }
  752. // Refresh the modes after import
  753. await this.refreshMergedState()
  754. return { success: true }
  755. } catch (error) {
  756. const errorMessage = error instanceof Error ? error.message : String(error)
  757. logger.error("Failed to import mode with rules", { error: errorMessage })
  758. return { success: false, error: errorMessage }
  759. }
  760. }
  761. private clearCache(): void {
  762. this.cachedModes = null
  763. this.cachedAt = 0
  764. }
  765. dispose(): void {
  766. for (const disposable of this.disposables) {
  767. disposable.dispose()
  768. }
  769. this.disposables = []
  770. }
  771. }