Skip to content

Commit 26c8692

Browse files
committed
feat: Support custom instruction file paths
- Add customInstructionPaths field to global settings schema - Implement loadCustomInstructionFiles function to load from external paths - Add security validation to ensure files are within workspace or parent dirs - Support markdown (.md, .markdown) and text (.txt) files only - Add file size limit (1MB) to prevent loading huge files - Pass customInstructionPaths through ClineProvider state management - Update system prompt generation to include custom instruction paths - Add comprehensive tests for the new functionality - Add JSDoc documentation for the new feature Fixes #6168
1 parent 1e17b3b commit 26c8692

File tree

8 files changed

+464
-0
lines changed

8 files changed

+464
-0
lines changed

packages/types/src/global-settings.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ export const globalSettingsSchema = z.object({
4040

4141
lastShownAnnouncementId: z.string().optional(),
4242
customInstructions: z.string().optional(),
43+
/**
44+
* Array of file paths to load custom instructions from.
45+
* Supports relative paths, absolute paths, and parent directory paths (e.g., "../shared-instructions.md").
46+
* Files must be markdown (.md, .markdown) or text (.txt) files.
47+
* Example: [".github/copilot-instructions.md", "../shared/ai-instructions.md"]
48+
*/
49+
customInstructionPaths: z.array(z.string()).optional(),
4350
taskHistory: z.array(historyItemSchema).optional(),
4451

4552
condensingApiConfigId: z.string().optional(),

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

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,3 +1322,338 @@ describe("Rules directory reading", () => {
13221322
expect(result).toBe("\n# Rules from .roorules:\nfallback content\n")
13231323
})
13241324
})
1325+
1326+
describe("loadCustomInstructionFiles", () => {
1327+
beforeEach(() => {
1328+
vi.clearAllMocks()
1329+
})
1330+
1331+
it("should load custom instruction files from specified paths", async () => {
1332+
// Mock file existence and content
1333+
statMock.mockImplementation((path) => {
1334+
const normalizedPath = path.toString().replace(/\\/g, "/")
1335+
if (
1336+
normalizedPath.endsWith(".github/copilot-instructions.md") ||
1337+
normalizedPath.endsWith("docs/ai-instructions.md")
1338+
) {
1339+
return Promise.resolve({
1340+
isFile: vi.fn().mockReturnValue(true),
1341+
size: 1000, // Less than 1MB
1342+
} as any)
1343+
}
1344+
return Promise.reject({ code: "ENOENT" })
1345+
})
1346+
1347+
readFileMock.mockImplementation((filePath: PathLike) => {
1348+
const pathStr = filePath.toString()
1349+
const normalizedPath = pathStr.replace(/\\/g, "/")
1350+
if (normalizedPath.endsWith(".github/copilot-instructions.md")) {
1351+
return Promise.resolve("GitHub Copilot instructions content")
1352+
}
1353+
if (normalizedPath.endsWith("docs/ai-instructions.md")) {
1354+
return Promise.resolve("AI instructions content")
1355+
}
1356+
return Promise.reject({ code: "ENOENT" })
1357+
})
1358+
1359+
const result = await addCustomInstructions(
1360+
"mode instructions",
1361+
"global instructions",
1362+
"/fake/path",
1363+
"test-mode",
1364+
{ customInstructionPaths: [".github/copilot-instructions.md", "docs/ai-instructions.md"] },
1365+
)
1366+
1367+
expect(result).toContain("# Custom instructions from .github/copilot-instructions.md:")
1368+
expect(result).toContain("GitHub Copilot instructions content")
1369+
expect(result).toContain("# Custom instructions from docs/ai-instructions.md:")
1370+
expect(result).toContain("AI instructions content")
1371+
})
1372+
1373+
it("should skip files outside workspace", async () => {
1374+
// Mock no .roo/rules-test-mode directory
1375+
statMock.mockRejectedValueOnce({ code: "ENOENT" })
1376+
1377+
// Mock file existence for custom instruction paths
1378+
statMock.mockImplementation((path) => {
1379+
return Promise.resolve({
1380+
isFile: vi.fn().mockReturnValue(true),
1381+
size: 1000,
1382+
} as any)
1383+
})
1384+
1385+
// Mock file reads - only return content for files that shouldn't be loaded
1386+
readFileMock.mockImplementation((filePath: PathLike) => {
1387+
const pathStr = filePath.toString()
1388+
if (pathStr.includes("/etc/passwd") || pathStr.includes("/root/.ssh/config")) {
1389+
return Promise.resolve("Should not be loaded")
1390+
}
1391+
// Return empty/rejected for other files (like .roorules-test-mode)
1392+
return Promise.reject({ code: "ENOENT" })
1393+
})
1394+
1395+
// Mock console.warn to verify warning is logged
1396+
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
1397+
1398+
const result = await addCustomInstructions(
1399+
"mode instructions",
1400+
"global instructions",
1401+
"/fake/path",
1402+
"test-mode",
1403+
{ customInstructionPaths: ["/etc/passwd", "/root/.ssh/config"] },
1404+
)
1405+
1406+
expect(result).not.toContain("Should not be loaded")
1407+
expect(consoleWarnSpy).toHaveBeenCalledWith(
1408+
expect.stringContaining("Skipping custom instruction file outside workspace"),
1409+
)
1410+
1411+
consoleWarnSpy.mockRestore()
1412+
})
1413+
1414+
it("should allow files in parent directories for monorepo scenarios", async () => {
1415+
// Mock no .roo/rules-test-mode directory
1416+
statMock.mockRejectedValueOnce({ code: "ENOENT" })
1417+
1418+
// Mock file existence - need to handle the joined path
1419+
statMock.mockImplementation((filePath) => {
1420+
const pathStr = filePath.toString()
1421+
// Handle both Unix and Windows path separators
1422+
const normalizedPath = pathStr.replace(/\\/g, "/")
1423+
// The path.join will create /fake/path/monorepo-instructions.md
1424+
if (normalizedPath.endsWith("monorepo-instructions.md")) {
1425+
return Promise.resolve({
1426+
isFile: vi.fn().mockReturnValue(true),
1427+
size: 1000,
1428+
} as any)
1429+
}
1430+
return Promise.reject({ code: "ENOENT" })
1431+
})
1432+
1433+
readFileMock.mockImplementation((filePath: PathLike) => {
1434+
const pathStr = filePath.toString()
1435+
const normalizedPath = pathStr.replace(/\\/g, "/")
1436+
if (normalizedPath.endsWith("monorepo-instructions.md")) {
1437+
return Promise.resolve("Monorepo instructions content")
1438+
}
1439+
return Promise.reject({ code: "ENOENT" })
1440+
})
1441+
1442+
const result = await addCustomInstructions(
1443+
"mode instructions",
1444+
"global instructions",
1445+
"/fake/path/subproject",
1446+
"test-mode",
1447+
{ customInstructionPaths: ["../monorepo-instructions.md"] },
1448+
)
1449+
1450+
expect(result).toContain("# Custom instructions from ../monorepo-instructions.md:")
1451+
expect(result).toContain("Monorepo instructions content")
1452+
})
1453+
1454+
it("should skip non-existent files with warning", async () => {
1455+
// Mock file doesn't exist
1456+
statMock.mockRejectedValue({ code: "ENOENT" })
1457+
1458+
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
1459+
1460+
const result = await addCustomInstructions(
1461+
"mode instructions",
1462+
"global instructions",
1463+
"/fake/path",
1464+
"test-mode",
1465+
{ customInstructionPaths: ["non-existent.md"] },
1466+
)
1467+
1468+
expect(result).not.toContain("non-existent.md")
1469+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Custom instruction file not found"))
1470+
1471+
consoleWarnSpy.mockRestore()
1472+
})
1473+
1474+
it("should skip non-markdown/text files", async () => {
1475+
// Mock file existence
1476+
statMock.mockImplementation((path) => {
1477+
return Promise.resolve({
1478+
isFile: vi.fn().mockReturnValue(true),
1479+
size: 1000,
1480+
} as any)
1481+
})
1482+
1483+
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
1484+
1485+
const result = await addCustomInstructions(
1486+
"mode instructions",
1487+
"global instructions",
1488+
"/fake/path",
1489+
"test-mode",
1490+
{ customInstructionPaths: ["binary.exe", "image.png", "data.json"] },
1491+
)
1492+
1493+
expect(result).not.toContain("binary.exe")
1494+
expect(result).not.toContain("image.png")
1495+
expect(result).not.toContain("data.json")
1496+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Skipping non-markdown/text file"))
1497+
1498+
consoleWarnSpy.mockRestore()
1499+
})
1500+
1501+
it("should skip files larger than 1MB", async () => {
1502+
// Mock file existence with large size
1503+
statMock.mockImplementation((path) => {
1504+
return Promise.resolve({
1505+
isFile: vi.fn().mockReturnValue(true),
1506+
size: 2 * 1024 * 1024, // 2MB
1507+
} as any)
1508+
})
1509+
1510+
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
1511+
1512+
const result = await addCustomInstructions(
1513+
"mode instructions",
1514+
"global instructions",
1515+
"/fake/path",
1516+
"test-mode",
1517+
{ customInstructionPaths: ["large-file.md"] },
1518+
)
1519+
1520+
expect(result).not.toContain("large-file.md")
1521+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Skipping large file (>1MB)"))
1522+
1523+
consoleWarnSpy.mockRestore()
1524+
})
1525+
1526+
it("should handle invalid path patterns", async () => {
1527+
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
1528+
1529+
const result = await addCustomInstructions(
1530+
"mode instructions",
1531+
"global instructions",
1532+
"/fake/path",
1533+
"test-mode",
1534+
{ customInstructionPaths: [null as any, "", "path/../../../etc/passwd"] },
1535+
)
1536+
1537+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid custom instruction path"))
1538+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Potentially dangerous path pattern"))
1539+
1540+
consoleWarnSpy.mockRestore()
1541+
})
1542+
1543+
it("should handle errors gracefully", async () => {
1544+
// Mock no .roo/rules-test-mode directory
1545+
statMock.mockRejectedValueOnce({ code: "ENOENT" })
1546+
1547+
// Mock file existence
1548+
statMock.mockImplementation((path) => {
1549+
const normalizedPath = path.toString().replace(/\\/g, "/")
1550+
if (normalizedPath.endsWith("restricted.md")) {
1551+
return Promise.resolve({
1552+
isFile: vi.fn().mockReturnValue(true),
1553+
size: 1000,
1554+
} as any)
1555+
}
1556+
return Promise.reject({ code: "ENOENT" })
1557+
})
1558+
1559+
// Mock read error for specific file
1560+
readFileMock.mockImplementation((filePath: PathLike) => {
1561+
const pathStr = filePath.toString()
1562+
if (pathStr.includes("restricted.md")) {
1563+
return Promise.reject(new Error("Permission denied"))
1564+
}
1565+
return Promise.reject({ code: "ENOENT" })
1566+
})
1567+
1568+
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
1569+
1570+
const result = await addCustomInstructions(
1571+
"mode instructions",
1572+
"global instructions",
1573+
"/fake/path",
1574+
"test-mode",
1575+
{ customInstructionPaths: ["restricted.md"] },
1576+
)
1577+
1578+
expect(result).not.toContain("restricted.md")
1579+
expect(consoleWarnSpy).toHaveBeenCalledWith(
1580+
expect.stringContaining("Failed to load custom instruction file"),
1581+
expect.any(Error),
1582+
)
1583+
1584+
consoleWarnSpy.mockRestore()
1585+
})
1586+
1587+
it("should handle absolute paths correctly", async () => {
1588+
// Mock file existence
1589+
statMock.mockImplementation((path) => {
1590+
const normalizedPath = path.toString().replace(/\\/g, "/")
1591+
if (normalizedPath === "/fake/path/absolute-instructions.md") {
1592+
return Promise.resolve({
1593+
isFile: vi.fn().mockReturnValue(true),
1594+
size: 1000,
1595+
} as any)
1596+
}
1597+
return Promise.reject({ code: "ENOENT" })
1598+
})
1599+
1600+
readFileMock.mockImplementation((filePath: PathLike) => {
1601+
const pathStr = filePath.toString()
1602+
const normalizedPath = pathStr.replace(/\\/g, "/")
1603+
if (normalizedPath === "/fake/path/absolute-instructions.md") {
1604+
return Promise.resolve("Absolute path instructions")
1605+
}
1606+
return Promise.reject({ code: "ENOENT" })
1607+
})
1608+
1609+
const result = await addCustomInstructions(
1610+
"mode instructions",
1611+
"global instructions",
1612+
"/fake/path",
1613+
"test-mode",
1614+
{ customInstructionPaths: ["/fake/path/absolute-instructions.md"] },
1615+
)
1616+
1617+
expect(result).toContain("# Custom instructions from /fake/path/absolute-instructions.md:")
1618+
expect(result).toContain("Absolute path instructions")
1619+
})
1620+
1621+
it("should return empty string when no paths provided", async () => {
1622+
const result = await addCustomInstructions(
1623+
"mode instructions",
1624+
"global instructions",
1625+
"/fake/path",
1626+
"test-mode",
1627+
{ customInstructionPaths: [] },
1628+
)
1629+
1630+
expect(result).toContain("Mode-specific Instructions:")
1631+
expect(result).not.toContain("# Custom instructions from")
1632+
})
1633+
1634+
it("should validate that path is a file not a directory", async () => {
1635+
// Mock path exists but is a directory
1636+
statMock.mockImplementation((path) => {
1637+
return Promise.resolve({
1638+
isFile: vi.fn().mockReturnValue(false),
1639+
isDirectory: vi.fn().mockReturnValue(true),
1640+
size: 0,
1641+
} as any)
1642+
})
1643+
1644+
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
1645+
1646+
const result = await addCustomInstructions(
1647+
"mode instructions",
1648+
"global instructions",
1649+
"/fake/path",
1650+
"test-mode",
1651+
{ customInstructionPaths: ["some-directory"] },
1652+
)
1653+
1654+
expect(result).not.toContain("some-directory")
1655+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Path is not a file"))
1656+
1657+
consoleWarnSpy.mockRestore()
1658+
})
1659+
})

0 commit comments

Comments
 (0)