diff --git a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts index f6fb8ea1f4..27d941b2f6 100644 --- a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts +++ b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts @@ -3,6 +3,11 @@ // Mock fs/promises vi.mock("fs/promises") +// Mock os module +vi.mock("os", () => ({ + homedir: vi.fn().mockReturnValue("/home/user"), +})) + // Mock path.resolve and path.join to be predictable in tests vi.mock("path", async () => ({ ...(await vi.importActual("path")), @@ -46,6 +51,7 @@ vi.mock("path", async () => ({ import fs from "fs/promises" import type { PathLike } from "fs" +import * as os from "os" import { loadRuleFiles, addCustomInstructions } from "../custom-instructions" @@ -1199,3 +1205,280 @@ describe("Rules directory reading", () => { expect(result).toBe("\n# Rules from .roorules:\nfallback content\n") }) }) + +describe("External file path support", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should load instructions from external file path when provided", async () => { + // Mock file existence check + statMock.mockImplementation((path) => { + const normalizedPath = path.toString().replace(/\\/g, "/") + if (normalizedPath === "/fake/path/.github/copilot-instructions.md") { + return Promise.resolve({ + isFile: vi.fn().mockReturnValue(true), + isDirectory: vi.fn().mockReturnValue(false), + } as any) + } + // For .roo/rules directory check + return Promise.reject({ code: "ENOENT" }) + }) + + // Mock file reading + readFileMock.mockImplementation((filePath: PathLike) => { + const pathStr = filePath.toString() + const normalizedPath = pathStr.replace(/\\/g, "/") + if (normalizedPath === "/fake/path/.github/copilot-instructions.md") { + return Promise.resolve("External file instructions content") + } + return Promise.reject({ code: "ENOENT" }) + }) + + const result = await addCustomInstructions( + "", + "./.github/copilot-instructions.md", // File path reference + "/fake/path", + "", + ) + + expect(result).toContain("Global Instructions:\nExternal file instructions content") + expect(readFileMock).toHaveBeenCalledWith( + process.platform === "win32" + ? "\\fake\\path\\.github\\copilot-instructions.md" + : "/fake/path/.github/copilot-instructions.md", + "utf-8", + ) + }) + + it("should handle absolute file paths", async () => { + const absolutePath = "/absolute/path/to/instructions.md" + + // Mock file existence check + statMock.mockImplementation((path) => { + const normalizedPath = path.toString().replace(/\\/g, "/") + if (normalizedPath === absolutePath) { + return Promise.resolve({ + isFile: vi.fn().mockReturnValue(true), + isDirectory: vi.fn().mockReturnValue(false), + } as any) + } + return Promise.reject({ code: "ENOENT" }) + }) + + // Mock file reading + readFileMock.mockImplementation((filePath: PathLike) => { + const pathStr = filePath.toString() + const normalizedPath = pathStr.replace(/\\/g, "/") + if (normalizedPath === absolutePath) { + return Promise.resolve("Absolute path instructions") + } + return Promise.reject({ code: "ENOENT" }) + }) + + const result = await addCustomInstructions("", absolutePath, "/fake/path", "") + + expect(result).toContain("Global Instructions:\nAbsolute path instructions") + }) + + it("should fall back to literal string if file path doesn't exist", async () => { + // Mock all file checks to fail + statMock.mockRejectedValue({ code: "ENOENT" }) + readFileMock.mockRejectedValue({ code: "ENOENT" }) + + const result = await addCustomInstructions("", "./non-existent/file.md", "/fake/path", "") + + expect(result).toContain("Global Instructions:\n./non-existent/file.md") + }) + + it("should treat non-path strings as literal instructions", async () => { + // Mock to ensure no file operations succeed + statMock.mockRejectedValue({ code: "ENOENT" }) + readFileMock.mockRejectedValue({ code: "ENOENT" }) + + const result = await addCustomInstructions("", "This is just a regular instruction string", "/fake/path", "") + + expect(result).toContain("Global Instructions:\nThis is just a regular instruction string") + }) + + it("should support file paths for mode-specific instructions", async () => { + // Mock file existence check + statMock.mockImplementation((path) => { + const normalizedPath = path.toString().replace(/\\/g, "/") + if (normalizedPath === "/fake/path/mode-specific.md") { + return Promise.resolve({ + isFile: vi.fn().mockReturnValue(true), + isDirectory: vi.fn().mockReturnValue(false), + } as any) + } + return Promise.reject({ code: "ENOENT" }) + }) + + // Mock file reading + readFileMock.mockImplementation((filePath: PathLike) => { + const pathStr = filePath.toString() + const normalizedPath = pathStr.replace(/\\/g, "/") + if (normalizedPath === "/fake/path/mode-specific.md") { + return Promise.resolve("Mode specific file content") + } + return Promise.reject({ code: "ENOENT" }) + }) + + const result = await addCustomInstructions( + "./mode-specific.md", // File path for mode instructions + "", + "/fake/path", + "test-mode", + ) + + expect(result).toContain("Mode-specific Instructions:\nMode specific file content") + }) + + it("should handle directories gracefully", async () => { + // Mock directory check + statMock.mockImplementation((path) => { + const normalizedPath = path.toString().replace(/\\/g, "/") + if (normalizedPath === "/fake/path/.github") { + return Promise.resolve({ + isFile: vi.fn().mockReturnValue(false), + isDirectory: vi.fn().mockReturnValue(true), + } as any) + } + return Promise.reject({ code: "ENOENT" }) + }) + + const result = await addCustomInstructions( + "", + "./.github", // Directory path + "/fake/path", + "", + ) + + // Should treat as literal string since it's not a file + expect(result).toContain("Global Instructions:\n./.github") + }) +}) + +describe("Extension-specific instruction targeting", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should load extension-specific instructions from .roo directory", async () => { + // Mock file reading + readFileMock.mockImplementation((filePath: PathLike) => { + const pathStr = filePath.toString() + const normalizedPath = pathStr.replace(/\\/g, "/") + + // Check for both global and project .roo directories + if (normalizedPath.includes("/.roo/py-instruction.md")) { + if (normalizedPath.includes("/home/") || normalizedPath.includes("/Users/")) { + return Promise.resolve("Global Python instructions") + } else { + return Promise.resolve("Project Python instructions") + } + } + return Promise.reject({ code: "ENOENT" }) + }) + + const { loadExtensionSpecificInstructions } = await import("../custom-instructions") + const result = await loadExtensionSpecificInstructions("/fake/path", "py") + + expect(result).toContain("Extension-specific instructions from") + expect(result).toContain("py-instruction.md") + + // Should check both global and project directories + expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining("py-instruction.md"), "utf-8") + }) + + it("should load extension-specific instructions from project root", async () => { + // Mock file reading - only project root file exists + readFileMock.mockImplementation((filePath: PathLike) => { + const pathStr = filePath.toString() + const normalizedPath = pathStr.replace(/\\/g, "/") + + if (normalizedPath === "/fake/path/ts-instruction.md") { + return Promise.resolve("TypeScript specific instructions") + } + return Promise.reject({ code: "ENOENT" }) + }) + + const { loadExtensionSpecificInstructions } = await import("../custom-instructions") + const result = await loadExtensionSpecificInstructions("/fake/path", "ts") + + expect(result).toContain("Extension-specific instructions from") + expect(result).toContain("/fake/path/ts-instruction.md") + expect(result).toContain("TypeScript specific instructions") + }) + + it("should return empty string when no extension provided", async () => { + const { loadExtensionSpecificInstructions } = await import("../custom-instructions") + const result = await loadExtensionSpecificInstructions("/fake/path", "") + + expect(result).toBe("") + expect(readFileMock).not.toHaveBeenCalled() + }) + + it("should return empty string when no extension-specific files found", async () => { + // Mock all reads to fail + readFileMock.mockRejectedValue({ code: "ENOENT" }) + + const { loadExtensionSpecificInstructions } = await import("../custom-instructions") + const result = await loadExtensionSpecificInstructions("/fake/path", "java") + + expect(result).toBe("") + }) + + it("should combine instructions from multiple locations", async () => { + // Mock file reading - files exist in multiple locations + readFileMock.mockImplementation((filePath: PathLike) => { + const pathStr = filePath.toString() + const normalizedPath = pathStr.replace(/\\/g, "/") + + if (normalizedPath === "/home/user/.roo/js-instruction.md") { + return Promise.resolve("Global JS instructions") + } else if (normalizedPath === "/fake/path/.roo/js-instruction.md") { + return Promise.resolve("Project .roo JS instructions") + } else if (normalizedPath === "/fake/path/js-instruction.md") { + return Promise.resolve("Project root JS instructions") + } + return Promise.reject({ code: "ENOENT" }) + }) + + const { loadExtensionSpecificInstructions } = await import("../custom-instructions") + const result = await loadExtensionSpecificInstructions("/fake/path", "js") + + // Should contain instructions from all locations + expect(result).toContain("Global JS instructions") + expect(result).toContain("Project .roo JS instructions") + expect(result).toContain("Project root JS instructions") + + // Verify all locations were checked + const calls = readFileMock.mock.calls.map((call) => call[0].toString()) + expect(calls.some((path) => path.includes("/.roo/js-instruction.md"))).toBe(true) + expect(calls.some((path) => path.endsWith("/fake/path/js-instruction.md"))).toBe(true) + }) + + it("should handle various file extensions correctly", async () => { + const extensions = ["py", "ts", "js", "jsx", "tsx", "java", "cpp", "c", "go", "rs", "rb", "php"] + + for (const ext of extensions) { + vi.clearAllMocks() + + readFileMock.mockImplementation((filePath: PathLike) => { + const pathStr = filePath.toString() + if (pathStr.includes(`${ext}-instruction.md`)) { + return Promise.resolve(`${ext} instructions`) + } + return Promise.reject({ code: "ENOENT" }) + }) + + const { loadExtensionSpecificInstructions } = await import("../custom-instructions") + const result = await loadExtensionSpecificInstructions("/fake/path", ext) + + if (result) { + expect(result).toContain(`${ext} instructions`) + } + } + }) +}) diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index a78882cc90..34d7b6397a 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -214,6 +214,35 @@ export async function loadRuleFiles(cwd: string): Promise { return "" } +/** + * Load instruction content from a file path reference + * Supports both absolute and relative paths + */ +async function loadInstructionFromPath(instructionPath: string, cwd: string): Promise { + try { + // Check if the path looks like a file path reference + if (!instructionPath.includes("/") && !instructionPath.includes("\\")) { + return null + } + + // Resolve the path relative to the current working directory + const resolvedPath = path.isAbsolute(instructionPath) ? instructionPath : path.resolve(cwd, instructionPath) + + // Check if file exists + const stats = await fs.stat(resolvedPath) + if (!stats.isFile()) { + return null + } + + // Read the file content + const content = await fs.readFile(resolvedPath, "utf-8") + return content.trim() + } catch (err) { + // If file doesn't exist or can't be read, return null + return null + } +} + export async function addCustomInstructions( modeCustomInstructions: string, globalCustomInstructions: string, @@ -271,14 +300,32 @@ export async function addCustomInstructions( ) } - // Add global instructions first + // Process global instructions - check if it's a file path reference + let processedGlobalInstructions = globalCustomInstructions if (typeof globalCustomInstructions === "string" && globalCustomInstructions.trim()) { - sections.push(`Global Instructions:\n${globalCustomInstructions.trim()}`) + const fileContent = await loadInstructionFromPath(globalCustomInstructions.trim(), cwd) + if (fileContent !== null) { + processedGlobalInstructions = fileContent + } + } + + // Process mode-specific instructions - check if it's a file path reference + let processedModeInstructions = modeCustomInstructions + if (typeof modeCustomInstructions === "string" && modeCustomInstructions.trim()) { + const fileContent = await loadInstructionFromPath(modeCustomInstructions.trim(), cwd) + if (fileContent !== null) { + processedModeInstructions = fileContent + } + } + + // Add global instructions first + if (typeof processedGlobalInstructions === "string" && processedGlobalInstructions.trim()) { + sections.push(`Global Instructions:\n${processedGlobalInstructions.trim()}`) } // Add mode-specific instructions after - if (typeof modeCustomInstructions === "string" && modeCustomInstructions.trim()) { - sections.push(`Mode-specific Instructions:\n${modeCustomInstructions.trim()}`) + if (typeof processedModeInstructions === "string" && processedModeInstructions.trim()) { + sections.push(`Mode-specific Instructions:\n${processedModeInstructions.trim()}`) } // Add rules - include both mode-specific and generic rules if they exist @@ -321,6 +368,40 @@ ${joinedSections}` : "" } +/** + * Load extension-specific instruction files (e.g., py-instruction.md for Python files) + * @param cwd Current working directory + * @param fileExtension The file extension to look for (e.g., 'py', 'ts', 'js') + * @returns The content of the extension-specific instruction file if found + */ +export async function loadExtensionSpecificInstructions(cwd: string, fileExtension: string): Promise { + if (!fileExtension) { + return "" + } + + const extensionRules: string[] = [] + const rooDirectories = getRooDirectoriesForCwd(cwd) + + // Check for extension-specific instruction files in .roo directories + for (const rooDir of rooDirectories) { + // Check for {ext}-instruction.md files + const extensionInstructionFile = path.join(rooDir, `${fileExtension}-instruction.md`) + const content = await safeReadFile(extensionInstructionFile) + if (content) { + extensionRules.push(`# Extension-specific instructions from ${extensionInstructionFile}:\n${content}`) + } + } + + // Also check in the project root for legacy support + const rootExtensionFile = path.join(cwd, `${fileExtension}-instruction.md`) + const rootContent = await safeReadFile(rootExtensionFile) + if (rootContent) { + extensionRules.push(`# Extension-specific instructions from ${rootExtensionFile}:\n${rootContent}`) + } + + return extensionRules.join("\n\n") +} + /** * Check if a file should be included in rule compilation. * Excludes cache files and system files that shouldn't be processed as rules. diff --git a/src/core/prompts/sections/index.ts b/src/core/prompts/sections/index.ts index d06dbbfde1..6848f52434 100644 --- a/src/core/prompts/sections/index.ts +++ b/src/core/prompts/sections/index.ts @@ -1,7 +1,7 @@ export { getRulesSection } from "./rules" export { getSystemInfoSection } from "./system-info" export { getObjectiveSection } from "./objective" -export { addCustomInstructions } from "./custom-instructions" +export { addCustomInstructions, loadExtensionSpecificInstructions } from "./custom-instructions" export { getSharedToolUseSection } from "./tool-use" export { getMcpServersSection } from "./mcp-servers" export { getToolUseGuidelinesSection } from "./tool-use-guidelines"