diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 3b80bba3b2..897cffb9e7 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -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 diff --git a/src/core/prompts/sections/__tests__/custom-instructions.test.ts b/src/core/prompts/sections/__tests__/custom-instructions.test.ts new file mode 100644 index 0000000000..c68cf4becf --- /dev/null +++ b/src/core/prompts/sections/__tests__/custom-instructions.test.ts @@ -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") + }) + }) +}) diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index 2d70e45419..c3dd7ae363 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -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 { 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 { + 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 { + 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 { 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 diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 3cc327c815..081520cd2f 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -62,6 +62,7 @@ async function generatePrompt( settings?: SystemPromptSettings, todoList?: TodoItem[], modelId?: string, + customInstructionPaths?: string[], ): Promise { 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 => { 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, ) } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 104cb87206..ed6a8a95ed 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2196,6 +2196,7 @@ export class Task extends EventEmitter implements TaskLike { customModes, customModePrompts, customInstructions, + customInstructionPaths, experiments, enableMcpServerCreation, browserToolEnabled, @@ -2239,6 +2240,7 @@ export class Task extends EventEmitter implements TaskLike { }, undefined, // todoList this.api.getModel().id, + customInstructionPaths, ) })() } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 18fa70035b..04b84285af 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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,