Skip to content

Commit 5147e28

Browse files
0xToshiiCline Evaluation
andauthored
workflows (RooCodeInc#3540)
* remove workflows subdirectory from cline local toggles * set workflow toggles * pre-updating the rules deletion logic * delete file logic * integration with task * words * pre-updating storage structure of workflows * workflow menu items, no regex * slash menu scrolling * menu buttons * placeholder * match command base * regex * nit * slash menu click outside * changeset * fixing linter warning * better UI/UX --------- Co-authored-by: Cline Evaluation <[email protected]>
1 parent c6e8b04 commit 5147e28

File tree

25 files changed

+820
-262
lines changed

25 files changed

+820
-262
lines changed
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+
new workflow feature

proto/file.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ message RuleFileRequest {
8585
bool is_global = 2; // Common field for all operations
8686
optional string rule_path = 3; // Path field for deleteRuleFile (optional)
8787
optional string filename = 4; // Filename field for createRuleFile (optional)
88+
optional string type = 5; // Type of the file to create (optional)
8889
}
8990

9091
// Result for rule file operations with meaningful data only

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

Lines changed: 5 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -8,45 +8,6 @@ import { getGlobalState, getWorkspaceState, updateGlobalState, updateWorkspaceSt
88
import * as vscode from "vscode"
99
import { synchronizeRuleToggles, getRuleFilesTotalContent } from "@core/context/instructions/user-instructions/rule-helpers"
1010

11-
/**
12-
* Converts .clinerules file to directory and places old .clinerule file inside directory, renaming it
13-
* Doesn't do anything if .clinerules dir already exists or doesn't exist
14-
* Returns whether there are any uncaught errors
15-
*/
16-
export async function ensureLocalClinerulesDirExists(cwd: string): Promise<boolean> {
17-
const clinerulePath = path.resolve(cwd, GlobalFileNames.clineRules)
18-
const defaultRuleFilename = "default-rules.md"
19-
20-
try {
21-
const exists = await fileExistsAtPath(clinerulePath)
22-
23-
if (exists && !(await isDirectory(clinerulePath))) {
24-
// logic to convert .clinerules file into directory, and rename the rules file to {defaultRuleFilename}
25-
const content = await fs.readFile(clinerulePath, "utf8")
26-
const tempPath = clinerulePath + ".bak"
27-
await fs.rename(clinerulePath, tempPath) // create backup
28-
try {
29-
await fs.mkdir(clinerulePath, { recursive: true })
30-
await fs.writeFile(path.join(clinerulePath, defaultRuleFilename), content, "utf8")
31-
await fs.unlink(tempPath).catch(() => {}) // delete backup
32-
33-
return false // conversion successful with no errors
34-
} catch (conversionError) {
35-
// attempt to restore backup on conversion failure
36-
try {
37-
await fs.rm(clinerulePath, { recursive: true, force: true }).catch(() => {})
38-
await fs.rename(tempPath, clinerulePath) // restore backup
39-
} catch (restoreError) {}
40-
return true // in either case here we consider this an error
41-
}
42-
}
43-
// exists and is a dir or doesn't exist, either of these cases we dont need to handle here
44-
return false
45-
} catch (error) {
46-
return true
47-
}
48-
}
49-
5011
export const getGlobalClineRules = async (globalClineRulesFilePath: string, toggles: ClineRulesToggles) => {
5112
if (await fileExistsAtPath(globalClineRulesFilePath)) {
5213
if (await isDirectory(globalClineRulesFilePath)) {
@@ -80,7 +41,8 @@ export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles
8041
if (await fileExistsAtPath(clineRulesFilePath)) {
8142
if (await isDirectory(clineRulesFilePath)) {
8243
try {
83-
const rulesFilePaths = await readDirectory(clineRulesFilePath)
44+
const rulesFilePaths = await readDirectory(clineRulesFilePath, [[".clinerules", "workflows"]])
45+
8446
const rulesFilesTotalContent = await getRuleFilesTotalContent(rulesFilePaths, cwd, toggles)
8547
if (rulesFilesTotalContent) {
8648
clineRulesFileInstructions = formatResponse.clineRulesLocalDirectoryInstructions(cwd, rulesFilesTotalContent)
@@ -121,90 +83,13 @@ export async function refreshClineRulesToggles(
12183
// Local toggles
12284
const localClineRulesToggles = ((await getWorkspaceState(context, "localClineRulesToggles")) as ClineRulesToggles) || {}
12385
const localClineRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.clineRules)
124-
const updatedLocalToggles = await synchronizeRuleToggles(localClineRulesFilePath, localClineRulesToggles)
86+
const updatedLocalToggles = await synchronizeRuleToggles(localClineRulesFilePath, localClineRulesToggles, "", [
87+
[".clinerules", "workflows"],
88+
])
12589
await updateWorkspaceState(context, "localClineRulesToggles", updatedLocalToggles)
12690

12791
return {
12892
globalToggles: updatedGlobalToggles,
12993
localToggles: updatedLocalToggles,
13094
}
13195
}
132-
133-
export const createRuleFile = async (isGlobal: boolean, filename: string, cwd: string) => {
134-
try {
135-
let filePath: string
136-
if (isGlobal) {
137-
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
138-
filePath = path.join(globalClineRulesFilePath, filename)
139-
} else {
140-
const localClineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
141-
142-
const hasError = await ensureLocalClinerulesDirExists(cwd)
143-
if (hasError === true) {
144-
return { filePath: null, fileExists: false }
145-
}
146-
147-
await fs.mkdir(localClineRulesFilePath, { recursive: true })
148-
149-
filePath = path.join(localClineRulesFilePath, filename)
150-
}
151-
152-
const fileExists = await fileExistsAtPath(filePath)
153-
154-
if (fileExists) {
155-
return { filePath, fileExists }
156-
}
157-
158-
await fs.writeFile(filePath, "", "utf8")
159-
160-
return { filePath, fileExists: false }
161-
} catch (error) {
162-
return { filePath: null, fileExists: false }
163-
}
164-
}
165-
166-
export async function deleteRuleFile(
167-
context: vscode.ExtensionContext,
168-
rulePath: string,
169-
isGlobal: boolean,
170-
): Promise<{ success: boolean; message: string }> {
171-
try {
172-
// Check if file exists
173-
const fileExists = await fileExistsAtPath(rulePath)
174-
if (!fileExists) {
175-
return {
176-
success: false,
177-
message: `Rule file does not exist: ${rulePath}`,
178-
}
179-
}
180-
181-
// Delete the file from disk
182-
await fs.unlink(rulePath)
183-
184-
// Get the filename for messages
185-
const fileName = path.basename(rulePath)
186-
187-
// Update the appropriate toggles
188-
if (isGlobal) {
189-
const toggles = ((await getGlobalState(context, "globalClineRulesToggles")) as ClineRulesToggles) || {}
190-
delete toggles[rulePath]
191-
await updateGlobalState(context, "globalClineRulesToggles", toggles)
192-
} else {
193-
const toggles = ((await getWorkspaceState(context, "localClineRulesToggles")) as ClineRulesToggles) || {}
194-
delete toggles[rulePath]
195-
await updateWorkspaceState(context, "localClineRulesToggles", toggles)
196-
}
197-
198-
return {
199-
success: true,
200-
message: `Rule file "${fileName}" deleted successfully`,
201-
}
202-
} catch (error) {
203-
const errorMessage = error instanceof Error ? error.message : String(error)
204-
console.error(`Error deleting rule file: ${errorMessage}`, error)
205-
return {
206-
success: false,
207-
message: `Failed to delete rule file.`,
208-
}
209-
}
210-
}

src/core/context/instructions/user-instructions/rule-helpers.ts

Lines changed: 163 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import { fileExistsAtPath, isDirectory, readDirectory } from "@utils/fs"
2+
import { ensureRulesDirectoryExists, GlobalFileNames } from "@core/storage/disk"
3+
import { getGlobalState, getWorkspaceState, updateGlobalState, updateWorkspaceState } from "@core/storage/state"
24
import * as path from "path"
35
import fs from "fs/promises"
46
import { ClineRulesToggles } from "@shared/cline-rules"
7+
import * as vscode from "vscode"
58

69
/**
710
* Recursively traverses directory and finds all files, including checking for optional whitelisted file extension
811
*/
9-
export async function readDirectoryRecursive(directoryPath: string, allowedFileExtension: string): Promise<string[]> {
12+
export async function readDirectoryRecursive(
13+
directoryPath: string,
14+
allowedFileExtension: string,
15+
excludedPaths: string[][] = [],
16+
): Promise<string[]> {
1017
try {
11-
const entries = await readDirectory(directoryPath)
18+
const entries = await readDirectory(directoryPath, excludedPaths)
1219
let results: string[] = []
1320
for (const entry of entries) {
1421
if (allowedFileExtension !== "") {
@@ -33,6 +40,7 @@ export async function synchronizeRuleToggles(
3340
rulesDirectoryPath: string,
3441
currentToggles: ClineRulesToggles,
3542
allowedFileExtension: string = "",
43+
excludedPaths: string[][] = [],
3644
): Promise<ClineRulesToggles> {
3745
// Create a copy of toggles to modify
3846
const updatedToggles = { ...currentToggles }
@@ -45,7 +53,7 @@ export async function synchronizeRuleToggles(
4553

4654
if (isDir) {
4755
// DIRECTORY CASE
48-
const filePaths = await readDirectoryRecursive(rulesDirectoryPath, allowedFileExtension)
56+
const filePaths = await readDirectoryRecursive(rulesDirectoryPath, allowedFileExtension, excludedPaths)
4957
const existingRulePaths = new Set<string>()
5058

5159
for (const filePath of filePaths) {
@@ -119,3 +127,155 @@ export const getRuleFilesTotalContent = async (rulesFilePaths: string[], basePat
119127
).then((contents) => contents.filter(Boolean).join("\n\n"))
120128
return ruleFilesTotalContent
121129
}
130+
131+
/**
132+
* Handles converting any directory into a file (specifically used for .clinerules and .clinerules/workflows)
133+
* The old .clinerules file or .clinerules/workflows file will be renamed to a default filename
134+
* Doesn't do anything if the dir already exists or doesn't exist
135+
* Returns whether there are any uncaught errors
136+
*/
137+
export async function ensureLocalClineDirExists(clinerulePath: string, defaultRuleFilename: string): Promise<boolean> {
138+
try {
139+
const exists = await fileExistsAtPath(clinerulePath)
140+
141+
if (exists && !(await isDirectory(clinerulePath))) {
142+
// logic to convert .clinerules file into directory, and rename the rules file to {defaultRuleFilename}
143+
const content = await fs.readFile(clinerulePath, "utf8")
144+
const tempPath = clinerulePath + ".bak"
145+
await fs.rename(clinerulePath, tempPath) // create backup
146+
try {
147+
await fs.mkdir(clinerulePath, { recursive: true })
148+
await fs.writeFile(path.join(clinerulePath, defaultRuleFilename), content, "utf8")
149+
await fs.unlink(tempPath).catch(() => {}) // delete backup
150+
151+
return false // conversion successful with no errors
152+
} catch (conversionError) {
153+
// attempt to restore backup on conversion failure
154+
try {
155+
await fs.rm(clinerulePath, { recursive: true, force: true }).catch(() => {})
156+
await fs.rename(tempPath, clinerulePath) // restore backup
157+
} catch (restoreError) {}
158+
return true // in either case here we consider this an error
159+
}
160+
}
161+
// exists and is a dir or doesn't exist, either of these cases we dont need to handle here
162+
return false
163+
} catch (error) {
164+
return true
165+
}
166+
}
167+
168+
/**
169+
* Create a rule file or workflow file
170+
*/
171+
export const createRuleFile = async (isGlobal: boolean, filename: string, cwd: string, type: string) => {
172+
try {
173+
let filePath: string
174+
if (isGlobal) {
175+
// global means its implicitly clinerules
176+
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
177+
filePath = path.join(globalClineRulesFilePath, filename)
178+
} else {
179+
const localClineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
180+
181+
const hasError = await ensureLocalClineDirExists(localClineRulesFilePath, "default-rules.md")
182+
if (hasError === true) {
183+
return { filePath: null, fileExists: false }
184+
}
185+
186+
await fs.mkdir(localClineRulesFilePath, { recursive: true })
187+
188+
if (type === "workflow") {
189+
const localWorkflowsFilePath = path.resolve(cwd, GlobalFileNames.workflows)
190+
191+
const hasError = await ensureLocalClineDirExists(localWorkflowsFilePath, "default-workflows.md")
192+
if (hasError === true) {
193+
return { filePath: null, fileExists: false }
194+
}
195+
196+
await fs.mkdir(localWorkflowsFilePath, { recursive: true })
197+
198+
filePath = path.join(localWorkflowsFilePath, filename)
199+
} else {
200+
// clinerules file creation
201+
filePath = path.join(localClineRulesFilePath, filename)
202+
}
203+
}
204+
205+
const fileExists = await fileExistsAtPath(filePath)
206+
207+
if (fileExists) {
208+
return { filePath, fileExists }
209+
}
210+
211+
await fs.writeFile(filePath, "", "utf8")
212+
213+
return { filePath, fileExists: false }
214+
} catch (error) {
215+
return { filePath: null, fileExists: false }
216+
}
217+
}
218+
219+
/**
220+
* Delete a rule file or workflow file
221+
*/
222+
export async function deleteRuleFile(
223+
context: vscode.ExtensionContext,
224+
rulePath: string,
225+
isGlobal: boolean,
226+
type: string,
227+
): Promise<{ success: boolean; message: string }> {
228+
try {
229+
// Check if file exists
230+
const fileExists = await fileExistsAtPath(rulePath)
231+
if (!fileExists) {
232+
return {
233+
success: false,
234+
message: `File does not exist: ${rulePath}`,
235+
}
236+
}
237+
238+
// Delete the file from disk
239+
await fs.unlink(rulePath)
240+
241+
// Get the filename for messages
242+
const fileName = path.basename(rulePath)
243+
244+
// Update the appropriate toggles
245+
if (isGlobal) {
246+
const toggles = ((await getGlobalState(context, "globalClineRulesToggles")) as ClineRulesToggles) || {}
247+
delete toggles[rulePath]
248+
await updateGlobalState(context, "globalClineRulesToggles", toggles)
249+
} else {
250+
if (type === "workflow") {
251+
const toggles = ((await getWorkspaceState(context, "workflowToggles")) as ClineRulesToggles) || {}
252+
delete toggles[rulePath]
253+
await updateWorkspaceState(context, "workflowToggles", toggles)
254+
} else if (type === "cursor") {
255+
const toggles = ((await getWorkspaceState(context, "localCursorRulesToggles")) as ClineRulesToggles) || {}
256+
delete toggles[rulePath]
257+
await updateWorkspaceState(context, "localCursorRulesToggles", toggles)
258+
} else if (type === "windsurf") {
259+
const toggles = ((await getWorkspaceState(context, "localWindsurfRulesToggles")) as ClineRulesToggles) || {}
260+
delete toggles[rulePath]
261+
await updateWorkspaceState(context, "localWindsurfRulesToggles", toggles)
262+
} else {
263+
const toggles = ((await getWorkspaceState(context, "localClineRulesToggles")) as ClineRulesToggles) || {}
264+
delete toggles[rulePath]
265+
await updateWorkspaceState(context, "localClineRulesToggles", toggles)
266+
}
267+
}
268+
269+
return {
270+
success: true,
271+
message: `File "${fileName}" deleted successfully`,
272+
}
273+
} catch (error) {
274+
const errorMessage = error instanceof Error ? error.message : String(error)
275+
console.error(`Error deleting file: ${errorMessage}`, error)
276+
return {
277+
success: false,
278+
message: `Failed to delete file.`,
279+
}
280+
}
281+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import path from "path"
2+
import { GlobalFileNames } from "@core/storage/disk"
3+
import { ClineRulesToggles } from "@shared/cline-rules"
4+
import { getWorkspaceState, updateWorkspaceState } from "@core/storage/state"
5+
import * as vscode from "vscode"
6+
import { synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
7+
8+
/**
9+
* Refresh the workflow toggles
10+
*/
11+
export async function refreshWorkflowToggles(
12+
context: vscode.ExtensionContext,
13+
workingDirectory: string,
14+
): Promise<ClineRulesToggles> {
15+
const workflowRulesToggles = ((await getWorkspaceState(context, "workflowToggles")) as ClineRulesToggles) || {}
16+
const workflowsDirPath = path.resolve(workingDirectory, GlobalFileNames.workflows)
17+
const updatedWorkflowToggles = await synchronizeRuleToggles(workflowsDirPath, workflowRulesToggles)
18+
await updateWorkspaceState(context, "workflowToggles", updatedWorkflowToggles)
19+
return updatedWorkflowToggles
20+
}

0 commit comments

Comments
 (0)