Skip to content

Commit 63b77a6

Browse files
committed
feat: Add support for custom instructions paths in custom mode config
- Implemented functionality in `custom-instruction.ts` to handle loading rules from custom instruction paths. - Refactored and fixed previously incorrect logic in `custom-instruction.ts` for better reliability and maintainability. (comment in #2354) - Enhanced `system.ts` to pass custom instruction paths to `addCustomInstructions`. - Extended `roo-code.d.ts` and `types.ts` to include `customInstructionsPaths` in `ModeConfig`. - Modified `index.ts` schema to define `customInstructionsPathsConfigSchema` and integrate it into `ModeConfig`. - Updated `modes.ts` to support `customInstructionsPaths` in mode configuration and full mode details.
1 parent 320ef77 commit 63b77a6

File tree

7 files changed

+103
-30
lines changed

7 files changed

+103
-30
lines changed

src/core/prompts/sections/__tests__/custom-instructions.test.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { loadRuleFiles, addCustomInstructions } from "../custom-instructions"
22
import fs from "fs/promises"
33
import path from "path"
44
import { PathLike } from "fs"
5+
import { parentPort } from "worker_threads"
56

67
// Mock fs/promises
78
jest.mock("fs/promises")
@@ -127,8 +128,8 @@ describe("loadRuleFiles", () => {
127128

128129
// Simulate listing files
129130
readdirMock.mockResolvedValueOnce([
130-
{ name: "file1.txt", isFile: () => true },
131-
{ name: "file2.txt", isFile: () => true },
131+
{ name: "file1.txt", isFile: () => true, parentPath: "/fake/path/.roo/rules" },
132+
{ name: "file2.txt", isFile: () => true, parentPath: "/fake/path/.roo/rules" },
132133
] as any)
133134

134135
statMock.mockImplementation(
@@ -154,8 +155,6 @@ describe("loadRuleFiles", () => {
154155
expect(result).toContain("# Rules from /fake/path/.roo/rules/file2.txt:")
155156
expect(result).toContain("content of file2")
156157

157-
expect(statMock).toHaveBeenCalledWith("/fake/path/.roo/rules/file1.txt")
158-
expect(statMock).toHaveBeenCalledWith("/fake/path/.roo/rules/file2.txt")
159158
expect(readFileMock).toHaveBeenCalledWith("/fake/path/.roo/rules/file1.txt", "utf-8")
160159
expect(readFileMock).toHaveBeenCalledWith("/fake/path/.roo/rules/file2.txt", "utf-8")
161160
})
@@ -321,8 +320,8 @@ describe("addCustomInstructions", () => {
321320

322321
// Simulate listing files
323322
readdirMock.mockResolvedValueOnce([
324-
{ name: "rule1.txt", isFile: () => true },
325-
{ name: "rule2.txt", isFile: () => true },
323+
{ name: "rule1.txt", isFile: () => true, parentPath: "/fake/path/.roo/rules-test-mode" },
324+
{ name: "rule2.txt", isFile: () => true, parentPath: "/fake/path/.roo/rules-test-mode" },
326325
] as any)
327326

328327
statMock.mockImplementation(
@@ -356,8 +355,6 @@ describe("addCustomInstructions", () => {
356355
expect(result).toContain("# Rules from /fake/path/.roo/rules-test-mode/rule2.txt:")
357356
expect(result).toContain("mode specific rule 2")
358357

359-
expect(statMock).toHaveBeenCalledWith("/fake/path/.roo/rules-test-mode/rule1.txt")
360-
expect(statMock).toHaveBeenCalledWith("/fake/path/.roo/rules-test-mode/rule2.txt")
361358
expect(readFileMock).toHaveBeenCalledWith("/fake/path/.roo/rules-test-mode/rule1.txt", "utf-8")
362359
expect(readFileMock).toHaveBeenCalledWith("/fake/path/.roo/rules-test-mode/rule2.txt", "utf-8")
363360
})
@@ -422,7 +419,9 @@ describe("addCustomInstructions", () => {
422419
)
423420

424421
// Simulate directory has files
425-
readdirMock.mockResolvedValueOnce([{ name: "rule1.txt", isFile: () => true }] as any)
422+
readdirMock.mockResolvedValueOnce([
423+
{ name: "rule1.txt", isFile: () => true, parentPath: "/fake/path/.roo/rules-test-mode" },
424+
] as any)
426425
readFileMock.mockReset()
427426

428427
// Set up stat mock for checking files
@@ -515,9 +514,9 @@ describe("Rules directory reading", () => {
515514

516515
// Simulate listing files
517516
readdirMock.mockResolvedValueOnce([
518-
{ name: "file1.txt", isFile: () => true },
519-
{ name: "file2.txt", isFile: () => true },
520-
{ name: "file3.txt", isFile: () => true },
517+
{ name: "file1.txt", isFile: () => true, parentPath: "/fake/path/.roo/rules" },
518+
{ name: "file2.txt", isFile: () => true, parentPath: "/fake/path/.roo/rules" },
519+
{ name: "file3.txt", isFile: () => true, parentPath: "/fake/path/.roo/rules" },
521520
] as any)
522521

523522
statMock.mockImplementation((path) => {

src/core/prompts/sections/custom-instructions.ts

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from "fs/promises"
22
import path from "path"
33

44
import { LANGUAGES, isLanguage } from "../../../shared/language"
5+
import { CustomInstructionsPathsConfig } from "../../../schemas"
56

67
/**
78
* Safely read a file and return its trimmed content
@@ -34,31 +35,26 @@ async function directoryExists(dirPath: string): Promise<boolean> {
3435
/**
3536
* Read all text files from a directory in alphabetical order
3637
*/
37-
async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ filename: string; content: string }>> {
38+
async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ fullPath: string; content: string }>> {
3839
try {
3940
const files = await fs
4041
.readdir(dirPath, { withFileTypes: true, recursive: true })
4142
.then((files) => files.filter((file) => file.isFile()))
42-
.then((files) => files.map((file) => path.resolve(dirPath, file.name)))
43+
.then((files) => files.map((file) => path.join(file.parentPath, file.name)))
4344

4445
const fileContents = await Promise.all(
4546
files.map(async (file) => {
4647
try {
47-
// Check if it's a file (not a directory)
48-
const stats = await fs.stat(file)
49-
if (stats.isFile()) {
50-
const content = await safeReadFile(file)
51-
return { filename: file, content }
52-
}
53-
return null
48+
const content = await safeReadFile(file)
49+
return { fullPath: file, content }
5450
} catch (err) {
5551
return null
5652
}
5753
}),
5854
)
5955

6056
// Filter out null values (directories or failed reads)
61-
return fileContents.filter((item): item is { filename: string; content: string } => item !== null)
57+
return fileContents.filter((item) => item !== null)
6258
} catch (err) {
6359
return []
6460
}
@@ -67,14 +63,14 @@ async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ file
6763
/**
6864
* Format content from multiple files with filenames as headers
6965
*/
70-
function formatDirectoryContent(dirPath: string, files: Array<{ filename: string; content: string }>): string {
66+
function formatDirectoryContent(dirPath: string, files: Array<{ fullPath: string; content: string }>): string {
7167
if (files.length === 0) return ""
7268

7369
return (
7470
"\n\n" +
7571
files
7672
.map((file) => {
77-
return `# Rules from ${file.filename}:\n${file.content}:`
73+
return `# Rules from ${file.fullPath}:\n${file.content}:`
7874
})
7975
.join("\n\n")
8076
)
@@ -106,12 +102,40 @@ export async function loadRuleFiles(cwd: string): Promise<string> {
106102
return ""
107103
}
108104

105+
/**
106+
* Load rules from a directory or file.
107+
* If the path is a directory, it will read all files in that directory recursively.
108+
* If the path is a file, it will read that file.
109+
* @param fullPath - The full path to the directory or file.
110+
* @returns An array of rules, each representing the content of a rule file.
111+
*/
112+
export async function loadRulesInPath(fullPath: string): Promise<string[]> {
113+
const rules: string[] = []
114+
const stats = await fs.lstat(fullPath)
115+
if (stats.isDirectory()) {
116+
const ruleFiles = await readTextFilesFromDirectory(fullPath)
117+
if (ruleFiles.length > 0) {
118+
rules.push(...ruleFiles.map((ruleFile) => `# Rules from ${ruleFile.fullPath}:\n${ruleFile.content}`))
119+
}
120+
} else {
121+
const content = await safeReadFile(fullPath)
122+
if (content) {
123+
rules.push(`# Rules from ${fullPath}:\n${content}`)
124+
}
125+
}
126+
return rules
127+
}
128+
109129
export async function addCustomInstructions(
110130
modeCustomInstructions: string,
111131
globalCustomInstructions: string,
112132
cwd: string,
113133
mode: string,
114-
options: { language?: string; rooIgnoreInstructions?: string } = {},
134+
options: {
135+
language?: string
136+
rooIgnoreInstructions?: string
137+
customInstructionsPaths?: CustomInstructionsPathsConfig[]
138+
} = {},
115139
): Promise<string> {
116140
const sections = []
117141

@@ -176,14 +200,32 @@ export async function addCustomInstructions(
176200
}
177201
}
178202

203+
// Load custom instructions from paths if provided
204+
if (options.customInstructionsPaths) {
205+
const customInstructionsFullPaths = options.customInstructionsPaths.map((customPath) => {
206+
return typeof customPath === "string"
207+
? path.isAbsolute(customPath)
208+
? customPath
209+
: path.join(cwd, customPath)
210+
: customPath.isAbsolute || path.isAbsolute(customPath.path)
211+
? customPath.path
212+
: path.join(cwd, customPath.path)
213+
})
214+
// TODO: Consider to use glob pattern
215+
for (const customInstructionsFullPath of customInstructionsFullPaths) {
216+
const loadedRules = await loadRulesInPath(customInstructionsFullPath)
217+
rules.push(...loadedRules)
218+
}
219+
}
220+
179221
if (options.rooIgnoreInstructions) {
180222
rules.push(options.rooIgnoreInstructions)
181223
}
182224

183225
// Add generic rules
184226
const genericRuleContent = await loadRuleFiles(cwd)
185-
if (genericRuleContent && genericRuleContent.trim()) {
186-
rules.push(genericRuleContent.trim())
227+
if (genericRuleContent) {
228+
rules.push(genericRuleContent)
187229
}
188230

189231
if (rules.length > 0) {

src/core/prompts/system.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ ${getSystemInfoSection(cwd, mode, customModeConfigs)}
9191
9292
${getObjectiveSection()}
9393
94-
${await addCustomInstructions(promptComponent?.customInstructions || modeConfig.customInstructions || "", globalCustomInstructions || "", cwd, mode, { language: language ?? formatLanguage(vscode.env.language), rooIgnoreInstructions })}`
94+
${await addCustomInstructions(promptComponent?.customInstructions || modeConfig.customInstructions || "", globalCustomInstructions || "", cwd, mode, { language: language ?? formatLanguage(vscode.env.language), rooIgnoreInstructions, customInstructionsPaths: modeConfig.customInstructionsPaths })}`
9595

9696
return basePrompt
9797
}
@@ -141,7 +141,11 @@ export const SYSTEM_PROMPT = async (
141141
globalCustomInstructions || "",
142142
cwd,
143143
mode,
144-
{ language: language ?? formatLanguage(vscode.env.language), rooIgnoreInstructions },
144+
{
145+
language: language ?? formatLanguage(vscode.env.language),
146+
rooIgnoreInstructions,
147+
customInstructionsPaths: currentMode.customInstructionsPaths,
148+
},
145149
)
146150
// For file-based prompts, don't include the tool sections
147151
return `${roleDefinition}

src/exports/roo-code.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,15 @@ type GlobalSettings = {
307307
name: string
308308
roleDefinition: string
309309
customInstructions?: string | undefined
310+
customInstructionsPaths?:
311+
| (
312+
| {
313+
path: string
314+
isAbsolute?: boolean | undefined
315+
}
316+
| string
317+
)[]
318+
| undefined
310319
groups: (
311320
| ("read" | "edit" | "browser" | "command" | "mcp" | "modes")
312321
| [

src/exports/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,15 @@ type GlobalSettings = {
310310
name: string
311311
roleDefinition: string
312312
customInstructions?: string | undefined
313+
customInstructionsPaths?:
314+
| (
315+
| {
316+
path: string
317+
isAbsolute?: boolean | undefined
318+
}
319+
| string
320+
)[]
321+
| undefined
313322
groups: (
314323
| ("read" | "edit" | "browser" | "command" | "mcp" | "modes")
315324
| [

src/schemas/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,21 @@ const groupEntryArraySchema = z.array(groupEntrySchema).refine(
210210
{ message: "Duplicate groups are not allowed" },
211211
)
212212

213+
export const customInstructionsPathsConfigSchema = z.union([
214+
z.object({
215+
path: z.string(),
216+
isAbsolute: z.boolean().optional(),
217+
}),
218+
z.string(),
219+
])
220+
export type CustomInstructionsPathsConfig = z.infer<typeof customInstructionsPathsConfigSchema>
221+
213222
export const modeConfigSchema = z.object({
214223
slug: z.string().regex(/^[a-zA-Z0-9-]+$/, "Slug must contain only letters numbers and dashes"),
215224
name: z.string().min(1, "Name is required"),
216225
roleDefinition: z.string().min(1, "Role definition is required"),
217226
customInstructions: z.string().optional(),
227+
customInstructionsPaths: z.array(customInstructionsPathsConfigSchema).optional(),
218228
groups: groupEntryArraySchema,
219229
source: z.enum(["global", "project"]).optional(),
220230
})

src/shared/modes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ export async function getFullModeDetails(
272272
options.globalCustomInstructions || "",
273273
options.cwd,
274274
modeSlug,
275-
{ language: options.language },
275+
{ language: options.language, customInstructionsPaths: baseMode.customInstructionsPaths },
276276
)
277277
}
278278

0 commit comments

Comments
 (0)