feat: add support for custom instruction file paths

- Add customInstructionPaths field to global settings schema
- Implement loadCustomInstructionFiles function with support for:
  - Single file paths
  - Directory paths (loads all .md and .txt files)
  - Glob patterns for flexible file selection
- Add security validation to prevent loading files outside workspace
- Support parent directory access for monorepo scenarios
- Integrate custom instructions into system prompt generation
- Pass customInstructionPaths through state management
- Add comprehensive unit tests for the new functionality

Fixes #6168
This commit is contained in:
Roo Code 2025-08-27 02:01:35 +00:00
parent ff1f4f0398
commit 04b7823751
6 changed files with 487 additions and 0 deletions

View file

@ -148,6 +148,12 @@ export const globalSettingsSchema = z.object({
hasOpenedModeSelector: z.boolean().optional(),
lastModeExportPath: z.string().optional(),
lastModeImportPath: z.string().optional(),
/**
* Custom instruction file paths, directories, or glob patterns.
* Supports single files, directories, glob patterns, and parent directory paths.
*/
customInstructionPaths: z.array(z.string()).optional(),
})
export type GlobalSettings = z.infer<typeof globalSettingsSchema>

View file

@ -0,0 +1,341 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import * as path from "path"
// Mock modules before importing the function to test
vi.mock("fs/promises", () => ({
default: {},
stat: vi.fn(),
readFile: vi.fn(),
readdir: vi.fn(),
}))
vi.mock("glob", () => ({
glob: vi.fn().mockResolvedValue([]),
}))
// Import after mocking
import { loadCustomInstructionFiles } from "../custom-instructions"
import * as fs from "fs/promises"
import { glob } from "glob"
describe("loadCustomInstructionFiles", () => {
const mockCwd = "/workspace/project"
beforeEach(() => {
vi.clearAllMocks()
// Reset console.warn mock
vi.spyOn(console, "warn").mockImplementation(() => {})
})
describe("basic functionality", () => {
it("should return empty string when customPaths is undefined", async () => {
const result = await loadCustomInstructionFiles(mockCwd, undefined)
expect(result).toBe("")
})
it("should return empty string when customPaths is empty array", async () => {
const result = await loadCustomInstructionFiles(mockCwd, [])
expect(result).toBe("")
})
})
describe("single file loading", () => {
it("should load content from a single file", async () => {
const filePath = ".github/copilot-instructions.md"
const resolvedPath = path.resolve(mockCwd, filePath)
const mockContent = "Custom Instructions"
// Setup mocks
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 100,
} as any)
vi.mocked(fs.readFile).mockResolvedValue(mockContent)
const result = await loadCustomInstructionFiles(mockCwd, [filePath])
expect(fs.stat).toHaveBeenCalledWith(resolvedPath)
expect(fs.readFile).toHaveBeenCalledWith(resolvedPath, "utf-8")
expect(result).toContain("Custom Instructions")
expect(result).toContain(`# Custom instructions from ${filePath}:`)
})
it("should skip files that exceed size limit", async () => {
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 2 * 1024 * 1024, // 2MB
} as any)
const result = await loadCustomInstructionFiles(mockCwd, ["large-file.md"])
expect(fs.readFile).not.toHaveBeenCalled()
expect(result).toBe("")
})
it("should skip files without allowed extensions", async () => {
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 100,
} as any)
const result = await loadCustomInstructionFiles(mockCwd, ["file.js"])
expect(fs.readFile).not.toHaveBeenCalled()
expect(result).toBe("")
})
})
describe("directory loading", () => {
it("should load all .md and .txt files from a directory", async () => {
const dirPath = ".roocode"
const resolvedPath = path.resolve(mockCwd, dirPath)
// Mock stat to identify as directory
vi.mocked(fs.stat)
.mockResolvedValueOnce({
isFile: () => false,
isDirectory: () => true,
} as any)
// For individual files
.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 100,
} as any)
// Mock readdir
vi.mocked(fs.readdir).mockResolvedValue([
{ name: "instructions.md", isFile: () => true, isDirectory: () => false },
{ name: "rules.txt", isFile: () => true, isDirectory: () => false },
{ name: "image.png", isFile: () => true, isDirectory: () => false },
] as any)
// Mock readFile
vi.mocked(fs.readFile).mockResolvedValueOnce("Instructions content").mockResolvedValueOnce("Rules content")
const result = await loadCustomInstructionFiles(mockCwd, [dirPath])
expect(fs.readdir).toHaveBeenCalledWith(resolvedPath, { withFileTypes: true })
expect(fs.readFile).toHaveBeenCalledTimes(2) // Only .md and .txt files
expect(result).toContain("Instructions content")
expect(result).toContain("Rules content")
})
})
describe("glob patterns", () => {
it("should expand glob patterns and load matching files", async () => {
const pattern = "docs/**/*.md"
const matches = [path.resolve(mockCwd, "docs/api.md"), path.resolve(mockCwd, "docs/guide.md")]
// Mock stat to fail first (triggering glob)
vi.mocked(fs.stat)
.mockRejectedValueOnce(new Error("Not found"))
.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 100,
} as any)
// Mock glob
vi.mocked(glob).mockResolvedValue(matches)
// Mock readFile
vi.mocked(fs.readFile).mockResolvedValueOnce("API docs").mockResolvedValueOnce("Guide docs")
const result = await loadCustomInstructionFiles(mockCwd, [pattern])
expect(glob).toHaveBeenCalledWith(pattern, {
cwd: mockCwd,
absolute: true,
nodir: true,
ignore: ["**/node_modules/**", "**/.git/**"],
})
expect(result).toContain("API docs")
expect(result).toContain("Guide docs")
})
})
describe("security validation", () => {
it("should reject paths outside workspace and parent", async () => {
const outsidePath = "/etc/passwd"
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 50,
} as any)
const result = await loadCustomInstructionFiles(mockCwd, [outsidePath])
expect(fs.readFile).not.toHaveBeenCalled()
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("Skipping instruction path outside allowed directories"),
)
expect(result).toBe("")
})
it("should allow parent directory access", async () => {
const parentPath = "../parent-instructions.md"
// This resolves to /workspace/parent-instructions.md which is in the parent dir
const resolvedPath = "/workspace/parent-instructions.md"
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 50,
} as any)
vi.mocked(fs.readFile).mockResolvedValue("Parent instructions")
const result = await loadCustomInstructionFiles(mockCwd, [parentPath])
expect(result).toContain("Parent instructions")
expect(result).toContain("# Custom instructions from ../parent-instructions.md:")
})
it("should allow workspace subdirectories", async () => {
const subPath = "src/rules/custom.md"
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 50,
} as any)
vi.mocked(fs.readFile).mockResolvedValue("Subdirectory content")
const result = await loadCustomInstructionFiles(mockCwd, [subPath])
expect(result).toContain("Subdirectory content")
expect(result).toContain("# Custom instructions from src/rules/custom.md:")
})
})
describe("error handling", () => {
it("should handle file read errors gracefully", async () => {
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 100,
} as any)
vi.mocked(fs.readFile).mockRejectedValue(new Error("Permission denied"))
const result = await loadCustomInstructionFiles(mockCwd, ["protected.md"])
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("Error reading instruction file"),
expect.any(Error),
)
expect(result).toBe("")
})
it("should handle glob errors gracefully", async () => {
vi.mocked(fs.stat).mockRejectedValue(new Error("Not found"))
vi.mocked(glob).mockRejectedValue(new Error("Invalid pattern"))
const result = await loadCustomInstructionFiles(mockCwd, ["[invalid"])
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("Error processing custom instruction path"),
expect.any(Error),
)
expect(result).toBe("")
})
it("should handle directory read errors gracefully", async () => {
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
} as any)
vi.mocked(fs.readdir).mockRejectedValue(new Error("Permission denied"))
const result = await loadCustomInstructionFiles(mockCwd, [".roocode"])
// Should fall through to glob attempt
expect(result).toBe("")
})
})
describe("content formatting", () => {
it("should format content with proper headers", async () => {
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 50,
} as any)
vi.mocked(fs.readFile).mockResolvedValue("Test content")
const result = await loadCustomInstructionFiles(mockCwd, ["test.md"])
expect(result).toContain("# Custom instructions from test.md:")
expect(result).toContain("Test content")
})
it("should combine multiple files with proper formatting", async () => {
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 50,
} as any)
vi.mocked(fs.readFile).mockResolvedValueOnce("Content 1").mockResolvedValueOnce("Content 2")
const result = await loadCustomInstructionFiles(mockCwd, ["file1.md", "file2.md"])
expect(result).toMatch(
/# Custom instructions from file1\.md:[\s\S]*Content 1[\s\S]*# Custom instructions from file2\.md:[\s\S]*Content 2/,
)
})
})
describe("mixed path types", () => {
it("should handle a mix of files, directories, and globs", async () => {
// Setup for single file
vi.mocked(fs.stat)
.mockResolvedValueOnce({
isFile: () => true,
isDirectory: () => false,
size: 50,
} as any)
// Setup for directory
.mockResolvedValueOnce({
isFile: () => false,
isDirectory: () => true,
} as any)
// Setup for glob (will fail stat, triggering glob)
.mockRejectedValueOnce(new Error("Not found"))
// For directory file and glob file
.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 50,
} as any)
// Setup directory listing
vi.mocked(fs.readdir).mockResolvedValue([
{ name: "dir-file.md", isFile: () => true, isDirectory: () => false },
] as any)
// Setup glob
vi.mocked(glob).mockResolvedValue([path.resolve(mockCwd, "docs/glob-file.md")])
// Setup file reads
vi.mocked(fs.readFile)
.mockResolvedValueOnce("Single file content")
.mockResolvedValueOnce("Directory file content")
.mockResolvedValueOnce("Glob file content")
const result = await loadCustomInstructionFiles(mockCwd, ["single.md", ".roocode", "docs/**/*.md"])
expect(result).toContain("Single file content")
expect(result).toContain("Directory file content")
expect(result).toContain("Glob file content")
})
})
})

View file

@ -2,6 +2,7 @@ import fs from "fs/promises"
import path from "path"
import * as os from "os"
import { Dirent } from "fs"
import { glob } from "glob"
import { isLanguage } from "@roo-code/types"
@ -261,6 +262,128 @@ async function loadAgentRulesFile(cwd: string): Promise<string> {
return ""
}
/**
* Load custom instruction files from specified paths, directories, or glob patterns
* @param cwd Current working directory for resolving relative paths
* @param customPaths Array of paths, directories, or glob patterns
* @returns Combined content from all loaded instruction files
*/
export async function loadCustomInstructionFiles(cwd: string, customPaths?: string[]): Promise<string> {
if (!customPaths || customPaths.length === 0) {
return ""
}
const loadedFiles: Array<{ path: string; content: string }> = []
const MAX_FILE_SIZE = 1024 * 1024 // 1MB limit per file
const ALLOWED_EXTENSIONS = [".md", ".markdown", ".txt"]
for (const pathPattern of customPaths) {
try {
// Resolve the path relative to the workspace
const resolvedPath = path.isAbsolute(pathPattern) ? pathPattern : path.resolve(cwd, pathPattern)
// Security validation: ensure path is within workspace or parent directories
const normalizedPath = path.normalize(resolvedPath)
const normalizedCwd = path.normalize(cwd)
const parentDir = path.dirname(normalizedCwd)
// Allow files within workspace or one level up (for monorepo scenarios)
if (!normalizedPath.startsWith(normalizedCwd) && !normalizedPath.startsWith(parentDir)) {
console.warn(`Skipping instruction path outside allowed directories: ${pathPattern}`)
continue
}
// Check if it's a directory
try {
const stats = await fs.stat(resolvedPath)
if (stats.isDirectory()) {
// Load all markdown and text files from the directory
const dirFiles = await fs.readdir(resolvedPath, { withFileTypes: true })
for (const file of dirFiles) {
if (file.isFile()) {
const ext = path.extname(file.name).toLowerCase()
if (ALLOWED_EXTENSIONS.includes(ext)) {
const filePath = path.join(resolvedPath, file.name)
const content = await loadSingleFile(filePath, MAX_FILE_SIZE)
if (content) {
loadedFiles.push({ path: filePath, content })
}
}
}
}
continue
} else if (stats.isFile()) {
// It's a single file
const ext = path.extname(resolvedPath).toLowerCase()
if (ALLOWED_EXTENSIONS.includes(ext)) {
const content = await loadSingleFile(resolvedPath, MAX_FILE_SIZE)
if (content) {
loadedFiles.push({ path: resolvedPath, content })
}
}
continue
}
} catch (err) {
// File/directory doesn't exist, might be a glob pattern
}
// Try as a glob pattern
const matches = await glob(pathPattern, {
cwd: cwd,
absolute: true,
nodir: true,
ignore: ["**/node_modules/**", "**/.git/**"],
})
for (const matchedFile of matches) {
const ext = path.extname(matchedFile).toLowerCase()
if (ALLOWED_EXTENSIONS.includes(ext)) {
// Apply same security validation
const normalizedMatch = path.normalize(matchedFile)
if (!normalizedMatch.startsWith(normalizedCwd) && !normalizedMatch.startsWith(parentDir)) {
console.warn(`Skipping matched file outside allowed directories: ${matchedFile}`)
continue
}
const content = await loadSingleFile(matchedFile, MAX_FILE_SIZE)
if (content) {
loadedFiles.push({ path: matchedFile, content })
}
}
}
} catch (error) {
console.warn(`Error processing custom instruction path "${pathPattern}":`, error)
}
}
// Combine all loaded content with file headers
if (loadedFiles.length === 0) {
return ""
}
return loadedFiles
.map((file) => `# Custom instructions from ${path.relative(cwd, file.path)}:\n${file.content}`)
.join("\n\n")
}
/**
* Load a single file with size validation
*/
async function loadSingleFile(filePath: string, maxSize: number): Promise<string | null> {
try {
const stats = await fs.stat(filePath)
if (stats.size > maxSize) {
console.warn(`Skipping large instruction file (>${maxSize} bytes): ${filePath}`)
return null
}
const content = await fs.readFile(filePath, "utf-8")
return content.trim()
} catch (error) {
console.warn(`Error reading instruction file "${filePath}":`, error)
return null
}
}
export async function addCustomInstructions(
modeCustomInstructions: string,
globalCustomInstructions: string,
@ -270,6 +393,7 @@ export async function addCustomInstructions(
language?: string
rooIgnoreInstructions?: string
settings?: SystemPromptSettings
customInstructionPaths?: string[]
} = {},
): Promise<string> {
const sections = []
@ -366,6 +490,14 @@ export async function addCustomInstructions(
sections.push(`Rules:\n\n${rules.join("\n\n")}`)
}
// Load custom instruction files from paths
if (options.customInstructionPaths && options.customInstructionPaths.length > 0) {
const customFileContent = await loadCustomInstructionFiles(cwd, options.customInstructionPaths)
if (customFileContent && customFileContent.trim()) {
sections.push(`Custom Instruction Files:\n\n${customFileContent.trim()}`)
}
}
const joinedSections = sections.join("\n\n")
return joinedSections

View file

@ -62,6 +62,7 @@ async function generatePrompt(
settings?: SystemPromptSettings,
todoList?: TodoItem[],
modelId?: string,
customInstructionPaths?: string[],
): Promise<string> {
if (!context) {
throw new Error("Extension context is required for generating system prompt")
@ -128,6 +129,7 @@ ${await addCustomInstructions(baseInstructions, globalCustomInstructions || "",
language: language ?? formatLanguage(vscode.env.language),
rooIgnoreInstructions,
settings,
customInstructionPaths,
})}`
return basePrompt
@ -153,6 +155,7 @@ export const SYSTEM_PROMPT = async (
settings?: SystemPromptSettings,
todoList?: TodoItem[],
modelId?: string,
customInstructionPaths?: string[],
): Promise<string> => {
if (!context) {
throw new Error("Extension context is required for generating system prompt")
@ -191,6 +194,7 @@ export const SYSTEM_PROMPT = async (
language: language ?? formatLanguage(vscode.env.language),
rooIgnoreInstructions,
settings,
customInstructionPaths,
},
)
@ -225,5 +229,6 @@ ${customInstructions}`
settings,
todoList,
modelId,
customInstructionPaths,
)
}

View file

@ -2196,6 +2196,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
customModes,
customModePrompts,
customInstructions,
customInstructionPaths,
experiments,
enableMcpServerCreation,
browserToolEnabled,
@ -2239,6 +2240,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
},
undefined, // todoList
this.api.getModel().id,
customInstructionPaths,
)
})()
}

View file

@ -1974,6 +1974,7 @@ export class ClineProvider
apiConfiguration: providerSettings,
lastShownAnnouncementId: stateValues.lastShownAnnouncementId,
customInstructions: stateValues.customInstructions,
customInstructionPaths: stateValues.customInstructionPaths,
apiModelId: stateValues.apiModelId,
alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false,
alwaysAllowReadOnlyOutsideWorkspace: stateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false,