Skip to content

Commit 7c6fe52

Browse files
committed
Add support for project-specific .roomodes
1 parent 2b574fb commit 7c6fe52

File tree

6 files changed

+685
-212
lines changed

6 files changed

+685
-212
lines changed

src/core/config/CustomModesManager.ts

+198-45
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { ModeConfig } from "../../shared/modes"
66
import { fileExistsAtPath } from "../../utils/fs"
77
import { arePathsEqual } from "../../utils/path"
88

9+
const ROOMODES_FILENAME = ".roomodes"
10+
911
export class CustomModesManager {
1012
private disposables: vscode.Disposable[] = []
1113
private isWriting = false
@@ -15,7 +17,7 @@ export class CustomModesManager {
1517
private readonly context: vscode.ExtensionContext,
1618
private readonly onUpdate: () => Promise<void>,
1719
) {
18-
this.watchCustomModesFile()
20+
this.watchCustomModesFiles()
1921
}
2022

2123
private async queueWrite(operation: () => Promise<void>): Promise<void> {
@@ -43,6 +45,73 @@ export class CustomModesManager {
4345
}
4446
}
4547

48+
private async getWorkspaceRoomodes(): Promise<string | undefined> {
49+
const workspaceFolders = vscode.workspace.workspaceFolders
50+
if (!workspaceFolders || workspaceFolders.length === 0) {
51+
return undefined
52+
}
53+
const workspaceRoot = workspaceFolders[0].uri.fsPath
54+
const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME)
55+
const exists = await fileExistsAtPath(roomodesPath)
56+
return exists ? roomodesPath : undefined
57+
}
58+
59+
private async loadModesFromFile(filePath: string): Promise<ModeConfig[]> {
60+
try {
61+
const content = await fs.readFile(filePath, "utf-8")
62+
const settings = JSON.parse(content)
63+
const result = CustomModesSettingsSchema.safeParse(settings)
64+
if (!result.success) {
65+
const errorMsg = `Schema validation failed for ${filePath}`
66+
console.error(`[CustomModesManager] ${errorMsg}:`, result.error)
67+
return []
68+
}
69+
70+
// Determine source based on file path
71+
const isRoomodes = filePath.endsWith(ROOMODES_FILENAME)
72+
const source = isRoomodes ? ("project" as const) : ("global" as const)
73+
74+
// Add source to each mode
75+
return result.data.customModes.map((mode) => ({
76+
...mode,
77+
source,
78+
}))
79+
} catch (error) {
80+
const errorMsg = `Failed to load modes from ${filePath}: ${error instanceof Error ? error.message : String(error)}`
81+
console.error(`[CustomModesManager] ${errorMsg}`)
82+
return []
83+
}
84+
}
85+
86+
private async mergeCustomModes(projectModes: ModeConfig[], globalModes: ModeConfig[]): Promise<ModeConfig[]> {
87+
const slugs = new Set<string>()
88+
const merged: ModeConfig[] = []
89+
90+
// Add project mode (takes precedence)
91+
for (const mode of projectModes) {
92+
if (!slugs.has(mode.slug)) {
93+
slugs.add(mode.slug)
94+
merged.push({
95+
...mode,
96+
source: "project",
97+
})
98+
}
99+
}
100+
101+
// Add non-duplicate global modes
102+
for (const mode of globalModes) {
103+
if (!slugs.has(mode.slug)) {
104+
slugs.add(mode.slug)
105+
merged.push({
106+
...mode,
107+
source: "global",
108+
})
109+
}
110+
}
111+
112+
return merged
113+
}
114+
46115
async getCustomModesFilePath(): Promise<string> {
47116
const settingsDir = await this.ensureSettingsDirectoryExists()
48117
const filePath = path.join(settingsDir, "cline_custom_modes.json")
@@ -55,14 +124,17 @@ export class CustomModesManager {
55124
return filePath
56125
}
57126

58-
private async watchCustomModesFile(): Promise<void> {
127+
private async watchCustomModesFiles(): Promise<void> {
59128
const settingsPath = await this.getCustomModesFilePath()
129+
130+
// Watch settings file
60131
this.disposables.push(
61132
vscode.workspace.onDidSaveTextDocument(async (document) => {
62133
if (arePathsEqual(document.uri.fsPath, settingsPath)) {
63134
const content = await fs.readFile(settingsPath, "utf-8")
64135
const errorMessage =
65136
"Invalid custom modes format. Please ensure your settings follow the correct JSON format."
137+
66138
let config: any
67139
try {
68140
config = JSON.parse(content)
@@ -71,86 +143,170 @@ export class CustomModesManager {
71143
vscode.window.showErrorMessage(errorMessage)
72144
return
73145
}
146+
74147
const result = CustomModesSettingsSchema.safeParse(config)
75148
if (!result.success) {
76149
vscode.window.showErrorMessage(errorMessage)
77150
return
78151
}
79-
await this.context.globalState.update("customModes", result.data.customModes)
152+
153+
// Get modes from .roomodes if it exists (takes precedence)
154+
const roomodesPath = await this.getWorkspaceRoomodes()
155+
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
156+
157+
// Merge modes from both sources (.roomodes takes precedence)
158+
const mergedModes = await this.mergeCustomModes(roomodesModes, result.data.customModes)
159+
await this.context.globalState.update("customModes", mergedModes)
80160
await this.onUpdate()
81161
}
82162
}),
83163
)
164+
165+
// Watch .roomodes file if it exists
166+
const roomodesPath = await this.getWorkspaceRoomodes()
167+
if (roomodesPath) {
168+
this.disposables.push(
169+
vscode.workspace.onDidSaveTextDocument(async (document) => {
170+
if (arePathsEqual(document.uri.fsPath, roomodesPath)) {
171+
const settingsModes = await this.loadModesFromFile(settingsPath)
172+
const roomodesModes = await this.loadModesFromFile(roomodesPath)
173+
// .roomodes takes precedence
174+
const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
175+
await this.context.globalState.update("customModes", mergedModes)
176+
await this.onUpdate()
177+
}
178+
}),
179+
)
180+
}
84181
}
85182

86183
async getCustomModes(): Promise<ModeConfig[]> {
87-
const modes = await this.context.globalState.get<ModeConfig[]>("customModes")
184+
// Get modes from settings file
185+
const settingsPath = await this.getCustomModesFilePath()
186+
const settingsModes = await this.loadModesFromFile(settingsPath)
88187

89-
// Always read from file to ensure we have the latest
90-
try {
91-
const settingsPath = await this.getCustomModesFilePath()
92-
const content = await fs.readFile(settingsPath, "utf-8")
188+
// Get modes from .roomodes if it exists
189+
const roomodesPath = await this.getWorkspaceRoomodes()
190+
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
93191

94-
const settings = JSON.parse(content)
95-
const result = CustomModesSettingsSchema.safeParse(settings)
96-
if (result.success) {
97-
await this.context.globalState.update("customModes", result.data.customModes)
98-
return result.data.customModes
192+
// Create maps to store modes by source
193+
const projectModes = new Map<string, ModeConfig>()
194+
const globalModes = new Map<string, ModeConfig>()
195+
196+
// Add project modes (they take precedence)
197+
for (const mode of roomodesModes) {
198+
projectModes.set(mode.slug, { ...mode, source: "project" as const })
199+
}
200+
201+
// Add global modes
202+
for (const mode of settingsModes) {
203+
if (!projectModes.has(mode.slug)) {
204+
globalModes.set(mode.slug, { ...mode, source: "global" as const })
99205
}
100-
return modes ?? []
101-
} catch (error) {
102-
// Return empty array if there's an error reading the file
103206
}
104207

105-
return modes ?? []
208+
// Combine modes in the correct order: project modes first, then global modes
209+
const mergedModes = [
210+
...roomodesModes.map((mode) => ({ ...mode, source: "project" as const })),
211+
...settingsModes
212+
.filter((mode) => !projectModes.has(mode.slug))
213+
.map((mode) => ({ ...mode, source: "global" as const })),
214+
]
215+
216+
await this.context.globalState.update("customModes", mergedModes)
217+
return mergedModes
106218
}
107219

108220
async updateCustomMode(slug: string, config: ModeConfig): Promise<void> {
109221
try {
110-
const settingsPath = await this.getCustomModesFilePath()
222+
const isProjectMode = config.source === "project"
223+
const targetPath = isProjectMode ? await this.getWorkspaceRoomodes() : await this.getCustomModesFilePath()
111224

112-
await this.queueWrite(async () => {
113-
// Read and update file
114-
const content = await fs.readFile(settingsPath, "utf-8")
115-
const settings = JSON.parse(content)
116-
const currentModes = settings.customModes || []
117-
const updatedModes = currentModes.filter((m: ModeConfig) => m.slug !== slug)
118-
updatedModes.push(config)
119-
settings.customModes = updatedModes
120-
121-
const newContent = JSON.stringify(settings, null, 2)
225+
if (isProjectMode && !targetPath) {
226+
throw new Error("No workspace folder found for project-specific mode")
227+
}
122228

123-
// Write to file
124-
await fs.writeFile(settingsPath, newContent)
229+
await this.queueWrite(async () => {
230+
// Ensure source is set correctly based on target file
231+
const modeWithSource = {
232+
...config,
233+
source: isProjectMode ? ("project" as const) : ("global" as const),
234+
}
125235

126-
// Update global state
127-
await this.context.globalState.update("customModes", updatedModes)
236+
await this.updateModesInFile(targetPath!, (modes) => {
237+
const updatedModes = modes.filter((m) => m.slug !== slug)
238+
updatedModes.push(modeWithSource)
239+
return updatedModes
240+
})
128241

129-
// Notify about the update
130-
await this.onUpdate()
242+
await this.refreshMergedState()
131243
})
132-
133-
// Success, no need for message
134244
} catch (error) {
135245
vscode.window.showErrorMessage(
136246
`Failed to update custom mode: ${error instanceof Error ? error.message : String(error)}`,
137247
)
138248
}
139249
}
250+
private async updateModesInFile(filePath: string, operation: (modes: ModeConfig[]) => ModeConfig[]): Promise<void> {
251+
let content = "{}"
252+
try {
253+
content = await fs.readFile(filePath, "utf-8")
254+
} catch (error) {
255+
// File might not exist yet
256+
content = JSON.stringify({ customModes: [] })
257+
}
258+
259+
let settings
260+
try {
261+
settings = JSON.parse(content)
262+
} catch (error) {
263+
console.error(`[CustomModesManager] Failed to parse JSON from ${filePath}:`, error)
264+
settings = { customModes: [] }
265+
}
266+
settings.customModes = operation(settings.customModes || [])
267+
await fs.writeFile(filePath, JSON.stringify(settings, null, 2), "utf-8")
268+
}
269+
270+
private async refreshMergedState(): Promise<void> {
271+
const settingsPath = await this.getCustomModesFilePath()
272+
const roomodesPath = await this.getWorkspaceRoomodes()
273+
274+
const settingsModes = await this.loadModesFromFile(settingsPath)
275+
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
276+
const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
277+
278+
await this.context.globalState.update("customModes", mergedModes)
279+
await this.onUpdate()
280+
}
140281

141282
async deleteCustomMode(slug: string): Promise<void> {
142283
try {
143284
const settingsPath = await this.getCustomModesFilePath()
285+
const roomodesPath = await this.getWorkspaceRoomodes()
286+
287+
const settingsModes = await this.loadModesFromFile(settingsPath)
288+
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
289+
290+
// Find the mode in either file
291+
const projectMode = roomodesModes.find((m) => m.slug === slug)
292+
const globalMode = settingsModes.find((m) => m.slug === slug)
293+
294+
if (!projectMode && !globalMode) {
295+
throw new Error("Write error: Mode not found")
296+
}
144297

145298
await this.queueWrite(async () => {
146-
const content = await fs.readFile(settingsPath, "utf-8")
147-
const settings = JSON.parse(content)
299+
// Delete from project first if it exists there
300+
if (projectMode && roomodesPath) {
301+
await this.updateModesInFile(roomodesPath, (modes) => modes.filter((m) => m.slug !== slug))
302+
}
148303

149-
settings.customModes = (settings.customModes || []).filter((m: ModeConfig) => m.slug !== slug)
150-
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2))
304+
// Delete from global settings if it exists there
305+
if (globalMode) {
306+
await this.updateModesInFile(settingsPath, (modes) => modes.filter((m) => m.slug !== slug))
307+
}
151308

152-
await this.context.globalState.update("customModes", settings.customModes)
153-
await this.onUpdate()
309+
await this.refreshMergedState()
154310
})
155311
} catch (error) {
156312
vscode.window.showErrorMessage(
@@ -165,9 +321,6 @@ export class CustomModesManager {
165321
return settingsDir
166322
}
167323

168-
/**
169-
* Delete the custom modes file and reset to default state
170-
*/
171324
async resetCustomModes(): Promise<void> {
172325
try {
173326
const filePath = await this.getCustomModesFilePath()

0 commit comments

Comments
 (0)