Skip to content

Commit 04b7823

Browse files
committed
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
1 parent ff1f4f0 commit 04b7823

File tree

6 files changed

+487
-0
lines changed

6 files changed

+487
-0
lines changed

packages/types/src/global-settings.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@ export const globalSettingsSchema = z.object({
148148
hasOpenedModeSelector: z.boolean().optional(),
149149
lastModeExportPath: z.string().optional(),
150150
lastModeImportPath: z.string().optional(),
151+
152+
/**
153+
* Custom instruction file paths, directories, or glob patterns.
154+
* Supports single files, directories, glob patterns, and parent directory paths.
155+
*/
156+
customInstructionPaths: z.array(z.string()).optional(),
151157
})
152158

153159
export type GlobalSettings = z.infer<typeof globalSettingsSchema>
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import * as path from "path"
3+
4+
// Mock modules before importing the function to test
5+
vi.mock("fs/promises", () => ({
6+
default: {},
7+
stat: vi.fn(),
8+
readFile: vi.fn(),
9+
readdir: vi.fn(),
10+
}))
11+
12+
vi.mock("glob", () => ({
13+
glob: vi.fn().mockResolvedValue([]),
14+
}))
15+
16+
// Import after mocking
17+
import { loadCustomInstructionFiles } from "../custom-instructions"
18+
import * as fs from "fs/promises"
19+
import { glob } from "glob"
20+
21+
describe("loadCustomInstructionFiles", () => {
22+
const mockCwd = "/workspace/project"
23+
24+
beforeEach(() => {
25+
vi.clearAllMocks()
26+
// Reset console.warn mock
27+
vi.spyOn(console, "warn").mockImplementation(() => {})
28+
})
29+
30+
describe("basic functionality", () => {
31+
it("should return empty string when customPaths is undefined", async () => {
32+
const result = await loadCustomInstructionFiles(mockCwd, undefined)
33+
expect(result).toBe("")
34+
})
35+
36+
it("should return empty string when customPaths is empty array", async () => {
37+
const result = await loadCustomInstructionFiles(mockCwd, [])
38+
expect(result).toBe("")
39+
})
40+
})
41+
42+
describe("single file loading", () => {
43+
it("should load content from a single file", async () => {
44+
const filePath = ".github/copilot-instructions.md"
45+
const resolvedPath = path.resolve(mockCwd, filePath)
46+
const mockContent = "Custom Instructions"
47+
48+
// Setup mocks
49+
vi.mocked(fs.stat).mockResolvedValue({
50+
isFile: () => true,
51+
isDirectory: () => false,
52+
size: 100,
53+
} as any)
54+
55+
vi.mocked(fs.readFile).mockResolvedValue(mockContent)
56+
57+
const result = await loadCustomInstructionFiles(mockCwd, [filePath])
58+
59+
expect(fs.stat).toHaveBeenCalledWith(resolvedPath)
60+
expect(fs.readFile).toHaveBeenCalledWith(resolvedPath, "utf-8")
61+
expect(result).toContain("Custom Instructions")
62+
expect(result).toContain(`# Custom instructions from ${filePath}:`)
63+
})
64+
65+
it("should skip files that exceed size limit", async () => {
66+
vi.mocked(fs.stat).mockResolvedValue({
67+
isFile: () => true,
68+
isDirectory: () => false,
69+
size: 2 * 1024 * 1024, // 2MB
70+
} as any)
71+
72+
const result = await loadCustomInstructionFiles(mockCwd, ["large-file.md"])
73+
74+
expect(fs.readFile).not.toHaveBeenCalled()
75+
expect(result).toBe("")
76+
})
77+
78+
it("should skip files without allowed extensions", async () => {
79+
vi.mocked(fs.stat).mockResolvedValue({
80+
isFile: () => true,
81+
isDirectory: () => false,
82+
size: 100,
83+
} as any)
84+
85+
const result = await loadCustomInstructionFiles(mockCwd, ["file.js"])
86+
87+
expect(fs.readFile).not.toHaveBeenCalled()
88+
expect(result).toBe("")
89+
})
90+
})
91+
92+
describe("directory loading", () => {
93+
it("should load all .md and .txt files from a directory", async () => {
94+
const dirPath = ".roocode"
95+
const resolvedPath = path.resolve(mockCwd, dirPath)
96+
97+
// Mock stat to identify as directory
98+
vi.mocked(fs.stat)
99+
.mockResolvedValueOnce({
100+
isFile: () => false,
101+
isDirectory: () => true,
102+
} as any)
103+
// For individual files
104+
.mockResolvedValue({
105+
isFile: () => true,
106+
isDirectory: () => false,
107+
size: 100,
108+
} as any)
109+
110+
// Mock readdir
111+
vi.mocked(fs.readdir).mockResolvedValue([
112+
{ name: "instructions.md", isFile: () => true, isDirectory: () => false },
113+
{ name: "rules.txt", isFile: () => true, isDirectory: () => false },
114+
{ name: "image.png", isFile: () => true, isDirectory: () => false },
115+
] as any)
116+
117+
// Mock readFile
118+
vi.mocked(fs.readFile).mockResolvedValueOnce("Instructions content").mockResolvedValueOnce("Rules content")
119+
120+
const result = await loadCustomInstructionFiles(mockCwd, [dirPath])
121+
122+
expect(fs.readdir).toHaveBeenCalledWith(resolvedPath, { withFileTypes: true })
123+
expect(fs.readFile).toHaveBeenCalledTimes(2) // Only .md and .txt files
124+
expect(result).toContain("Instructions content")
125+
expect(result).toContain("Rules content")
126+
})
127+
})
128+
129+
describe("glob patterns", () => {
130+
it("should expand glob patterns and load matching files", async () => {
131+
const pattern = "docs/**/*.md"
132+
const matches = [path.resolve(mockCwd, "docs/api.md"), path.resolve(mockCwd, "docs/guide.md")]
133+
134+
// Mock stat to fail first (triggering glob)
135+
vi.mocked(fs.stat)
136+
.mockRejectedValueOnce(new Error("Not found"))
137+
.mockResolvedValue({
138+
isFile: () => true,
139+
isDirectory: () => false,
140+
size: 100,
141+
} as any)
142+
143+
// Mock glob
144+
vi.mocked(glob).mockResolvedValue(matches)
145+
146+
// Mock readFile
147+
vi.mocked(fs.readFile).mockResolvedValueOnce("API docs").mockResolvedValueOnce("Guide docs")
148+
149+
const result = await loadCustomInstructionFiles(mockCwd, [pattern])
150+
151+
expect(glob).toHaveBeenCalledWith(pattern, {
152+
cwd: mockCwd,
153+
absolute: true,
154+
nodir: true,
155+
ignore: ["**/node_modules/**", "**/.git/**"],
156+
})
157+
expect(result).toContain("API docs")
158+
expect(result).toContain("Guide docs")
159+
})
160+
})
161+
162+
describe("security validation", () => {
163+
it("should reject paths outside workspace and parent", async () => {
164+
const outsidePath = "/etc/passwd"
165+
166+
vi.mocked(fs.stat).mockResolvedValue({
167+
isFile: () => true,
168+
isDirectory: () => false,
169+
size: 50,
170+
} as any)
171+
172+
const result = await loadCustomInstructionFiles(mockCwd, [outsidePath])
173+
174+
expect(fs.readFile).not.toHaveBeenCalled()
175+
expect(console.warn).toHaveBeenCalledWith(
176+
expect.stringContaining("Skipping instruction path outside allowed directories"),
177+
)
178+
expect(result).toBe("")
179+
})
180+
181+
it("should allow parent directory access", async () => {
182+
const parentPath = "../parent-instructions.md"
183+
// This resolves to /workspace/parent-instructions.md which is in the parent dir
184+
const resolvedPath = "/workspace/parent-instructions.md"
185+
186+
vi.mocked(fs.stat).mockResolvedValue({
187+
isFile: () => true,
188+
isDirectory: () => false,
189+
size: 50,
190+
} as any)
191+
192+
vi.mocked(fs.readFile).mockResolvedValue("Parent instructions")
193+
194+
const result = await loadCustomInstructionFiles(mockCwd, [parentPath])
195+
196+
expect(result).toContain("Parent instructions")
197+
expect(result).toContain("# Custom instructions from ../parent-instructions.md:")
198+
})
199+
200+
it("should allow workspace subdirectories", async () => {
201+
const subPath = "src/rules/custom.md"
202+
203+
vi.mocked(fs.stat).mockResolvedValue({
204+
isFile: () => true,
205+
isDirectory: () => false,
206+
size: 50,
207+
} as any)
208+
209+
vi.mocked(fs.readFile).mockResolvedValue("Subdirectory content")
210+
211+
const result = await loadCustomInstructionFiles(mockCwd, [subPath])
212+
213+
expect(result).toContain("Subdirectory content")
214+
expect(result).toContain("# Custom instructions from src/rules/custom.md:")
215+
})
216+
})
217+
218+
describe("error handling", () => {
219+
it("should handle file read errors gracefully", async () => {
220+
vi.mocked(fs.stat).mockResolvedValue({
221+
isFile: () => true,
222+
isDirectory: () => false,
223+
size: 100,
224+
} as any)
225+
226+
vi.mocked(fs.readFile).mockRejectedValue(new Error("Permission denied"))
227+
228+
const result = await loadCustomInstructionFiles(mockCwd, ["protected.md"])
229+
230+
expect(console.warn).toHaveBeenCalledWith(
231+
expect.stringContaining("Error reading instruction file"),
232+
expect.any(Error),
233+
)
234+
expect(result).toBe("")
235+
})
236+
237+
it("should handle glob errors gracefully", async () => {
238+
vi.mocked(fs.stat).mockRejectedValue(new Error("Not found"))
239+
240+
vi.mocked(glob).mockRejectedValue(new Error("Invalid pattern"))
241+
242+
const result = await loadCustomInstructionFiles(mockCwd, ["[invalid"])
243+
244+
expect(console.warn).toHaveBeenCalledWith(
245+
expect.stringContaining("Error processing custom instruction path"),
246+
expect.any(Error),
247+
)
248+
expect(result).toBe("")
249+
})
250+
251+
it("should handle directory read errors gracefully", async () => {
252+
vi.mocked(fs.stat).mockResolvedValue({
253+
isFile: () => false,
254+
isDirectory: () => true,
255+
} as any)
256+
257+
vi.mocked(fs.readdir).mockRejectedValue(new Error("Permission denied"))
258+
259+
const result = await loadCustomInstructionFiles(mockCwd, [".roocode"])
260+
261+
// Should fall through to glob attempt
262+
expect(result).toBe("")
263+
})
264+
})
265+
266+
describe("content formatting", () => {
267+
it("should format content with proper headers", async () => {
268+
vi.mocked(fs.stat).mockResolvedValue({
269+
isFile: () => true,
270+
isDirectory: () => false,
271+
size: 50,
272+
} as any)
273+
vi.mocked(fs.readFile).mockResolvedValue("Test content")
274+
275+
const result = await loadCustomInstructionFiles(mockCwd, ["test.md"])
276+
277+
expect(result).toContain("# Custom instructions from test.md:")
278+
expect(result).toContain("Test content")
279+
})
280+
281+
it("should combine multiple files with proper formatting", async () => {
282+
vi.mocked(fs.stat).mockResolvedValue({
283+
isFile: () => true,
284+
isDirectory: () => false,
285+
size: 50,
286+
} as any)
287+
vi.mocked(fs.readFile).mockResolvedValueOnce("Content 1").mockResolvedValueOnce("Content 2")
288+
289+
const result = await loadCustomInstructionFiles(mockCwd, ["file1.md", "file2.md"])
290+
291+
expect(result).toMatch(
292+
/# Custom instructions from file1\.md:[\s\S]*Content 1[\s\S]*# Custom instructions from file2\.md:[\s\S]*Content 2/,
293+
)
294+
})
295+
})
296+
297+
describe("mixed path types", () => {
298+
it("should handle a mix of files, directories, and globs", async () => {
299+
// Setup for single file
300+
vi.mocked(fs.stat)
301+
.mockResolvedValueOnce({
302+
isFile: () => true,
303+
isDirectory: () => false,
304+
size: 50,
305+
} as any)
306+
// Setup for directory
307+
.mockResolvedValueOnce({
308+
isFile: () => false,
309+
isDirectory: () => true,
310+
} as any)
311+
// Setup for glob (will fail stat, triggering glob)
312+
.mockRejectedValueOnce(new Error("Not found"))
313+
// For directory file and glob file
314+
.mockResolvedValue({
315+
isFile: () => true,
316+
isDirectory: () => false,
317+
size: 50,
318+
} as any)
319+
320+
// Setup directory listing
321+
vi.mocked(fs.readdir).mockResolvedValue([
322+
{ name: "dir-file.md", isFile: () => true, isDirectory: () => false },
323+
] as any)
324+
325+
// Setup glob
326+
vi.mocked(glob).mockResolvedValue([path.resolve(mockCwd, "docs/glob-file.md")])
327+
328+
// Setup file reads
329+
vi.mocked(fs.readFile)
330+
.mockResolvedValueOnce("Single file content")
331+
.mockResolvedValueOnce("Directory file content")
332+
.mockResolvedValueOnce("Glob file content")
333+
334+
const result = await loadCustomInstructionFiles(mockCwd, ["single.md", ".roocode", "docs/**/*.md"])
335+
336+
expect(result).toContain("Single file content")
337+
expect(result).toContain("Directory file content")
338+
expect(result).toContain("Glob file content")
339+
})
340+
})
341+
})

0 commit comments

Comments
 (0)