Skip to content

Commit abdbb45

Browse files
committed
feat: add symlink support for AGENTS.md file loading
- Add safeReadFileFollowingSymlinks function to handle symlink resolution - Update loadAgentRulesFile to use the new symlink-aware function - Add comprehensive tests for both symlink and regular file scenarios - Ensures AGENTS.md can be a symlink pointing to actual rules file
1 parent 7b756e3 commit abdbb45

File tree

2 files changed

+164
-1
lines changed

2 files changed

+164
-1
lines changed

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

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,14 @@ const readFileMock = vi.fn()
5454
const statMock = vi.fn()
5555
const readdirMock = vi.fn()
5656
const readlinkMock = vi.fn()
57+
const lstatMock = vi.fn()
5758

5859
// Replace fs functions with our mocks
5960
fs.readFile = readFileMock as any
6061
fs.stat = statMock as any
6162
fs.readdir = readdirMock as any
6263
fs.readlink = readlinkMock as any
64+
fs.lstat = lstatMock as any
6365

6466
// Mock process.cwd
6567
const originalCwd = process.cwd
@@ -509,6 +511,17 @@ describe("addCustomInstructions", () => {
509511
// Simulate no .roo/rules-test-mode directory
510512
statMock.mockRejectedValueOnce({ code: "ENOENT" })
511513

514+
// Mock lstat to indicate AGENTS.md is NOT a symlink
515+
lstatMock.mockImplementation((filePath: PathLike) => {
516+
const pathStr = filePath.toString()
517+
if (pathStr.endsWith("AGENTS.md")) {
518+
return Promise.resolve({
519+
isSymbolicLink: vi.fn().mockReturnValue(false),
520+
})
521+
}
522+
return Promise.reject({ code: "ENOENT" })
523+
})
524+
512525
readFileMock.mockImplementation((filePath: PathLike) => {
513526
const pathStr = filePath.toString()
514527
if (pathStr.endsWith("AGENTS.md")) {
@@ -558,6 +571,17 @@ describe("addCustomInstructions", () => {
558571
// Simulate no .roo/rules-test-mode directory
559572
statMock.mockRejectedValueOnce({ code: "ENOENT" })
560573

574+
// Mock lstat to indicate AGENTS.md is NOT a symlink
575+
lstatMock.mockImplementation((filePath: PathLike) => {
576+
const pathStr = filePath.toString()
577+
if (pathStr.endsWith("AGENTS.md")) {
578+
return Promise.resolve({
579+
isSymbolicLink: vi.fn().mockReturnValue(false),
580+
})
581+
}
582+
return Promise.reject({ code: "ENOENT" })
583+
})
584+
561585
readFileMock.mockImplementation((filePath: PathLike) => {
562586
const pathStr = filePath.toString()
563587
if (pathStr.endsWith("AGENTS.md")) {
@@ -602,6 +626,17 @@ describe("addCustomInstructions", () => {
602626
// Simulate no .roo/rules-test-mode directory
603627
statMock.mockRejectedValueOnce({ code: "ENOENT" })
604628

629+
// Mock lstat to indicate AGENTS.md is NOT a symlink
630+
lstatMock.mockImplementation((filePath: PathLike) => {
631+
const pathStr = filePath.toString()
632+
if (pathStr.endsWith("AGENTS.md")) {
633+
return Promise.resolve({
634+
isSymbolicLink: vi.fn().mockReturnValue(false),
635+
})
636+
}
637+
return Promise.reject({ code: "ENOENT" })
638+
})
639+
605640
readFileMock.mockImplementation((filePath: PathLike) => {
606641
const pathStr = filePath.toString()
607642
if (pathStr.endsWith("AGENTS.md")) {
@@ -628,6 +663,106 @@ describe("addCustomInstructions", () => {
628663
expect(result).toContain("Roo rules content")
629664
})
630665

666+
it("should follow symlinks when loading AGENTS.md", async () => {
667+
// Simulate no .roo/rules-test-mode directory
668+
statMock.mockRejectedValueOnce({ code: "ENOENT" })
669+
670+
// Mock lstat to indicate AGENTS.md is a symlink
671+
lstatMock.mockImplementation((filePath: PathLike) => {
672+
const pathStr = filePath.toString()
673+
if (pathStr.endsWith("AGENTS.md")) {
674+
return Promise.resolve({
675+
isSymbolicLink: vi.fn().mockReturnValue(true),
676+
})
677+
}
678+
return Promise.reject({ code: "ENOENT" })
679+
})
680+
681+
// Mock readlink to return the symlink target
682+
readlinkMock.mockImplementation((filePath: PathLike) => {
683+
const pathStr = filePath.toString()
684+
if (pathStr.endsWith("AGENTS.md")) {
685+
return Promise.resolve("../actual-agents-file.md")
686+
}
687+
return Promise.reject({ code: "ENOENT" })
688+
})
689+
690+
// Mock readFile to return content from the resolved path
691+
readFileMock.mockImplementation((filePath: PathLike) => {
692+
const pathStr = filePath.toString()
693+
const normalizedPath = pathStr.replace(/\\/g, "/")
694+
if (normalizedPath.endsWith("actual-agents-file.md")) {
695+
return Promise.resolve("Agent rules from symlinked file")
696+
}
697+
return Promise.reject({ code: "ENOENT" })
698+
})
699+
700+
const result = await addCustomInstructions(
701+
"mode instructions",
702+
"global instructions",
703+
"/fake/path",
704+
"test-mode",
705+
{ settings: { maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true } },
706+
)
707+
708+
expect(result).toContain("# Agent Rules Standard (AGENTS.md):")
709+
expect(result).toContain("Agent rules from symlinked file")
710+
711+
// Verify lstat was called to check if it's a symlink
712+
expect(lstatMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))
713+
714+
// Verify readlink was called to resolve the symlink
715+
expect(readlinkMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))
716+
717+
// Verify the resolved path was read
718+
expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining("actual-agents-file.md"), "utf-8")
719+
})
720+
721+
it("should handle AGENTS.md as a regular file when not a symlink", async () => {
722+
// Simulate no .roo/rules-test-mode directory
723+
statMock.mockRejectedValueOnce({ code: "ENOENT" })
724+
725+
// Mock lstat to indicate AGENTS.md is NOT a symlink
726+
lstatMock.mockImplementation((filePath: PathLike) => {
727+
const pathStr = filePath.toString()
728+
if (pathStr.endsWith("AGENTS.md")) {
729+
return Promise.resolve({
730+
isSymbolicLink: vi.fn().mockReturnValue(false),
731+
})
732+
}
733+
return Promise.reject({ code: "ENOENT" })
734+
})
735+
736+
// Mock readFile to return content directly from AGENTS.md
737+
readFileMock.mockImplementation((filePath: PathLike) => {
738+
const pathStr = filePath.toString()
739+
if (pathStr.endsWith("AGENTS.md")) {
740+
return Promise.resolve("Agent rules from regular file")
741+
}
742+
return Promise.reject({ code: "ENOENT" })
743+
})
744+
745+
const result = await addCustomInstructions(
746+
"mode instructions",
747+
"global instructions",
748+
"/fake/path",
749+
"test-mode",
750+
{ settings: { maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true } },
751+
)
752+
753+
expect(result).toContain("# Agent Rules Standard (AGENTS.md):")
754+
expect(result).toContain("Agent rules from regular file")
755+
756+
// Verify lstat was called
757+
expect(lstatMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))
758+
759+
// Verify readlink was NOT called since it's not a symlink
760+
expect(readlinkMock).not.toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))
761+
762+
// Verify the file was read directly
763+
expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"), "utf-8")
764+
})
765+
631766
it("should return empty string when no instructions provided", async () => {
632767
// Simulate no .roo/rules directory
633768
statMock.mockRejectedValueOnce({ code: "ENOENT" })

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,34 @@ async function safeReadFile(filePath: string): Promise<string> {
2626
}
2727
}
2828

29+
/**
30+
* Safely read a file and follow symlinks if necessary
31+
*/
32+
async function safeReadFileFollowingSymlinks(filePath: string): Promise<string> {
33+
try {
34+
// Check if the path is a symlink
35+
const stats = await fs.lstat(filePath)
36+
if (stats.isSymbolicLink()) {
37+
// Resolve the symlink to get the actual file path
38+
const linkTarget = await fs.readlink(filePath)
39+
const resolvedPath = path.resolve(path.dirname(filePath), linkTarget)
40+
// Read from the resolved path
41+
const content = await fs.readFile(resolvedPath, "utf-8")
42+
return content.trim()
43+
} else {
44+
// Not a symlink, read normally
45+
const content = await fs.readFile(filePath, "utf-8")
46+
return content.trim()
47+
}
48+
} catch (err) {
49+
const errorCode = (err as NodeJS.ErrnoException).code
50+
if (!errorCode || !["ENOENT", "EISDIR"].includes(errorCode)) {
51+
throw err
52+
}
53+
return ""
54+
}
55+
}
56+
2957
/**
3058
* Check if a directory exists
3159
*/
@@ -222,7 +250,7 @@ export async function loadRuleFiles(cwd: string): Promise<string> {
222250
async function loadAgentRulesFile(cwd: string): Promise<string> {
223251
try {
224252
const agentsPath = path.join(cwd, "AGENTS.md")
225-
const content = await safeReadFile(agentsPath)
253+
const content = await safeReadFileFollowingSymlinks(agentsPath)
226254
if (content) {
227255
return `# Agent Rules Standard (AGENTS.md):\n${content}`
228256
}

0 commit comments

Comments
 (0)