headlesscode 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/ATTRIBUTION.md +53 -0
  2. package/CODE_OF_CONDUCT.md +130 -0
  3. package/CONTRIBUTING.md +107 -0
  4. package/LICENSE +202 -0
  5. package/README.md +486 -0
  6. package/SECURITY.md +211 -0
  7. package/bin/headlesscode.mjs +83 -0
  8. package/package.json +63 -0
  9. package/shared/prompts/review-mode-prompt-short.md +93 -0
  10. package/shared/prompts/review-mode-prompt.md +281 -0
  11. package/shared/rules-code/rules.md +22 -0
  12. package/shared/stacks/cpp/rules.md +30 -0
  13. package/shared/stacks/fastapi/rules.md +30 -0
  14. package/shared/stacks/javascript/rules.md +37 -0
  15. package/shared/stacks/postgresql/rules.md +31 -0
  16. package/shared/stacks/python/rules.md +35 -0
  17. package/shared/stacks/react/rules.md +11 -0
  18. package/shared/stacks/typescript/rules.md +10 -0
  19. package/src/budget/budget.ts +221 -0
  20. package/src/budget/concurrency.ts +126 -0
  21. package/src/budget/cost.ts +309 -0
  22. package/src/budget/index.ts +8 -0
  23. package/src/checkpoints/cli.ts +256 -0
  24. package/src/checkpoints/service.ts +227 -0
  25. package/src/cli.ts +1535 -0
  26. package/src/cloud/docker-provider.ts +334 -0
  27. package/src/cloud/provider.ts +300 -0
  28. package/src/codeintel/call-graph.ts +78 -0
  29. package/src/codeintel/find-references.ts +123 -0
  30. package/src/codeintel/go-to-definition.ts +193 -0
  31. package/src/codeintel/handlers.ts +190 -0
  32. package/src/codeintel/import-graph.ts +173 -0
  33. package/src/codeintel/outline.ts +180 -0
  34. package/src/codeintel/position.ts +77 -0
  35. package/src/codeintel/program.ts +350 -0
  36. package/src/codeintel/rename-symbol.ts +213 -0
  37. package/src/codeintel/tools.ts +280 -0
  38. package/src/codemap/build.ts +135 -0
  39. package/src/codemap/cli.ts +190 -0
  40. package/src/codemap/extract.ts +339 -0
  41. package/src/codemap/files.ts +236 -0
  42. package/src/codemap/fingerprint.ts +65 -0
  43. package/src/codemap/flows.ts +62 -0
  44. package/src/codemap/html.ts +451 -0
  45. package/src/codemap/lock.ts +80 -0
  46. package/src/codemap/types.ts +101 -0
  47. package/src/codesearch/airunner-embedder.ts +185 -0
  48. package/src/codesearch/chunk.ts +339 -0
  49. package/src/codesearch/cli.ts +223 -0
  50. package/src/codesearch/embedder.ts +332 -0
  51. package/src/codesearch/files.ts +280 -0
  52. package/src/codesearch/index.ts +469 -0
  53. package/src/codesearch/ollama-embedder.ts +205 -0
  54. package/src/codesearch/search.ts +141 -0
  55. package/src/codesearch/types.ts +100 -0
  56. package/src/config/mode-models.ts +218 -0
  57. package/src/dashboard/aggregate.ts +364 -0
  58. package/src/dashboard/chat-thread.ts +141 -0
  59. package/src/dashboard/checkpoints.ts +124 -0
  60. package/src/dashboard/cli.ts +193 -0
  61. package/src/dashboard/codemap.ts +44 -0
  62. package/src/dashboard/files.ts +121 -0
  63. package/src/dashboard/page.ts +2803 -0
  64. package/src/dashboard/self-improvement-metrics.ts +282 -0
  65. package/src/dashboard/server.ts +1103 -0
  66. package/src/dashboard/session-launch.ts +310 -0
  67. package/src/dashboard/timeline.ts +273 -0
  68. package/src/dashboard/tool-exec.ts +107 -0
  69. package/src/dashboard/trend-cli.ts +141 -0
  70. package/src/dashboard/trend.ts +413 -0
  71. package/src/decision-proxy/cli.ts +261 -0
  72. package/src/decision-proxy/proxy.ts +569 -0
  73. package/src/deploy/gate-cli.ts +147 -0
  74. package/src/deploy/gate.ts +254 -0
  75. package/src/engine/condense.ts +512 -0
  76. package/src/engine/events.ts +428 -0
  77. package/src/engine/handoff.ts +71 -0
  78. package/src/engine/lazy-tools.ts +160 -0
  79. package/src/engine/local-explore.ts +653 -0
  80. package/src/engine/logger.ts +96 -0
  81. package/src/engine/loop.ts +5517 -0
  82. package/src/engine/parser.ts +347 -0
  83. package/src/engine/prompt.ts +860 -0
  84. package/src/engine/reports.ts +47 -0
  85. package/src/engine/stacks.ts +448 -0
  86. package/src/engine/types.ts +291 -0
  87. package/src/engine/usage.ts +186 -0
  88. package/src/github/app-auth.ts +161 -0
  89. package/src/github/cli.ts +448 -0
  90. package/src/github/installations.ts +133 -0
  91. package/src/github/pr.ts +321 -0
  92. package/src/github/provision.ts +118 -0
  93. package/src/github/push.ts +122 -0
  94. package/src/index-util.ts +50 -0
  95. package/src/index.ts +81 -0
  96. package/src/init/cli.ts +248 -0
  97. package/src/init/gitignore.ts +74 -0
  98. package/src/llm/ollama.ts +308 -0
  99. package/src/llm/openrouter.ts +868 -0
  100. package/src/llm/preflight.ts +367 -0
  101. package/src/llm/transcript-capture.ts +84 -0
  102. package/src/memory/embed.ts +110 -0
  103. package/src/memory/index.ts +22 -0
  104. package/src/memory/local.ts +259 -0
  105. package/src/memory/summarizer.ts +283 -0
  106. package/src/memory/types.ts +153 -0
  107. package/src/memory/uwuchat.ts +157 -0
  108. package/src/migrate/cli.ts +115 -0
  109. package/src/orchestrator/analyze-cli.ts +104 -0
  110. package/src/orchestrator/auto-split.ts +206 -0
  111. package/src/orchestrator/cleanup.ts +1003 -0
  112. package/src/orchestrator/cli.ts +3571 -0
  113. package/src/orchestrator/cost-estimate.ts +564 -0
  114. package/src/orchestrator/cost-history-cli.ts +242 -0
  115. package/src/orchestrator/cost-history.ts +397 -0
  116. package/src/orchestrator/git-sync.ts +250 -0
  117. package/src/orchestrator/index.ts +153 -0
  118. package/src/orchestrator/log-analysis.ts +0 -0
  119. package/src/orchestrator/merge-check.ts +108 -0
  120. package/src/orchestrator/pipeline.ts +411 -0
  121. package/src/orchestrator/resume.ts +1940 -0
  122. package/src/orchestrator/reviewer.ts +503 -0
  123. package/src/orchestrator/split.ts +296 -0
  124. package/src/orchestrator/state.ts +542 -0
  125. package/src/orchestrator/status.ts +697 -0
  126. package/src/orchestrator/verification-gate.ts +134 -0
  127. package/src/orchestrator/watch.ts +898 -0
  128. package/src/permissions/commands.ts +1083 -0
  129. package/src/permissions/config.ts +241 -0
  130. package/src/permissions/index.ts +12 -0
  131. package/src/permissions/protected-files.ts +96 -0
  132. package/src/permissions/store-protection.ts +272 -0
  133. package/src/project-store.ts +648 -0
  134. package/src/projects/cli.ts +382 -0
  135. package/src/qa/qa.ts +487 -0
  136. package/src/tools/browser/handler.ts +346 -0
  137. package/src/tools/browser/service.ts +406 -0
  138. package/src/tools/browser/smoke.ts +78 -0
  139. package/src/tools/browser/tool.ts +99 -0
  140. package/src/tools/executor.ts +2575 -0
  141. package/src/tools/language-detect.ts +183 -0
  142. package/src/tools/output-summarizer.ts +369 -0
  143. package/src/tools/run-tests.ts +302 -0
  144. package/src/tools/set-indentation-tool.ts +49 -0
  145. package/src/tools/test-selection.ts +160 -0
  146. package/src/vendor/tests/smoke.ts +103 -0
  147. package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
  148. package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
  149. package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
  150. package/src/vendor/zoo-code/shim/os-name.ts +18 -0
  151. package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
  152. package/src/vendor/zoo-code/shim/vscode.ts +76 -0
  153. package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
  154. package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
  155. package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
  156. package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
  157. package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
  158. package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
  159. package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
  160. package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
  161. package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
  162. package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
  163. package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
  164. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
  165. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
  166. package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
  167. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
  168. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
  169. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
  170. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
  171. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
  172. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
  173. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
  174. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
  175. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
  176. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
  177. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
  178. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
  179. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
  180. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
  181. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
  182. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
  183. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
  184. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
  185. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
  186. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
  187. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
  188. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
  189. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
  190. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
  191. package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
  192. package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
  193. package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
  194. package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
  195. package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
  196. package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
  197. package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
  198. package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
  199. package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
  200. package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
  201. package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
  202. package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
  203. package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
  204. package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
  205. package/src/vendor/zoo-code/src/shared/language.ts +43 -0
  206. package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
  207. package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
  208. package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
  209. package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
  210. package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
  211. package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
  212. package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
  213. package/src/vendor/zoo-code/src/utils/object.ts +18 -0
  214. package/src/vendor/zoo-code/src/utils/path.ts +94 -0
  215. package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
  216. package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
  217. package/src/vendor/zoo-code/types/global-settings.ts +19 -0
  218. package/src/vendor/zoo-code/types/index.ts +22 -0
  219. package/src/vendor/zoo-code/types/message.ts +375 -0
  220. package/src/vendor/zoo-code/types/mode.ts +241 -0
  221. package/src/vendor/zoo-code/types/todo.ts +19 -0
  222. package/src/vendor/zoo-code/types/tool-params.ts +116 -0
  223. package/src/vendor/zoo-code/types/tool.ts +67 -0
  224. package/src/vendor/zoo-code/types/vscode.ts +84 -0
  225. package/src/vision/describe.ts +242 -0
  226. package/src/vision/tool.ts +91 -0
  227. package/src/watcher/cli.ts +369 -0
  228. package/src/watcher/github.ts +304 -0
  229. package/src/watcher/index.ts +59 -0
  230. package/src/watcher/state.ts +254 -0
  231. package/src/watcher/watch.ts +562 -0
  232. package/tsconfig.json +18 -0
@@ -0,0 +1,1015 @@
1
+ import * as vscode from "../../../shim/vscode"
2
+ import * as path from "path"
3
+ import * as fs from "fs/promises"
4
+ import * as os from "os"
5
+
6
+ import * as yaml from "yaml"
7
+ import stripBom from "../../../shim/strip-bom"
8
+
9
+ import { type ModeConfig, type PromptComponent, customModesSettingsSchema, modeConfigSchema } from "../../../types"
10
+
11
+ import { fileExistsAtPath } from "../../utils/fs"
12
+ import { getWorkspacePath } from "../../utils/path"
13
+ import { getGlobalRooDirectory } from "../../services/roo-config"
14
+ import { logger } from "../../utils/logging"
15
+ import { GlobalFileNames } from "../../shared/globalFileNames"
16
+ import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
17
+ import { t } from "../../i18n"
18
+
19
+ const ROOMODES_FILENAME = ".roomodes"
20
+
21
+ // Type definitions for import/export functionality
22
+ interface RuleFile {
23
+ relativePath: string
24
+ content: string
25
+ }
26
+
27
+ interface ExportedModeConfig extends ModeConfig {
28
+ rulesFiles?: RuleFile[]
29
+ }
30
+
31
+ interface ImportData {
32
+ customModes: ExportedModeConfig[]
33
+ }
34
+
35
+ interface ExportResult {
36
+ success: boolean
37
+ yaml?: string
38
+ error?: string
39
+ }
40
+
41
+ interface ImportResult {
42
+ success: boolean
43
+ slug?: string
44
+ error?: string
45
+ }
46
+
47
+ export class CustomModesManager {
48
+ private static readonly cacheTTL = 10_000
49
+
50
+ private disposables: vscode.Disposable[] = []
51
+ private isWriting = false
52
+ private writeQueue: Array<() => Promise<void>> = []
53
+ private cachedModes: ModeConfig[] | null = null
54
+ private cachedAt: number = 0
55
+
56
+ constructor(
57
+ private readonly context: vscode.ExtensionContext,
58
+ private readonly onUpdate: () => Promise<void>,
59
+ ) {
60
+ this.watchCustomModesFiles().catch((error) => {
61
+ console.error("[CustomModesManager] Failed to setup file watchers:", error)
62
+ })
63
+ }
64
+
65
+ private async queueWrite(operation: () => Promise<void>): Promise<void> {
66
+ this.writeQueue.push(operation)
67
+
68
+ if (!this.isWriting) {
69
+ await this.processWriteQueue()
70
+ }
71
+ }
72
+
73
+ private async processWriteQueue(): Promise<void> {
74
+ if (this.isWriting || this.writeQueue.length === 0) {
75
+ return
76
+ }
77
+
78
+ this.isWriting = true
79
+
80
+ try {
81
+ while (this.writeQueue.length > 0) {
82
+ const operation = this.writeQueue.shift()
83
+
84
+ if (operation) {
85
+ await operation()
86
+ }
87
+ }
88
+ } finally {
89
+ this.isWriting = false
90
+ }
91
+ }
92
+
93
+ private async getWorkspaceRoomodes(): Promise<string | undefined> {
94
+ const workspaceFolders = vscode.workspace.workspaceFolders
95
+
96
+ if (!workspaceFolders || workspaceFolders.length === 0) {
97
+ return undefined
98
+ }
99
+
100
+ const workspaceRoot = getWorkspacePath()
101
+ const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME)
102
+ const exists = await fileExistsAtPath(roomodesPath)
103
+ return exists ? roomodesPath : undefined
104
+ }
105
+
106
+ /**
107
+ * Regex pattern for problematic characters that need to be cleaned from YAML content
108
+ * Includes:
109
+ * - \u00A0: Non-breaking space
110
+ * - \u200B-\u200D: Zero-width spaces and joiners
111
+ * - \u2010-\u2015, \u2212: Various dash characters
112
+ * - \u2018-\u2019: Smart single quotes
113
+ * - \u201C-\u201D: Smart double quotes
114
+ */
115
+ private static readonly PROBLEMATIC_CHARS_REGEX =
116
+ // eslint-disable-next-line no-misleading-character-class
117
+ /[\u00A0\u200B\u200C\u200D\u2010\u2011\u2012\u2013\u2014\u2015\u2212\u2018\u2019\u201C\u201D]/g
118
+
119
+ /**
120
+ * Clean invisible and problematic characters from YAML content
121
+ */
122
+ private cleanInvisibleCharacters(content: string): string {
123
+ // Single pass replacement for all problematic characters
124
+ return content.replace(CustomModesManager.PROBLEMATIC_CHARS_REGEX, (match) => {
125
+ switch (match) {
126
+ case "\u00A0": // Non-breaking space
127
+ return " "
128
+ case "\u200B": // Zero-width space
129
+ case "\u200C": // Zero-width non-joiner
130
+ case "\u200D": // Zero-width joiner
131
+ return ""
132
+ case "\u2018": // Left single quotation mark
133
+ case "\u2019": // Right single quotation mark
134
+ return "'"
135
+ case "\u201C": // Left double quotation mark
136
+ case "\u201D": // Right double quotation mark
137
+ return '"'
138
+ default: // Dash characters (U+2010 through U+2015, U+2212)
139
+ return "-"
140
+ }
141
+ })
142
+ }
143
+
144
+ /**
145
+ * Parse YAML content with enhanced error handling and preprocessing
146
+ */
147
+ private parseYamlSafely(content: string, filePath: string): any {
148
+ // Clean the content
149
+ let cleanedContent = stripBom(content)
150
+ cleanedContent = this.cleanInvisibleCharacters(cleanedContent)
151
+
152
+ try {
153
+ const parsed = yaml.parse(cleanedContent)
154
+ // Ensure we never return null or undefined
155
+ return parsed ?? {}
156
+ } catch (yamlError) {
157
+ // For .roomodes files, try JSON as fallback
158
+ if (filePath.endsWith(ROOMODES_FILENAME)) {
159
+ try {
160
+ // Try parsing the original content as JSON (not the cleaned content)
161
+ return JSON.parse(content)
162
+ } catch (jsonError) {
163
+ // JSON also failed, show the original YAML error
164
+ const errorMsg = yamlError instanceof Error ? yamlError.message : String(yamlError)
165
+ console.error(`[CustomModesManager] Failed to parse YAML from ${filePath}:`, errorMsg)
166
+
167
+ const lineMatch = errorMsg.match(/at line (\d+)/)
168
+ const line = lineMatch ? lineMatch[1] : "unknown"
169
+ vscode.window.showErrorMessage(t("common:customModes.errors.yamlParseError", { line }))
170
+
171
+ // Return empty object to prevent duplicate error handling
172
+ return {}
173
+ }
174
+ }
175
+
176
+ // For non-.roomodes files, just log and return empty object
177
+ const errorMsg = yamlError instanceof Error ? yamlError.message : String(yamlError)
178
+ console.error(`[CustomModesManager] Failed to parse YAML from ${filePath}:`, errorMsg)
179
+ return {}
180
+ }
181
+ }
182
+
183
+ private async loadModesFromFile(filePath: string): Promise<ModeConfig[]> {
184
+ try {
185
+ const content = await fs.readFile(filePath, "utf-8")
186
+ const settings = this.parseYamlSafely(content, filePath)
187
+
188
+ // Ensure settings has customModes property
189
+ if (!settings || typeof settings !== "object" || !settings.customModes) {
190
+ return []
191
+ }
192
+
193
+ const result = customModesSettingsSchema.safeParse(settings)
194
+
195
+ if (!result.success) {
196
+ console.error(`[CustomModesManager] Schema validation failed for ${filePath}:`, result.error)
197
+
198
+ // Show user-friendly error for .roomodes files
199
+ if (filePath.endsWith(ROOMODES_FILENAME)) {
200
+ const issues = result.error.issues
201
+ .map((issue) => `• ${issue.path.join(".")}: ${issue.message}`)
202
+ .join("\n")
203
+
204
+ vscode.window.showErrorMessage(t("common:customModes.errors.schemaValidationError", { issues }))
205
+ }
206
+
207
+ return []
208
+ }
209
+
210
+ // Determine source based on file path
211
+ const isRoomodes = filePath.endsWith(ROOMODES_FILENAME)
212
+ const source = isRoomodes ? ("project" as const) : ("global" as const)
213
+
214
+ // Add source to each mode
215
+ return result.data.customModes.map((mode) => ({ ...mode, source }))
216
+ } catch (error) {
217
+ // Only log if the error wasn't already handled in parseYamlSafely
218
+ if (!(error as any).alreadyHandled) {
219
+ const errorMsg = `Failed to load modes from ${filePath}: ${error instanceof Error ? error.message : String(error)}`
220
+ console.error(`[CustomModesManager] ${errorMsg}`)
221
+ }
222
+ return []
223
+ }
224
+ }
225
+
226
+ private async mergeCustomModes(projectModes: ModeConfig[], globalModes: ModeConfig[]): Promise<ModeConfig[]> {
227
+ const slugs = new Set<string>()
228
+ const merged: ModeConfig[] = []
229
+
230
+ // Add project mode (takes precedence)
231
+ for (const mode of projectModes) {
232
+ if (!slugs.has(mode.slug)) {
233
+ slugs.add(mode.slug)
234
+ merged.push({ ...mode, source: "project" })
235
+ }
236
+ }
237
+
238
+ // Add non-duplicate global modes
239
+ for (const mode of globalModes) {
240
+ if (!slugs.has(mode.slug)) {
241
+ slugs.add(mode.slug)
242
+ merged.push({ ...mode, source: "global" })
243
+ }
244
+ }
245
+
246
+ return merged
247
+ }
248
+
249
+ public async getCustomModesFilePath(): Promise<string> {
250
+ const settingsDir = await ensureSettingsDirectoryExists(this.context)
251
+ const filePath = path.join(settingsDir, GlobalFileNames.customModes)
252
+ const fileExists = await fileExistsAtPath(filePath)
253
+
254
+ if (!fileExists) {
255
+ await this.queueWrite(() => fs.writeFile(filePath, yaml.stringify({ customModes: [] }, { lineWidth: 0 })))
256
+ }
257
+
258
+ return filePath
259
+ }
260
+
261
+ private async watchCustomModesFiles(): Promise<void> {
262
+ // Skip if test environment is detected
263
+ if (process.env.NODE_ENV === "test") {
264
+ return
265
+ }
266
+
267
+ const settingsPath = await this.getCustomModesFilePath()
268
+
269
+ // Watch settings file
270
+ const settingsWatcher = vscode.workspace.createFileSystemWatcher(settingsPath)
271
+
272
+ const handleSettingsChange = async () => {
273
+ try {
274
+ // Ensure that the settings file exists (especially important for delete events)
275
+ await this.getCustomModesFilePath()
276
+ const content = await fs.readFile(settingsPath, "utf-8")
277
+
278
+ const errorMessage = t("common:customModes.errors.invalidFormat")
279
+
280
+ let config: any
281
+
282
+ try {
283
+ config = this.parseYamlSafely(content, settingsPath)
284
+ } catch (error) {
285
+ console.error(error)
286
+ vscode.window.showErrorMessage(errorMessage)
287
+ return
288
+ }
289
+
290
+ const result = customModesSettingsSchema.safeParse(config)
291
+
292
+ if (!result.success) {
293
+ vscode.window.showErrorMessage(errorMessage)
294
+ return
295
+ }
296
+
297
+ // Get modes from .roomodes if it exists (takes precedence)
298
+ const roomodesPath = await this.getWorkspaceRoomodes()
299
+ const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
300
+
301
+ // Merge modes from both sources (.roomodes takes precedence)
302
+ const mergedModes = await this.mergeCustomModes(roomodesModes, result.data.customModes)
303
+ await this.context.globalState.update("customModes", mergedModes)
304
+ this.clearCache()
305
+ await this.onUpdate()
306
+ } catch (error) {
307
+ console.error(`[CustomModesManager] Error handling settings file change:`, error)
308
+ }
309
+ }
310
+
311
+ this.disposables.push(settingsWatcher.onDidChange(handleSettingsChange))
312
+ this.disposables.push(settingsWatcher.onDidCreate(handleSettingsChange))
313
+ this.disposables.push(settingsWatcher.onDidDelete(handleSettingsChange))
314
+ this.disposables.push(settingsWatcher)
315
+
316
+ // Watch .roomodes file - watch the path even if it doesn't exist yet
317
+ const workspaceFolders = vscode.workspace.workspaceFolders
318
+ if (workspaceFolders && workspaceFolders.length > 0) {
319
+ const workspaceRoot = getWorkspacePath()
320
+ const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME)
321
+ const roomodesWatcher = vscode.workspace.createFileSystemWatcher(roomodesPath)
322
+
323
+ const handleRoomodesChange = async () => {
324
+ try {
325
+ const settingsModes = await this.loadModesFromFile(settingsPath)
326
+ const roomodesModes = await this.loadModesFromFile(roomodesPath)
327
+ // .roomodes takes precedence
328
+ const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
329
+ await this.context.globalState.update("customModes", mergedModes)
330
+ this.clearCache()
331
+ await this.onUpdate()
332
+ } catch (error) {
333
+ console.error(`[CustomModesManager] Error handling .roomodes file change:`, error)
334
+ }
335
+ }
336
+
337
+ this.disposables.push(roomodesWatcher.onDidChange(handleRoomodesChange))
338
+ this.disposables.push(roomodesWatcher.onDidCreate(handleRoomodesChange))
339
+ this.disposables.push(
340
+ roomodesWatcher.onDidDelete(async () => {
341
+ // When .roomodes is deleted, refresh with only settings modes
342
+ try {
343
+ const settingsModes = await this.loadModesFromFile(settingsPath)
344
+ await this.context.globalState.update("customModes", settingsModes)
345
+ this.clearCache()
346
+ await this.onUpdate()
347
+ } catch (error) {
348
+ console.error(`[CustomModesManager] Error handling .roomodes file deletion:`, error)
349
+ }
350
+ }),
351
+ )
352
+ this.disposables.push(roomodesWatcher)
353
+ }
354
+ }
355
+
356
+ public async getCustomModes(): Promise<ModeConfig[]> {
357
+ // Check if we have a valid cached result.
358
+ const now = Date.now()
359
+
360
+ if (this.cachedModes && now - this.cachedAt < CustomModesManager.cacheTTL) {
361
+ return this.cachedModes
362
+ }
363
+
364
+ // Get modes from settings file.
365
+ const settingsPath = await this.getCustomModesFilePath()
366
+ const settingsModes = await this.loadModesFromFile(settingsPath)
367
+
368
+ // Get modes from .roomodes if it exists.
369
+ const roomodesPath = await this.getWorkspaceRoomodes()
370
+ const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
371
+
372
+ // Create maps to store modes by source.
373
+ const projectModes = new Map<string, ModeConfig>()
374
+ const globalModes = new Map<string, ModeConfig>()
375
+
376
+ // Add project modes (they take precedence).
377
+ for (const mode of roomodesModes) {
378
+ projectModes.set(mode.slug, { ...mode, source: "project" as const })
379
+ }
380
+
381
+ // Add global modes.
382
+ for (const mode of settingsModes) {
383
+ if (!projectModes.has(mode.slug)) {
384
+ globalModes.set(mode.slug, { ...mode, source: "global" as const })
385
+ }
386
+ }
387
+
388
+ // Combine modes in the correct order: project modes first, then global modes.
389
+ const mergedModes = [
390
+ ...roomodesModes.map((mode) => ({ ...mode, source: "project" as const })),
391
+ ...settingsModes
392
+ .filter((mode) => !projectModes.has(mode.slug))
393
+ .map((mode) => ({ ...mode, source: "global" as const })),
394
+ ]
395
+
396
+ await this.context.globalState.update("customModes", mergedModes)
397
+
398
+ this.cachedModes = mergedModes
399
+ this.cachedAt = now
400
+
401
+ return mergedModes
402
+ }
403
+
404
+ public async updateCustomMode(slug: string, config: ModeConfig): Promise<void> {
405
+ try {
406
+ // Validate the mode configuration before saving
407
+ const validationResult = modeConfigSchema.safeParse(config)
408
+ if (!validationResult.success) {
409
+ const errorMessages = validationResult.error.errors
410
+ .map((err) => `${err.path.join(".")}: ${err.message}`)
411
+ .join(", ")
412
+ const errorMessage = `Invalid mode configuration: ${errorMessages}`
413
+ logger.error("Mode validation failed", { slug, errors: validationResult.error.errors })
414
+ vscode.window.showErrorMessage(t("common:customModes.errors.updateFailed", { error: errorMessage }))
415
+ throw new Error(errorMessage)
416
+ }
417
+
418
+ const isProjectMode = config.source === "project"
419
+ let targetPath: string
420
+
421
+ if (isProjectMode) {
422
+ const workspaceFolders = vscode.workspace.workspaceFolders
423
+
424
+ if (!workspaceFolders || workspaceFolders.length === 0) {
425
+ logger.error("Failed to update project mode: No workspace folder found", { slug })
426
+ throw new Error(t("common:customModes.errors.noWorkspaceForProject"))
427
+ }
428
+
429
+ const workspaceRoot = getWorkspacePath()
430
+ targetPath = path.join(workspaceRoot, ROOMODES_FILENAME)
431
+ const exists = await fileExistsAtPath(targetPath)
432
+
433
+ logger.info(`${exists ? "Updating" : "Creating"} project mode in ${ROOMODES_FILENAME}`, {
434
+ slug,
435
+ workspace: workspaceRoot,
436
+ })
437
+ } else {
438
+ targetPath = await this.getCustomModesFilePath()
439
+ }
440
+
441
+ await this.queueWrite(async () => {
442
+ // Ensure source is set correctly based on target file.
443
+ const modeWithSource = {
444
+ ...config,
445
+ source: isProjectMode ? ("project" as const) : ("global" as const),
446
+ }
447
+
448
+ await this.updateModesInFile(targetPath, (modes) => {
449
+ const updatedModes = modes.filter((m) => m.slug !== slug)
450
+ updatedModes.push(modeWithSource)
451
+ return updatedModes
452
+ })
453
+
454
+ this.clearCache()
455
+ await this.refreshMergedState()
456
+ })
457
+ } catch (error) {
458
+ const errorMessage = error instanceof Error ? error.message : String(error)
459
+ logger.error("Failed to update custom mode", { slug, error: errorMessage })
460
+ vscode.window.showErrorMessage(t("common:customModes.errors.updateFailed", { error: errorMessage }))
461
+ throw error
462
+ }
463
+ }
464
+
465
+ private async updateModesInFile(filePath: string, operation: (modes: ModeConfig[]) => ModeConfig[]): Promise<void> {
466
+ let content = "{}"
467
+
468
+ try {
469
+ content = await fs.readFile(filePath, "utf-8")
470
+ } catch (error) {
471
+ // File might not exist yet.
472
+ content = yaml.stringify({ customModes: [] }, { lineWidth: 0 })
473
+ }
474
+
475
+ let settings
476
+
477
+ try {
478
+ settings = this.parseYamlSafely(content, filePath)
479
+ } catch (error) {
480
+ // Error already logged in parseYamlSafely
481
+ settings = { customModes: [] }
482
+ }
483
+
484
+ // Ensure settings is an object and has customModes property
485
+ if (!settings || typeof settings !== "object") {
486
+ settings = { customModes: [] }
487
+ }
488
+ if (!settings.customModes) {
489
+ settings.customModes = []
490
+ }
491
+
492
+ settings.customModes = operation(settings.customModes)
493
+ await fs.writeFile(filePath, yaml.stringify(settings, { lineWidth: 0 }), "utf-8")
494
+ }
495
+
496
+ private async refreshMergedState(): Promise<void> {
497
+ const settingsPath = await this.getCustomModesFilePath()
498
+ const roomodesPath = await this.getWorkspaceRoomodes()
499
+
500
+ const settingsModes = await this.loadModesFromFile(settingsPath)
501
+ const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
502
+ const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
503
+
504
+ await this.context.globalState.update("customModes", mergedModes)
505
+
506
+ this.clearCache()
507
+
508
+ await this.onUpdate()
509
+ }
510
+
511
+ public async deleteCustomMode(slug: string, fromMarketplace = false): Promise<void> {
512
+ try {
513
+ const settingsPath = await this.getCustomModesFilePath()
514
+ const roomodesPath = await this.getWorkspaceRoomodes()
515
+
516
+ const settingsModes = await this.loadModesFromFile(settingsPath)
517
+ const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
518
+
519
+ // Find the mode in either file
520
+ const projectMode = roomodesModes.find((m) => m.slug === slug)
521
+ const globalMode = settingsModes.find((m) => m.slug === slug)
522
+
523
+ if (!projectMode && !globalMode) {
524
+ throw new Error(t("common:customModes.errors.modeNotFound"))
525
+ }
526
+
527
+ // Determine which mode to use for rules folder path calculation
528
+ const modeToDelete = projectMode || globalMode
529
+
530
+ await this.queueWrite(async () => {
531
+ // Delete from project first if it exists there
532
+ if (projectMode && roomodesPath) {
533
+ await this.updateModesInFile(roomodesPath, (modes) => modes.filter((m) => m.slug !== slug))
534
+ }
535
+
536
+ // Delete from global settings if it exists there
537
+ if (globalMode) {
538
+ await this.updateModesInFile(settingsPath, (modes) => modes.filter((m) => m.slug !== slug))
539
+ }
540
+
541
+ // Delete associated rules folder
542
+ if (modeToDelete) {
543
+ await this.deleteRulesFolder(slug, modeToDelete, fromMarketplace)
544
+ }
545
+
546
+ // Clear cache when modes are deleted
547
+ this.clearCache()
548
+ await this.refreshMergedState()
549
+ })
550
+ } catch (error) {
551
+ const errorMessage = error instanceof Error ? error.message : String(error)
552
+ vscode.window.showErrorMessage(t("common:customModes.errors.deleteFailed", { error: errorMessage }))
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Deletes the rules folder for a specific mode
558
+ * @param slug - The mode slug
559
+ * @param mode - The mode configuration to determine the scope
560
+ */
561
+ private async deleteRulesFolder(slug: string, mode: ModeConfig, fromMarketplace = false): Promise<void> {
562
+ try {
563
+ // Determine the scope based on source (project or global)
564
+ const scope = mode.source || "global"
565
+
566
+ // Determine the rules folder path
567
+ let rulesFolderPath: string
568
+ if (scope === "project") {
569
+ const workspacePath = getWorkspacePath()
570
+ if (workspacePath) {
571
+ rulesFolderPath = path.join(workspacePath, ".roo", `rules-${slug}`)
572
+ } else {
573
+ return // No workspace, can't delete project rules
574
+ }
575
+ } else {
576
+ // Global scope - use OS home directory
577
+ const homeDir = os.homedir()
578
+ rulesFolderPath = path.join(homeDir, ".roo", `rules-${slug}`)
579
+ }
580
+
581
+ // Check if the rules folder exists and delete it
582
+ const rulesFolderExists = await fileExistsAtPath(rulesFolderPath)
583
+ if (rulesFolderExists) {
584
+ try {
585
+ await fs.rm(rulesFolderPath, { recursive: true, force: true })
586
+ logger.info(`Deleted rules folder for mode ${slug}: ${rulesFolderPath}`)
587
+ } catch (error) {
588
+ logger.error(`Failed to delete rules folder for mode ${slug}: ${error}`)
589
+ // Notify the user about the failure
590
+ const messageKey = fromMarketplace
591
+ ? "common:marketplace.mode.rulesCleanupFailed"
592
+ : "common:customModes.errors.rulesCleanupFailed"
593
+ vscode.window.showWarningMessage(t(messageKey, { rulesFolderPath }))
594
+ // Continue even if folder deletion fails
595
+ }
596
+ }
597
+ } catch (error) {
598
+ logger.error(`Error deleting rules folder for mode ${slug}`, {
599
+ error: error instanceof Error ? error.message : String(error),
600
+ })
601
+ }
602
+ }
603
+
604
+ public async resetCustomModes(): Promise<void> {
605
+ try {
606
+ const filePath = await this.getCustomModesFilePath()
607
+ await fs.writeFile(filePath, yaml.stringify({ customModes: [] }, { lineWidth: 0 }))
608
+ await this.context.globalState.update("customModes", [])
609
+ this.clearCache()
610
+ await this.onUpdate()
611
+ } catch (error) {
612
+ const errorMessage = error instanceof Error ? error.message : String(error)
613
+ vscode.window.showErrorMessage(t("common:customModes.errors.resetFailed", { error: errorMessage }))
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Checks if a mode has associated rules files in the .roo/rules-{slug}/ directory
619
+ * @param slug - The mode identifier to check
620
+ * @returns True if the mode has rules files with content, false otherwise
621
+ */
622
+ public async checkRulesDirectoryHasContent(slug: string): Promise<boolean> {
623
+ try {
624
+ // First, find the mode to determine its source
625
+ const allModes = await this.getCustomModes()
626
+ const mode = allModes.find((m) => m.slug === slug)
627
+
628
+ if (!mode) {
629
+ // If not in custom modes, check if it's in .roomodes (project-specific)
630
+ const workspacePath = getWorkspacePath()
631
+ if (!workspacePath) {
632
+ return false
633
+ }
634
+
635
+ const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
636
+ try {
637
+ const roomodesExists = await fileExistsAtPath(roomodesPath)
638
+ if (roomodesExists) {
639
+ const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
640
+ const roomodesData = yaml.parse(roomodesContent)
641
+ const roomodesModes = roomodesData?.customModes || []
642
+
643
+ // Check if this specific mode exists in .roomodes
644
+ const modeInRoomodes = roomodesModes.find((m: any) => m.slug === slug)
645
+ if (!modeInRoomodes) {
646
+ return false // Mode not found anywhere
647
+ }
648
+ } else {
649
+ return false // No .roomodes file and not in custom modes
650
+ }
651
+ } catch (error) {
652
+ return false // Cannot read .roomodes and not in custom modes
653
+ }
654
+ }
655
+
656
+ // Determine the correct rules directory based on mode source
657
+ let modeRulesDir: string
658
+ const isGlobalMode = mode?.source === "global"
659
+
660
+ if (isGlobalMode) {
661
+ // For global modes, check in global .roo directory
662
+ const globalRooDir = getGlobalRooDirectory()
663
+ modeRulesDir = path.join(globalRooDir, `rules-${slug}`)
664
+ } else {
665
+ // For project modes, check in workspace .roo directory
666
+ const workspacePath = getWorkspacePath()
667
+ if (!workspacePath) {
668
+ return false
669
+ }
670
+ modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`)
671
+ }
672
+
673
+ try {
674
+ const stats = await fs.stat(modeRulesDir)
675
+ if (!stats.isDirectory()) {
676
+ return false
677
+ }
678
+ } catch (error) {
679
+ return false
680
+ }
681
+
682
+ // Check if directory has any content files
683
+ try {
684
+ const entries = await fs.readdir(modeRulesDir, { withFileTypes: true })
685
+
686
+ for (const entry of entries) {
687
+ if (entry.isFile()) {
688
+ // Use path.join with modeRulesDir and entry.name for compatibility
689
+ const filePath = path.join(modeRulesDir, entry.name)
690
+ const content = await fs.readFile(filePath, "utf-8")
691
+ if (content.trim()) {
692
+ return true // Found at least one file with content
693
+ }
694
+ }
695
+ }
696
+
697
+ return false // No files with content found
698
+ } catch (error) {
699
+ return false
700
+ }
701
+ } catch (error) {
702
+ logger.error("Failed to check rules directory for mode", {
703
+ slug,
704
+ error: error instanceof Error ? error.message : String(error),
705
+ })
706
+ return false
707
+ }
708
+ }
709
+
710
+ /**
711
+ * Exports a mode configuration with its associated rules files into a shareable YAML format
712
+ * @param slug - The mode identifier to export
713
+ * @param customPrompts - Optional custom prompts to merge into the export
714
+ * @returns Success status with YAML content or error message
715
+ */
716
+ public async exportModeWithRules(slug: string, customPrompts?: PromptComponent): Promise<ExportResult> {
717
+ try {
718
+ // Import modes from shared to check built-in modes
719
+ const { modes: builtInModes } = await import("../../shared/modes")
720
+
721
+ // Get all current modes
722
+ const allModes = await this.getCustomModes()
723
+ let mode = allModes.find((m) => m.slug === slug)
724
+
725
+ // If mode not found in custom modes, check if it's a built-in mode that has been customized
726
+ if (!mode) {
727
+ // Only check workspace-based modes if workspace is available
728
+ const workspacePath = getWorkspacePath()
729
+ if (workspacePath) {
730
+ const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
731
+ try {
732
+ const roomodesExists = await fileExistsAtPath(roomodesPath)
733
+ if (roomodesExists) {
734
+ const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
735
+ const roomodesData = yaml.parse(roomodesContent)
736
+ const roomodesModes = roomodesData?.customModes || []
737
+
738
+ // Find the mode in .roomodes
739
+ mode = roomodesModes.find((m: any) => m.slug === slug)
740
+ }
741
+ } catch (error) {
742
+ // Continue to check built-in modes
743
+ }
744
+ }
745
+
746
+ // If still not found, check if it's a built-in mode
747
+ if (!mode) {
748
+ const builtInMode = builtInModes.find((m) => m.slug === slug)
749
+ if (builtInMode) {
750
+ // Use the built-in mode as the base
751
+ mode = { ...builtInMode }
752
+ } else {
753
+ return { success: false, error: "Mode not found" }
754
+ }
755
+ }
756
+ }
757
+
758
+ // Determine the base directory based on mode source
759
+ const isGlobalMode = mode.source === "global"
760
+ let baseDir: string
761
+ if (isGlobalMode) {
762
+ // For global modes, use the global .roo directory
763
+ baseDir = getGlobalRooDirectory()
764
+ } else {
765
+ // For project modes, use the workspace directory
766
+ const workspacePath = getWorkspacePath()
767
+ if (!workspacePath) {
768
+ return { success: false, error: "No workspace found" }
769
+ }
770
+ baseDir = workspacePath
771
+ }
772
+
773
+ // Check for .roo/rules-{slug}/ directory (or rules-{slug}/ for global)
774
+ const modeRulesDir = isGlobalMode
775
+ ? path.join(baseDir, `rules-${slug}`)
776
+ : path.join(baseDir, ".roo", `rules-${slug}`)
777
+
778
+ const rulesFiles: RuleFile[] = []
779
+ try {
780
+ const stats = await fs.stat(modeRulesDir)
781
+ if (stats.isDirectory()) {
782
+ // Extract content specific to this mode by looking for the mode-specific rules
783
+ const entries = await fs.readdir(modeRulesDir, { withFileTypes: true })
784
+
785
+ for (const entry of entries) {
786
+ if (entry.isFile()) {
787
+ // Use path.join with modeRulesDir and entry.name for compatibility
788
+ const filePath = path.join(modeRulesDir, entry.name)
789
+ const content = await fs.readFile(filePath, "utf-8")
790
+ if (content.trim()) {
791
+ // Calculate relative path from within the rules directory
792
+ // This excludes the rules-{slug} folder from the path
793
+ const relativePath = path.relative(modeRulesDir, filePath)
794
+ // Normalize path to use forward slashes for cross-platform compatibility
795
+ const normalizedRelativePath = relativePath.replace(/\\/g, "/")
796
+ rulesFiles.push({ relativePath: normalizedRelativePath, content: content.trim() })
797
+ }
798
+ }
799
+ }
800
+ }
801
+ } catch (error) {
802
+ // Directory doesn't exist, which is fine - mode might not have rules
803
+ }
804
+
805
+ // Create an export mode with rules files preserved
806
+ const exportMode: ExportedModeConfig = {
807
+ ...mode,
808
+ // Remove source property for export
809
+ source: "project" as const,
810
+ }
811
+
812
+ // Merge custom prompts if provided
813
+ if (customPrompts) {
814
+ if (customPrompts.roleDefinition) exportMode.roleDefinition = customPrompts.roleDefinition
815
+ if (customPrompts.description) exportMode.description = customPrompts.description
816
+ if (customPrompts.whenToUse) exportMode.whenToUse = customPrompts.whenToUse
817
+ if (customPrompts.customInstructions) exportMode.customInstructions = customPrompts.customInstructions
818
+ }
819
+
820
+ // Add rules files if any exist
821
+ if (rulesFiles.length > 0) {
822
+ exportMode.rulesFiles = rulesFiles
823
+ }
824
+
825
+ // Generate YAML
826
+ const exportData = {
827
+ customModes: [exportMode],
828
+ }
829
+
830
+ const yamlContent = yaml.stringify(exportData)
831
+
832
+ return { success: true, yaml: yamlContent }
833
+ } catch (error) {
834
+ const errorMessage = error instanceof Error ? error.message : String(error)
835
+ logger.error("Failed to export mode with rules", { slug, error: errorMessage })
836
+ return { success: false, error: errorMessage }
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Helper method to import rules files for a mode
842
+ * @param importMode - The mode being imported
843
+ * @param rulesFiles - The rules files to import
844
+ * @param source - The import source ("global" or "project")
845
+ */
846
+ private async importRulesFiles(
847
+ importMode: ExportedModeConfig,
848
+ rulesFiles: RuleFile[],
849
+ source: "global" | "project",
850
+ ): Promise<void> {
851
+ // Determine base directory and rules folder path based on source
852
+ let baseDir: string
853
+ let rulesFolderPath: string
854
+
855
+ if (source === "global") {
856
+ baseDir = getGlobalRooDirectory()
857
+ rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`)
858
+ } else {
859
+ const workspacePath = getWorkspacePath()
860
+ baseDir = path.join(workspacePath, ".roo")
861
+ rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`)
862
+ }
863
+
864
+ // Always remove the existing rules folder for this mode if it exists
865
+ // This ensures that if the imported mode has no rules, the folder is cleaned up
866
+ try {
867
+ await fs.rm(rulesFolderPath, { recursive: true, force: true })
868
+ logger.info(`Removed existing ${source} rules folder for mode ${importMode.slug}`)
869
+ } catch (error) {
870
+ // It's okay if the folder doesn't exist
871
+ logger.debug(`No existing ${source} rules folder to remove for mode ${importMode.slug}`)
872
+ }
873
+
874
+ // Only proceed with file creation if there are rules files to import
875
+ if (!rulesFiles || !Array.isArray(rulesFiles) || rulesFiles.length === 0) {
876
+ return
877
+ }
878
+
879
+ // Import the new rules files with path validation
880
+ for (const ruleFile of rulesFiles) {
881
+ if (ruleFile.relativePath && ruleFile.content) {
882
+ // Validate the relative path to prevent path traversal attacks
883
+ const normalizedRelativePath = path.normalize(ruleFile.relativePath)
884
+
885
+ // Ensure the path doesn't contain traversal sequences
886
+ if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) {
887
+ logger.error(`Invalid file path detected: ${ruleFile.relativePath}`)
888
+ continue // Skip this file but continue with others
889
+ }
890
+
891
+ // Check if path starts with a rules-* folder (old export format)
892
+ let cleanedRelativePath = normalizedRelativePath
893
+ const rulesMatch = normalizedRelativePath.match(/^rules-[^\/\\]+[\/\\]/)
894
+ if (rulesMatch) {
895
+ // Strip the entire rules-* folder reference for backwards compatibility
896
+ cleanedRelativePath = normalizedRelativePath.substring(rulesMatch[0].length)
897
+ logger.info(`Detected old export format, stripping ${rulesMatch[0]} from path`)
898
+ }
899
+
900
+ // Use the rules folder path instead of base directory
901
+ const targetPath = path.join(rulesFolderPath, cleanedRelativePath)
902
+ const normalizedTargetPath = path.normalize(targetPath)
903
+ const expectedBasePath = path.normalize(rulesFolderPath)
904
+
905
+ // Ensure the resolved path stays within the rules folder
906
+ if (!normalizedTargetPath.startsWith(expectedBasePath)) {
907
+ logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`)
908
+ continue // Skip this file but continue with others
909
+ }
910
+
911
+ // Ensure directory exists
912
+ const targetDir = path.dirname(targetPath)
913
+ await fs.mkdir(targetDir, { recursive: true })
914
+
915
+ // Write the file
916
+ await fs.writeFile(targetPath, ruleFile.content, "utf-8")
917
+ }
918
+ }
919
+ }
920
+
921
+ /**
922
+ * Imports modes from YAML content, including their associated rules files
923
+ * @param yamlContent - The YAML content containing mode configurations
924
+ * @param source - Target level for import: "global" (all projects) or "project" (current workspace only)
925
+ * @returns Success status with optional error message
926
+ */
927
+ public async importModeWithRules(
928
+ yamlContent: string,
929
+ source: "global" | "project" = "project",
930
+ ): Promise<ImportResult> {
931
+ try {
932
+ // Parse the YAML content with proper type validation
933
+ let importData: ImportData
934
+ try {
935
+ const parsed = yaml.parse(yamlContent)
936
+
937
+ // Validate the structure
938
+ if (!parsed?.customModes || !Array.isArray(parsed.customModes) || parsed.customModes.length === 0) {
939
+ return { success: false, error: "Invalid import format: Expected 'customModes' array in YAML" }
940
+ }
941
+
942
+ importData = parsed as ImportData
943
+ } catch (parseError) {
944
+ return {
945
+ success: false,
946
+ error: `Invalid YAML format: ${parseError instanceof Error ? parseError.message : "Failed to parse YAML"}`,
947
+ }
948
+ }
949
+
950
+ // Check workspace availability early if importing at project level
951
+ if (source === "project") {
952
+ const workspacePath = getWorkspacePath()
953
+ if (!workspacePath) {
954
+ return { success: false, error: "No workspace found" }
955
+ }
956
+ }
957
+
958
+ // Process each mode in the import
959
+ for (const importMode of importData.customModes) {
960
+ const { rulesFiles, ...modeConfig } = importMode
961
+
962
+ // Validate the mode configuration
963
+ const validationResult = modeConfigSchema.safeParse(modeConfig)
964
+ if (!validationResult.success) {
965
+ logger.error(`Invalid mode configuration for ${modeConfig.slug}`, {
966
+ errors: validationResult.error.errors,
967
+ })
968
+ return {
969
+ success: false,
970
+ error: `Invalid mode configuration for ${modeConfig.slug}: ${validationResult.error.errors.map((e) => e.message).join(", ")}`,
971
+ }
972
+ }
973
+
974
+ // Check for existing mode conflicts
975
+ const existingModes = await this.getCustomModes()
976
+ const existingMode = existingModes.find((m) => m.slug === importMode.slug)
977
+ if (existingMode) {
978
+ logger.info(`Overwriting existing mode: ${importMode.slug}`)
979
+ }
980
+
981
+ // Import the mode configuration with the specified source
982
+ await this.updateCustomMode(importMode.slug, {
983
+ ...modeConfig,
984
+ source: source, // Use the provided source parameter
985
+ })
986
+
987
+ // Import rules files (this also handles cleanup of existing rules folders)
988
+ await this.importRulesFiles(importMode, rulesFiles || [], source)
989
+ }
990
+
991
+ // Refresh the modes after import
992
+ await this.refreshMergedState()
993
+
994
+ // Return the imported mode's slug so the UI can activate it
995
+ return { success: true, slug: importData.customModes[0]?.slug }
996
+ } catch (error) {
997
+ const errorMessage = error instanceof Error ? error.message : String(error)
998
+ logger.error("Failed to import mode with rules", { error: errorMessage })
999
+ return { success: false, error: errorMessage }
1000
+ }
1001
+ }
1002
+
1003
+ private clearCache(): void {
1004
+ this.cachedModes = null
1005
+ this.cachedAt = 0
1006
+ }
1007
+
1008
+ dispose(): void {
1009
+ for (const disposable of this.disposables) {
1010
+ disposable.dispose()
1011
+ }
1012
+
1013
+ this.disposables = []
1014
+ }
1015
+ }