Skip to content

Commit e4d26be

Browse files
authored
enable cursorrules and windsurfrules (RooCodeInc#3245)
* base * task call * base 2 * changeset * wrap recursive dir
1 parent 1c7d33a commit e4d26be

File tree

8 files changed

+292
-87
lines changed

8 files changed

+292
-87
lines changed

.changeset/hip-eggs-fly.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"claude-dev": minor
3+
---
4+
5+
allow cursorrules and windsurfrules

src/core/context/instructions/user-instructions/cline-rules.ts

Lines changed: 3 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import fs from "fs/promises"
66
import { ClineRulesToggles } from "@shared/cline-rules"
77
import { getGlobalState, getWorkspaceState, updateGlobalState, updateWorkspaceState } from "@core/storage/state"
88
import * as vscode from "vscode"
9+
import { synchronizeRuleToggles, getRuleFilesTotalContent } from "@core/context/instructions/user-instructions/rule-helpers"
910

1011
/**
1112
* Converts .clinerules file to directory and places old .clinerule file inside directory, renaming it
@@ -51,11 +52,7 @@ export const getGlobalClineRules = async (globalClineRulesFilePath: string, togg
5152
if (await isDirectory(globalClineRulesFilePath)) {
5253
try {
5354
const rulesFilePaths = await readDirectory(globalClineRulesFilePath)
54-
const rulesFilesTotalContent = await getClineRulesFilesTotalContent(
55-
rulesFilePaths,
56-
globalClineRulesFilePath,
57-
toggles,
58-
)
55+
const rulesFilesTotalContent = await getRuleFilesTotalContent(rulesFilePaths, globalClineRulesFilePath, toggles)
5956
if (rulesFilesTotalContent) {
6057
const clineRulesFileInstructions = formatResponse.clineRulesGlobalDirectoryInstructions(
6158
globalClineRulesFilePath,
@@ -84,7 +81,7 @@ export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles
8481
if (await isDirectory(clineRulesFilePath)) {
8582
try {
8683
const rulesFilePaths = await readDirectory(clineRulesFilePath)
87-
const rulesFilesTotalContent = await getClineRulesFilesTotalContent(rulesFilePaths, cwd, toggles)
84+
const rulesFilesTotalContent = await getRuleFilesTotalContent(rulesFilePaths, cwd, toggles)
8885
if (rulesFilesTotalContent) {
8986
clineRulesFileInstructions = formatResponse.clineRulesLocalDirectoryInstructions(cwd, rulesFilesTotalContent)
9087
}
@@ -108,86 +105,6 @@ export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles
108105
return clineRulesFileInstructions
109106
}
110107

111-
const getClineRulesFilesTotalContent = async (rulesFilePaths: string[], basePath: string, toggles: ClineRulesToggles) => {
112-
const ruleFilesTotalContent = await Promise.all(
113-
rulesFilePaths.map(async (filePath) => {
114-
const ruleFilePath = path.resolve(basePath, filePath)
115-
const ruleFilePathRelative = path.relative(basePath, ruleFilePath)
116-
117-
if (ruleFilePath in toggles && toggles[ruleFilePath] === false) {
118-
return null
119-
}
120-
121-
return `${ruleFilePathRelative}\n` + (await fs.readFile(ruleFilePath, "utf8")).trim()
122-
}),
123-
).then((contents) => contents.filter(Boolean).join("\n\n"))
124-
return ruleFilesTotalContent
125-
}
126-
127-
export async function synchronizeRuleToggles(
128-
rulesDirectoryPath: string,
129-
currentToggles: ClineRulesToggles,
130-
): Promise<ClineRulesToggles> {
131-
// Create a copy of toggles to modify
132-
const updatedToggles = { ...currentToggles }
133-
134-
try {
135-
const pathExists = await fileExistsAtPath(rulesDirectoryPath)
136-
137-
if (pathExists) {
138-
const isDir = await isDirectory(rulesDirectoryPath)
139-
140-
if (isDir) {
141-
// DIRECTORY CASE
142-
const filePaths = await readDirectory(rulesDirectoryPath)
143-
const existingRulePaths = new Set<string>()
144-
145-
for (const filePath of filePaths) {
146-
const ruleFilePath = path.resolve(rulesDirectoryPath, filePath)
147-
existingRulePaths.add(ruleFilePath)
148-
149-
const pathHasToggle = ruleFilePath in updatedToggles
150-
if (!pathHasToggle) {
151-
updatedToggles[ruleFilePath] = true
152-
}
153-
}
154-
155-
// Clean up toggles for non-existent files
156-
for (const togglePath in updatedToggles) {
157-
const pathExists = existingRulePaths.has(togglePath)
158-
if (!pathExists) {
159-
delete updatedToggles[togglePath]
160-
}
161-
}
162-
} else {
163-
// FILE CASE
164-
// Add toggle for this file
165-
const pathHasToggle = rulesDirectoryPath in updatedToggles
166-
if (!pathHasToggle) {
167-
updatedToggles[rulesDirectoryPath] = true
168-
}
169-
170-
// Remove toggles for any other paths
171-
for (const togglePath in updatedToggles) {
172-
if (togglePath !== rulesDirectoryPath) {
173-
delete updatedToggles[togglePath]
174-
}
175-
}
176-
}
177-
} else {
178-
// PATH DOESN'T EXIST CASE
179-
// Clear all toggles since the path doesn't exist
180-
for (const togglePath in updatedToggles) {
181-
delete updatedToggles[togglePath]
182-
}
183-
}
184-
} catch (error) {
185-
console.error(`Failed to synchronize rule toggles for path: ${rulesDirectoryPath}`, error)
186-
}
187-
188-
return updatedToggles
189-
}
190-
191108
export async function refreshClineRulesToggles(
192109
context: vscode.ExtensionContext,
193110
workingDirectory: string,
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import path from "path"
2+
import fs from "fs/promises"
3+
import { GlobalFileNames } from "@core/storage/disk"
4+
import { fileExistsAtPath, isDirectory } from "@utils/fs"
5+
import { formatResponse } from "@core/prompts/responses"
6+
import { getWorkspaceState, updateWorkspaceState } from "@core/storage/state"
7+
import {
8+
synchronizeRuleToggles,
9+
combineRuleToggles,
10+
getRuleFilesTotalContent,
11+
readDirectoryRecursive,
12+
} from "@core/context/instructions/user-instructions/rule-helpers"
13+
import { ClineRulesToggles } from "@shared/cline-rules"
14+
import * as vscode from "vscode"
15+
16+
/**
17+
* Refreshes the toggles for windsurf and cursor rules
18+
*/
19+
export async function refreshExternalRulesToggles(
20+
context: vscode.ExtensionContext,
21+
workingDirectory: string,
22+
): Promise<{
23+
windsurfLocalToggles: ClineRulesToggles
24+
cursorLocalToggles: ClineRulesToggles
25+
}> {
26+
// local windsurf toggles
27+
const localWindsurfRulesToggles = ((await getWorkspaceState(context, "localWindsurfRulesToggles")) as ClineRulesToggles) || {}
28+
const localWindsurfRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.windsurfRules)
29+
const updatedLocalWindsurfToggles = await synchronizeRuleToggles(localWindsurfRulesFilePath, localWindsurfRulesToggles)
30+
await updateWorkspaceState(context, "localWindsurfRulesToggles", updatedLocalWindsurfToggles)
31+
32+
// local cursor toggles
33+
const localCursorRulesToggles = ((await getWorkspaceState(context, "localCursorRulesToggles")) as ClineRulesToggles) || {}
34+
35+
// cursor has two valid locations for rules files, so we need to check both and combine
36+
// synchronizeRuleToggles will drop whichever rules files are not in each given path, but combining the results will result in no data loss
37+
let localCursorRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.cursorRulesDir)
38+
const updatedLocalCursorToggles1 = await synchronizeRuleToggles(localCursorRulesFilePath, localCursorRulesToggles, ".mdc")
39+
40+
localCursorRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.cursorRulesFile)
41+
const updatedLocalCursorToggles2 = await synchronizeRuleToggles(localCursorRulesFilePath, localCursorRulesToggles)
42+
43+
const updatedLocalCursorToggles = combineRuleToggles(updatedLocalCursorToggles1, updatedLocalCursorToggles2)
44+
await updateWorkspaceState(context, "localCursorRulesToggles", updatedLocalCursorToggles)
45+
46+
return {
47+
windsurfLocalToggles: updatedLocalWindsurfToggles,
48+
cursorLocalToggles: updatedLocalCursorToggles,
49+
}
50+
}
51+
52+
/**
53+
* Gather formatted windsurf rules
54+
*/
55+
export const getLocalWindsurfRules = async (cwd: string, toggles: ClineRulesToggles) => {
56+
const windsurfRulesFilePath = path.resolve(cwd, GlobalFileNames.windsurfRules)
57+
58+
let windsurfRulesFileInstructions: string | undefined
59+
60+
if (await fileExistsAtPath(windsurfRulesFilePath)) {
61+
if (!(await isDirectory(windsurfRulesFilePath))) {
62+
try {
63+
if (windsurfRulesFilePath in toggles && toggles[windsurfRulesFilePath] !== false) {
64+
const ruleFileContent = (await fs.readFile(windsurfRulesFilePath, "utf8")).trim()
65+
if (ruleFileContent) {
66+
windsurfRulesFileInstructions = formatResponse.windsurfRulesLocalFileInstructions(cwd, ruleFileContent)
67+
}
68+
}
69+
} catch {
70+
console.error(`Failed to read .windsurfrules file at ${windsurfRulesFilePath}`)
71+
}
72+
}
73+
}
74+
75+
return windsurfRulesFileInstructions
76+
}
77+
78+
/**
79+
* Gather formatted cursor rules, which can come from two sources
80+
*/
81+
export const getLocalCursorRules = async (cwd: string, toggles: ClineRulesToggles) => {
82+
// we first check for the .cursorrules file
83+
const cursorRulesFilePath = path.resolve(cwd, GlobalFileNames.cursorRulesFile)
84+
let cursorRulesFileInstructions: string | undefined
85+
86+
if (await fileExistsAtPath(cursorRulesFilePath)) {
87+
if (!(await isDirectory(cursorRulesFilePath))) {
88+
try {
89+
if (cursorRulesFilePath in toggles && toggles[cursorRulesFilePath] !== false) {
90+
const ruleFileContent = (await fs.readFile(cursorRulesFilePath, "utf8")).trim()
91+
if (ruleFileContent) {
92+
cursorRulesFileInstructions = formatResponse.cursorRulesLocalFileInstructions(cwd, ruleFileContent)
93+
}
94+
}
95+
} catch {
96+
console.error(`Failed to read .cursorrules file at ${cursorRulesFilePath}`)
97+
}
98+
}
99+
}
100+
101+
// we then check for the .cursor/rules dir
102+
const cursorRulesDirPath = path.resolve(cwd, GlobalFileNames.cursorRulesDir)
103+
let cursorRulesDirInstructions: string | undefined
104+
105+
if (await fileExistsAtPath(cursorRulesDirPath)) {
106+
if (await isDirectory(cursorRulesDirPath)) {
107+
try {
108+
const rulesFilePaths = await readDirectoryRecursive(cursorRulesDirPath, ".mdc")
109+
const rulesFilesTotalContent = await getRuleFilesTotalContent(rulesFilePaths, cwd, toggles)
110+
if (rulesFilesTotalContent) {
111+
cursorRulesDirInstructions = formatResponse.cursorRulesLocalDirectoryInstructions(cwd, rulesFilesTotalContent)
112+
}
113+
} catch {
114+
console.error(`Failed to read .cursor/rules directory at ${cursorRulesDirPath}`)
115+
}
116+
}
117+
}
118+
119+
return [cursorRulesFileInstructions, cursorRulesDirInstructions]
120+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { fileExistsAtPath, isDirectory, readDirectory } from "@utils/fs"
2+
import * as path from "path"
3+
import fs from "fs/promises"
4+
import { ClineRulesToggles } from "@shared/cline-rules"
5+
6+
/**
7+
* Recursively traverses directory and finds all files, including checking for optional whitelisted file extension
8+
*/
9+
export async function readDirectoryRecursive(directoryPath: string, allowedFileExtension: string): Promise<string[]> {
10+
try {
11+
const entries = await readDirectory(directoryPath)
12+
let results: string[] = []
13+
for (const entry of entries) {
14+
if (allowedFileExtension !== "") {
15+
const fileExtension = path.extname(entry)
16+
if (fileExtension !== allowedFileExtension) {
17+
continue
18+
}
19+
}
20+
results.push(entry)
21+
}
22+
return results
23+
} catch (error) {
24+
console.error(`Error reading directory ${directoryPath}: ${error}`)
25+
return []
26+
}
27+
}
28+
29+
/**
30+
* Gets the up to date toggles
31+
*/
32+
export async function synchronizeRuleToggles(
33+
rulesDirectoryPath: string,
34+
currentToggles: ClineRulesToggles,
35+
allowedFileExtension: string = "",
36+
): Promise<ClineRulesToggles> {
37+
// Create a copy of toggles to modify
38+
const updatedToggles = { ...currentToggles }
39+
40+
try {
41+
const pathExists = await fileExistsAtPath(rulesDirectoryPath)
42+
43+
if (pathExists) {
44+
const isDir = await isDirectory(rulesDirectoryPath)
45+
46+
if (isDir) {
47+
// DIRECTORY CASE
48+
const filePaths = await readDirectoryRecursive(rulesDirectoryPath, allowedFileExtension)
49+
const existingRulePaths = new Set<string>()
50+
51+
for (const filePath of filePaths) {
52+
const ruleFilePath = path.resolve(rulesDirectoryPath, filePath)
53+
existingRulePaths.add(ruleFilePath)
54+
55+
const pathHasToggle = ruleFilePath in updatedToggles
56+
if (!pathHasToggle) {
57+
updatedToggles[ruleFilePath] = true
58+
}
59+
}
60+
61+
// Clean up toggles for non-existent files
62+
for (const togglePath in updatedToggles) {
63+
const pathExists = existingRulePaths.has(togglePath)
64+
if (!pathExists) {
65+
delete updatedToggles[togglePath]
66+
}
67+
}
68+
} else {
69+
// FILE CASE
70+
// Add toggle for this file
71+
const pathHasToggle = rulesDirectoryPath in updatedToggles
72+
if (!pathHasToggle) {
73+
updatedToggles[rulesDirectoryPath] = true
74+
}
75+
76+
// Remove toggles for any other paths
77+
for (const togglePath in updatedToggles) {
78+
if (togglePath !== rulesDirectoryPath) {
79+
delete updatedToggles[togglePath]
80+
}
81+
}
82+
}
83+
} else {
84+
// PATH DOESN'T EXIST CASE
85+
// Clear all toggles since the path doesn't exist
86+
for (const togglePath in updatedToggles) {
87+
delete updatedToggles[togglePath]
88+
}
89+
}
90+
} catch (error) {
91+
console.error(`Failed to synchronize rule toggles for path: ${rulesDirectoryPath}`, error)
92+
}
93+
94+
return updatedToggles
95+
}
96+
97+
/**
98+
* Certain project rules have more than a single location where rules are allowed to be stored
99+
*/
100+
export function combineRuleToggles(toggles1: ClineRulesToggles, toggles2: ClineRulesToggles): ClineRulesToggles {
101+
return { ...toggles1, ...toggles2 }
102+
}
103+
104+
/**
105+
* Read the content of rules files
106+
*/
107+
export const getRuleFilesTotalContent = async (rulesFilePaths: string[], basePath: string, toggles: ClineRulesToggles) => {
108+
const ruleFilesTotalContent = await Promise.all(
109+
rulesFilePaths.map(async (filePath) => {
110+
const ruleFilePath = path.resolve(basePath, filePath)
111+
const ruleFilePathRelative = path.relative(basePath, ruleFilePath)
112+
113+
if (ruleFilePath in toggles && toggles[ruleFilePath] === false) {
114+
return null
115+
}
116+
117+
return `${ruleFilePathRelative}\n` + (await fs.readFile(ruleFilePath, "utf8")).trim()
118+
}),
119+
).then((contents) => contents.filter(Boolean).join("\n\n"))
120+
return ruleFilesTotalContent
121+
}

src/core/prompts/responses.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,15 @@ Otherwise, if you have not completed the task and do not need additional informa
215215

216216
clineRulesLocalFileInstructions: (cwd: string, content: string) =>
217217
`# .clinerules\n\nThe following is provided by a root-level .clinerules file where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${content}`,
218+
219+
windsurfRulesLocalFileInstructions: (cwd: string, content: string) =>
220+
`# .windsurfrules\n\nThe following is provided by a root-level .windsurfrules file where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${content}`,
221+
222+
cursorRulesLocalFileInstructions: (cwd: string, content: string) =>
223+
`# .cursorrules\n\nThe following is provided by a root-level .cursorrules file where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${content}`,
224+
225+
cursorRulesLocalDirectoryInstructions: (cwd: string, content: string) =>
226+
`# .cursor/rules\n\nThe following is provided by a root-level .cursor/rules directory where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${content}`,
218227
}
219228

220229
// to avoid circular dependency

0 commit comments

Comments
 (0)