Skip to content
Merged
128 changes: 107 additions & 21 deletions src/core/config/CustomModesManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as path from "path"
import * as fs from "fs/promises"

import * as yaml from "yaml"
import stripBom from "strip-bom"

import { type ModeConfig, customModesSettingsSchema } from "@roo-code/types"

Expand All @@ -11,6 +12,7 @@ import { getWorkspacePath } from "../../utils/path"
import { logger } from "../../utils/logging"
import { GlobalFileNames } from "../../shared/globalFileNames"
import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
import { t } from "../../i18n"

const ROOMODES_FILENAME = ".roomodes"

Expand Down Expand Up @@ -73,12 +75,88 @@ export class CustomModesManager {
return exists ? roomodesPath : undefined
}

/**
* Regex pattern for problematic characters that need to be cleaned from YAML content
* Includes:
* - \u00A0: Non-breaking space
* - \u200B-\u200D: Zero-width spaces and joiners
* - \u2010-\u2015, \u2212: Various dash characters
* - \u2018-\u2019: Smart single quotes
* - \u201C-\u201D: Smart double quotes
*/
private static readonly PROBLEMATIC_CHARS_REGEX =
// eslint-disable-next-line no-misleading-character-class
/[\u00A0\u200B\u200C\u200D\u2010\u2011\u2012\u2013\u2014\u2015\u2212\u2018\u2019\u201C\u201D]/g

/**
* Clean invisible and problematic characters from YAML content
*/
private cleanInvisibleCharacters(content: string): string {
// Single pass replacement for all problematic characters
return content.replace(CustomModesManager.PROBLEMATIC_CHARS_REGEX, (match) => {
switch (match) {
case "\u00A0": // Non-breaking space
return " "
case "\u200B": // Zero-width space
case "\u200C": // Zero-width non-joiner
case "\u200D": // Zero-width joiner
return ""
case "\u2018": // Left single quotation mark
case "\u2019": // Right single quotation mark
return "'"
case "\u201C": // Left double quotation mark
case "\u201D": // Right double quotation mark
return '"'
default: // Dash characters (U+2010 through U+2015, U+2212)
return "-"
}
})
}

/**
* Parse YAML content with enhanced error handling and preprocessing
*/
private parseYamlSafely(content: string, filePath: string): any {
// Clean the content
let cleanedContent = stripBom(content)
cleanedContent = this.cleanInvisibleCharacters(cleanedContent)

try {
return yaml.parse(cleanedContent)
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
console.error(`[CustomModesManager] Failed to parse YAML from ${filePath}:`, errorMsg)

// Show user-friendly error message for .roomodes files
if (filePath.endsWith(ROOMODES_FILENAME)) {
const lineMatch = errorMsg.match(/at line (\d+)/)
const line = lineMatch ? lineMatch[1] : "unknown"
vscode.window.showErrorMessage(t("common:customModes.errors.yamlParseError", { line }))
}

// Return empty object to prevent duplicate error handling
return {}
}
}

private async loadModesFromFile(filePath: string): Promise<ModeConfig[]> {
try {
const content = await fs.readFile(filePath, "utf-8")
const settings = yaml.parse(content)
const settings = this.parseYamlSafely(content, filePath)
const result = customModesSettingsSchema.safeParse(settings)

if (!result.success) {
console.error(`[CustomModesManager] Schema validation failed for ${filePath}:`, result.error)

// Show user-friendly error for .roomodes files
if (filePath.endsWith(ROOMODES_FILENAME)) {
const issues = result.error.issues
.map((issue) => `• ${issue.path.join(".")}: ${issue.message}`)
.join("\n")

vscode.window.showErrorMessage(t("common:customModes.errors.schemaValidationError", { issues }))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

}

return []
}

Expand All @@ -89,8 +167,11 @@ export class CustomModesManager {
// Add source to each mode
return result.data.customModes.map((mode) => ({ ...mode, source }))
} catch (error) {
const errorMsg = `Failed to load modes from ${filePath}: ${error instanceof Error ? error.message : String(error)}`
console.error(`[CustomModesManager] ${errorMsg}`)
// Only log if the error wasn't already handled in parseYamlSafely
if (!(error as any).alreadyHandled) {
const errorMsg = `Failed to load modes from ${filePath}: ${error instanceof Error ? error.message : String(error)}`
console.error(`[CustomModesManager] ${errorMsg}`)
}
return []
}
}
Expand Down Expand Up @@ -124,7 +205,12 @@ export class CustomModesManager {
const fileExists = await fileExistsAtPath(filePath)

if (!fileExists) {
await this.queueWrite(() => fs.writeFile(filePath, yaml.stringify({ customModes: [] })))
await this.queueWrite(() =>
fs.writeFile(
filePath,
yaml.stringify({ customModes: [] }, { lineWidth: 0, defaultStringType: "PLAIN" }),
),
)
}

return filePath
Expand All @@ -147,13 +233,12 @@ export class CustomModesManager {
await this.getCustomModesFilePath()
const content = await fs.readFile(settingsPath, "utf-8")

const errorMessage =
"Invalid custom modes format. Please ensure your settings follow the correct YAML format."
const errorMessage = t("common:customModes.errors.invalidFormat")

let config: any

try {
config = yaml.parse(content)
config = this.parseYamlSafely(content, settingsPath)
} catch (error) {
console.error(error)
vscode.window.showErrorMessage(errorMessage)
Expand Down Expand Up @@ -284,7 +369,7 @@ export class CustomModesManager {

if (!workspaceFolders || workspaceFolders.length === 0) {
logger.error("Failed to update project mode: No workspace folder found", { slug })
throw new Error("No workspace folder found for project-specific mode")
throw new Error(t("common:customModes.errors.noWorkspaceForProject"))
}

const workspaceRoot = getWorkspacePath()
Expand Down Expand Up @@ -318,7 +403,7 @@ export class CustomModesManager {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logger.error("Failed to update custom mode", { slug, error: errorMessage })
vscode.window.showErrorMessage(`Failed to update custom mode: ${errorMessage}`)
vscode.window.showErrorMessage(t("common:customModes.errors.updateFailed", { error: errorMessage }))
}
}

Expand All @@ -329,20 +414,20 @@ export class CustomModesManager {
content = await fs.readFile(filePath, "utf-8")
} catch (error) {
// File might not exist yet.
content = yaml.stringify({ customModes: [] })
content = yaml.stringify({ customModes: [] }, { lineWidth: 0, defaultStringType: "PLAIN" })
}

let settings

try {
settings = yaml.parse(content)
settings = this.parseYamlSafely(content, filePath)
} catch (error) {
console.error(`[CustomModesManager] Failed to parse YAML from ${filePath}:`, error)
// Error already logged in parseYamlSafely
settings = { customModes: [] }
}

settings.customModes = operation(settings.customModes || [])
await fs.writeFile(filePath, yaml.stringify(settings), "utf-8")
await fs.writeFile(filePath, yaml.stringify(settings, { lineWidth: 0, defaultStringType: "PLAIN" }), "utf-8")
}

private async refreshMergedState(): Promise<void> {
Expand Down Expand Up @@ -373,7 +458,7 @@ export class CustomModesManager {
const globalMode = settingsModes.find((m) => m.slug === slug)

if (!projectMode && !globalMode) {
throw new Error("Write error: Mode not found")
throw new Error(t("common:customModes.errors.modeNotFound"))
}

await this.queueWrite(async () => {
Expand All @@ -392,23 +477,24 @@ export class CustomModesManager {
await this.refreshMergedState()
})
} catch (error) {
vscode.window.showErrorMessage(
`Failed to delete custom mode: ${error instanceof Error ? error.message : String(error)}`,
)
const errorMessage = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(t("common:customModes.errors.deleteFailed", { error: errorMessage }))
}
}

public async resetCustomModes(): Promise<void> {
try {
const filePath = await this.getCustomModesFilePath()
await fs.writeFile(filePath, yaml.stringify({ customModes: [] }))
await fs.writeFile(
filePath,
yaml.stringify({ customModes: [] }, { lineWidth: 0, defaultStringType: "PLAIN" }),
)
await this.context.globalState.update("customModes", [])
this.clearCache()
await this.onUpdate()
} catch (error) {
vscode.window.showErrorMessage(
`Failed to reset custom modes: ${error instanceof Error ? error.message : String(error)}`,
)
const errorMessage = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(t("common:customModes.errors.resetFailed", { error: errorMessage }))
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/core/config/__tests__/CustomModesManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -754,7 +754,7 @@ describe("CustomModesManager", () => {

await manager.deleteCustomMode("non-existent-mode")

expect(mockShowError).toHaveBeenCalledWith(expect.stringContaining("Write error"))
expect(mockShowError).toHaveBeenCalledWith("customModes.errors.deleteFailed")
})
})

Expand Down
Loading
Loading