diff --git a/src/core/prompts/sections/__tests__/custom-system-prompt.spec.ts b/src/core/prompts/sections/__tests__/custom-system-prompt.spec.ts index 81f96728d9..95e722378f 100644 --- a/src/core/prompts/sections/__tests__/custom-system-prompt.spec.ts +++ b/src/core/prompts/sections/__tests__/custom-system-prompt.spec.ts @@ -1,16 +1,19 @@ // Mocks must come first, before imports vi.mock("fs/promises") +vi.mock("os") // Then imports import type { Mock } from "vitest" import path from "path" +import os from "os" import { readFile } from "fs/promises" import type { Mode } from "../../../../shared/modes" // Type-only import -import { loadSystemPromptFile, PromptVariables } from "../custom-system-prompt" +import { loadSystemPromptFile, PromptVariables, getGlobalSystemPromptFilePath } from "../custom-system-prompt" // Cast the mocked readFile to the correct Mock type const mockedReadFile = readFile as Mock +const mockedHomedir = os.homedir as Mock describe("loadSystemPromptFile", () => { // Corrected PromptVariables type and added mockMode @@ -19,15 +22,18 @@ describe("loadSystemPromptFile", () => { } const mockCwd = "/mock/cwd" const mockMode: Mode = "test" // Use Mode type, e.g., 'test' + const mockHomeDir = "/home/user" // Corrected expected file path format - const expectedFilePath = path.join(mockCwd, ".roo", `system-prompt-${mockMode}`) + const expectedLocalFilePath = path.join(mockCwd, ".roo", `system-prompt-${mockMode}`) + const expectedGlobalFilePath = path.join(mockHomeDir, ".roo", `system-prompt-${mockMode}`) beforeEach(() => { // Clear mocks before each test mockedReadFile.mockClear() + mockedHomedir.mockReturnValue(mockHomeDir) }) - it("should return an empty string if the file does not exist (ENOENT)", async () => { + it("should return an empty string if neither local nor global file exists (ENOENT)", async () => { const error: NodeJS.ErrnoException = new Error("File not found") error.code = "ENOENT" mockedReadFile.mockRejectedValue(error) @@ -36,8 +42,9 @@ describe("loadSystemPromptFile", () => { const result = await loadSystemPromptFile(mockCwd, mockMode, mockVariables) expect(result).toBe("") - expect(mockedReadFile).toHaveBeenCalledTimes(1) - expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8") + expect(mockedReadFile).toHaveBeenCalledTimes(2) + expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8") + expect(mockedReadFile).toHaveBeenCalledWith(expectedGlobalFilePath, "utf-8") }) // Updated test: should re-throw unexpected errors @@ -50,18 +57,23 @@ describe("loadSystemPromptFile", () => { // Verify readFile was still called correctly expect(mockedReadFile).toHaveBeenCalledTimes(1) - expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8") + expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8") }) - it("should return an empty string if the file content is empty", async () => { - mockedReadFile.mockResolvedValue("") + it("should return an empty string if the local file content is empty and check global", async () => { + const error: NodeJS.ErrnoException = new Error("File not found") + error.code = "ENOENT" + + // Local file is empty, global file doesn't exist + mockedReadFile.mockResolvedValueOnce("").mockRejectedValueOnce(error) // Added mockMode argument const result = await loadSystemPromptFile(mockCwd, mockMode, mockVariables) expect(result).toBe("") - expect(mockedReadFile).toHaveBeenCalledTimes(1) - expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8") + expect(mockedReadFile).toHaveBeenCalledTimes(2) + expect(mockedReadFile).toHaveBeenNthCalledWith(1, expectedLocalFilePath, "utf-8") + expect(mockedReadFile).toHaveBeenNthCalledWith(2, expectedGlobalFilePath, "utf-8") }) // Updated test to only check workspace interpolation @@ -74,7 +86,7 @@ describe("loadSystemPromptFile", () => { expect(result).toBe("Workspace is: /path/to/workspace") expect(mockedReadFile).toHaveBeenCalledTimes(1) - expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8") + expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8") }) // Updated test for multiple occurrences of workspace @@ -87,7 +99,7 @@ describe("loadSystemPromptFile", () => { expect(result).toBe("Path: /path/to/workspace//path/to/workspace") expect(mockedReadFile).toHaveBeenCalledTimes(1) - expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8") + expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8") }) // Updated test for mixed used/unused @@ -101,7 +113,7 @@ describe("loadSystemPromptFile", () => { // Unused variables should remain untouched expect(result).toBe("Workspace: /path/to/workspace, Unused: {{unusedVar}}, Another: {{another}}") expect(mockedReadFile).toHaveBeenCalledTimes(1) - expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8") + expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8") }) // Test remains valid, just needs the mode argument and updated template @@ -114,7 +126,7 @@ describe("loadSystemPromptFile", () => { expect(result).toBe("Workspace: /path/to/workspace, Missing: {{missingPlaceholder}}") expect(mockedReadFile).toHaveBeenCalledTimes(1) - expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8") + expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8") }) // Removed the test for extra keys as PromptVariables is simple now @@ -129,6 +141,39 @@ describe("loadSystemPromptFile", () => { expect(result).toBe("This is a static prompt.") expect(mockedReadFile).toHaveBeenCalledTimes(1) - expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8") + expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8") + }) + + it("should use global system prompt when local file doesn't exist", async () => { + const error: NodeJS.ErrnoException = new Error("File not found") + error.code = "ENOENT" + const globalTemplate = "Global system prompt: {{workspace}}" + + // First call (local) fails, second call (global) succeeds + mockedReadFile.mockRejectedValueOnce(error).mockResolvedValueOnce(globalTemplate) + + const result = await loadSystemPromptFile(mockCwd, mockMode, mockVariables) + + expect(result).toBe("Global system prompt: /path/to/workspace") + expect(mockedReadFile).toHaveBeenCalledTimes(2) + expect(mockedReadFile).toHaveBeenNthCalledWith(1, expectedLocalFilePath, "utf-8") + expect(mockedReadFile).toHaveBeenNthCalledWith(2, expectedGlobalFilePath, "utf-8") + }) + + it("should prefer local system prompt over global when both exist", async () => { + const localTemplate = "Local system prompt: {{workspace}}" + mockedReadFile.mockResolvedValueOnce(localTemplate) + + const result = await loadSystemPromptFile(mockCwd, mockMode, mockVariables) + + expect(result).toBe("Local system prompt: /path/to/workspace") + // Should only read the local file, not the global one + expect(mockedReadFile).toHaveBeenCalledTimes(1) + expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8") + }) + + it("should correctly generate global system prompt file path", () => { + const result = getGlobalSystemPromptFilePath(mockMode) + expect(result).toBe(expectedGlobalFilePath) }) }) diff --git a/src/core/prompts/sections/custom-system-prompt.ts b/src/core/prompts/sections/custom-system-prompt.ts index f401000bb5..828073d98a 100644 --- a/src/core/prompts/sections/custom-system-prompt.ts +++ b/src/core/prompts/sections/custom-system-prompt.ts @@ -1,5 +1,6 @@ import fs from "fs/promises" import path from "path" +import os from "os" import { Mode } from "../../../shared/modes" import { fileExistsAtPath } from "../../../utils/fs" @@ -50,15 +51,34 @@ export function getSystemPromptFilePath(cwd: string, mode: Mode): string { } /** - * Loads custom system prompt from a file at .roo/system-prompt-[mode slug] - * If the file doesn't exist, returns an empty string + * Get the path to a global system prompt file for a specific mode + * Located in the user's home directory under .roo/system-prompt-[mode slug] + */ +export function getGlobalSystemPromptFilePath(mode: Mode): string { + return path.join(os.homedir(), ".roo", `system-prompt-${mode}`) +} + +/** + * Loads custom system prompt from a file, checking in the following order: + * 1. Local project: .roo/system-prompt-[mode slug] + * 2. Global (home directory): ~/.roo/system-prompt-[mode slug] + * If neither file exists, returns an empty string */ export async function loadSystemPromptFile(cwd: string, mode: Mode, variables: PromptVariables): Promise { - const filePath = getSystemPromptFilePath(cwd, mode) - const rawContent = await safeReadFile(filePath) + // First, check for local project-specific system prompt + const localFilePath = getSystemPromptFilePath(cwd, mode) + let rawContent = await safeReadFile(localFilePath) + + // If no local file exists, check for global system prompt + if (!rawContent) { + const globalFilePath = getGlobalSystemPromptFilePath(mode) + rawContent = await safeReadFile(globalFilePath) + } + if (!rawContent) { return "" } + const interpolatedContent = interpolatePromptContent(rawContent, variables) return interpolatedContent } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 274060a19b..7ed24bcedd 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -78,7 +78,7 @@ import { ContextProxy } from "../config/ContextProxy" import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { Task, TaskOptions } from "../task/Task" -import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt" +import { getSystemPromptFilePath, getGlobalSystemPromptFilePath } from "../prompts/sections/custom-system-prompt" import { webviewMessageHandler } from "./webviewMessageHandler" import { getNonce } from "./getNonce" @@ -1480,10 +1480,18 @@ export class ClineProvider /** * Checks if there is a file-based system prompt override for the given mode + * Checks both local project directory and global home directory */ async hasFileBasedSystemPromptOverride(mode: Mode): Promise { - const promptFilePath = getSystemPromptFilePath(this.cwd, mode) - return await fileExistsAtPath(promptFilePath) + // Check local project directory first + const localPromptFilePath = getSystemPromptFilePath(this.cwd, mode) + if (await fileExistsAtPath(localPromptFilePath)) { + return true + } + + // Check global home directory + const globalPromptFilePath = getGlobalSystemPromptFilePath(mode) + return await fileExistsAtPath(globalPromptFilePath) } /**