Skip to content

Commit 36af94e

Browse files
committed
feat: add global system prompt override support
- Users can now define global system prompts in ~/.roo/system-prompt-{mode} - Local project prompts still take precedence over global ones - Updated tests to cover the new functionality - Fixes #6813
1 parent 6b4ac52 commit 36af94e

File tree

3 files changed

+95
-22
lines changed

3 files changed

+95
-22
lines changed

src/core/prompts/sections/__tests__/custom-system-prompt.spec.ts

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
// Mocks must come first, before imports
22

33
vi.mock("fs/promises")
4+
vi.mock("os")
45

56
// Then imports
67
import type { Mock } from "vitest"
78
import path from "path"
9+
import os from "os"
810
import { readFile } from "fs/promises"
911
import type { Mode } from "../../../../shared/modes" // Type-only import
10-
import { loadSystemPromptFile, PromptVariables } from "../custom-system-prompt"
12+
import { loadSystemPromptFile, PromptVariables, getGlobalSystemPromptFilePath } from "../custom-system-prompt"
1113

1214
// Cast the mocked readFile to the correct Mock type
1315
const mockedReadFile = readFile as Mock<typeof readFile>
16+
const mockedHomedir = os.homedir as Mock<typeof os.homedir>
1417

1518
describe("loadSystemPromptFile", () => {
1619
// Corrected PromptVariables type and added mockMode
@@ -19,15 +22,18 @@ describe("loadSystemPromptFile", () => {
1922
}
2023
const mockCwd = "/mock/cwd"
2124
const mockMode: Mode = "test" // Use Mode type, e.g., 'test'
25+
const mockHomeDir = "/home/user"
2226
// Corrected expected file path format
23-
const expectedFilePath = path.join(mockCwd, ".roo", `system-prompt-${mockMode}`)
27+
const expectedLocalFilePath = path.join(mockCwd, ".roo", `system-prompt-${mockMode}`)
28+
const expectedGlobalFilePath = path.join(mockHomeDir, ".roo", `system-prompt-${mockMode}`)
2429

2530
beforeEach(() => {
2631
// Clear mocks before each test
2732
mockedReadFile.mockClear()
33+
mockedHomedir.mockReturnValue(mockHomeDir)
2834
})
2935

30-
it("should return an empty string if the file does not exist (ENOENT)", async () => {
36+
it("should return an empty string if neither local nor global file exists (ENOENT)", async () => {
3137
const error: NodeJS.ErrnoException = new Error("File not found")
3238
error.code = "ENOENT"
3339
mockedReadFile.mockRejectedValue(error)
@@ -36,8 +42,9 @@ describe("loadSystemPromptFile", () => {
3642
const result = await loadSystemPromptFile(mockCwd, mockMode, mockVariables)
3743

3844
expect(result).toBe("")
39-
expect(mockedReadFile).toHaveBeenCalledTimes(1)
40-
expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8")
45+
expect(mockedReadFile).toHaveBeenCalledTimes(2)
46+
expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8")
47+
expect(mockedReadFile).toHaveBeenCalledWith(expectedGlobalFilePath, "utf-8")
4148
})
4249

4350
// Updated test: should re-throw unexpected errors
@@ -50,18 +57,23 @@ describe("loadSystemPromptFile", () => {
5057

5158
// Verify readFile was still called correctly
5259
expect(mockedReadFile).toHaveBeenCalledTimes(1)
53-
expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8")
60+
expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8")
5461
})
5562

56-
it("should return an empty string if the file content is empty", async () => {
57-
mockedReadFile.mockResolvedValue("")
63+
it("should return an empty string if the local file content is empty and check global", async () => {
64+
const error: NodeJS.ErrnoException = new Error("File not found")
65+
error.code = "ENOENT"
66+
67+
// Local file is empty, global file doesn't exist
68+
mockedReadFile.mockResolvedValueOnce("").mockRejectedValueOnce(error)
5869

5970
// Added mockMode argument
6071
const result = await loadSystemPromptFile(mockCwd, mockMode, mockVariables)
6172

6273
expect(result).toBe("")
63-
expect(mockedReadFile).toHaveBeenCalledTimes(1)
64-
expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8")
74+
expect(mockedReadFile).toHaveBeenCalledTimes(2)
75+
expect(mockedReadFile).toHaveBeenNthCalledWith(1, expectedLocalFilePath, "utf-8")
76+
expect(mockedReadFile).toHaveBeenNthCalledWith(2, expectedGlobalFilePath, "utf-8")
6577
})
6678

6779
// Updated test to only check workspace interpolation
@@ -74,7 +86,7 @@ describe("loadSystemPromptFile", () => {
7486

7587
expect(result).toBe("Workspace is: /path/to/workspace")
7688
expect(mockedReadFile).toHaveBeenCalledTimes(1)
77-
expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8")
89+
expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8")
7890
})
7991

8092
// Updated test for multiple occurrences of workspace
@@ -87,7 +99,7 @@ describe("loadSystemPromptFile", () => {
8799

88100
expect(result).toBe("Path: /path/to/workspace//path/to/workspace")
89101
expect(mockedReadFile).toHaveBeenCalledTimes(1)
90-
expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8")
102+
expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8")
91103
})
92104

93105
// Updated test for mixed used/unused
@@ -101,7 +113,7 @@ describe("loadSystemPromptFile", () => {
101113
// Unused variables should remain untouched
102114
expect(result).toBe("Workspace: /path/to/workspace, Unused: {{unusedVar}}, Another: {{another}}")
103115
expect(mockedReadFile).toHaveBeenCalledTimes(1)
104-
expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8")
116+
expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8")
105117
})
106118

107119
// Test remains valid, just needs the mode argument and updated template
@@ -114,7 +126,7 @@ describe("loadSystemPromptFile", () => {
114126

115127
expect(result).toBe("Workspace: /path/to/workspace, Missing: {{missingPlaceholder}}")
116128
expect(mockedReadFile).toHaveBeenCalledTimes(1)
117-
expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8")
129+
expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8")
118130
})
119131

120132
// Removed the test for extra keys as PromptVariables is simple now
@@ -129,6 +141,39 @@ describe("loadSystemPromptFile", () => {
129141

130142
expect(result).toBe("This is a static prompt.")
131143
expect(mockedReadFile).toHaveBeenCalledTimes(1)
132-
expect(mockedReadFile).toHaveBeenCalledWith(expectedFilePath, "utf-8")
144+
expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8")
145+
})
146+
147+
it("should use global system prompt when local file doesn't exist", async () => {
148+
const error: NodeJS.ErrnoException = new Error("File not found")
149+
error.code = "ENOENT"
150+
const globalTemplate = "Global system prompt: {{workspace}}"
151+
152+
// First call (local) fails, second call (global) succeeds
153+
mockedReadFile.mockRejectedValueOnce(error).mockResolvedValueOnce(globalTemplate)
154+
155+
const result = await loadSystemPromptFile(mockCwd, mockMode, mockVariables)
156+
157+
expect(result).toBe("Global system prompt: /path/to/workspace")
158+
expect(mockedReadFile).toHaveBeenCalledTimes(2)
159+
expect(mockedReadFile).toHaveBeenNthCalledWith(1, expectedLocalFilePath, "utf-8")
160+
expect(mockedReadFile).toHaveBeenNthCalledWith(2, expectedGlobalFilePath, "utf-8")
161+
})
162+
163+
it("should prefer local system prompt over global when both exist", async () => {
164+
const localTemplate = "Local system prompt: {{workspace}}"
165+
mockedReadFile.mockResolvedValueOnce(localTemplate)
166+
167+
const result = await loadSystemPromptFile(mockCwd, mockMode, mockVariables)
168+
169+
expect(result).toBe("Local system prompt: /path/to/workspace")
170+
// Should only read the local file, not the global one
171+
expect(mockedReadFile).toHaveBeenCalledTimes(1)
172+
expect(mockedReadFile).toHaveBeenCalledWith(expectedLocalFilePath, "utf-8")
173+
})
174+
175+
it("should correctly generate global system prompt file path", () => {
176+
const result = getGlobalSystemPromptFilePath(mockMode)
177+
expect(result).toBe(expectedGlobalFilePath)
133178
})
134179
})

src/core/prompts/sections/custom-system-prompt.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from "fs/promises"
22
import path from "path"
3+
import os from "os"
34
import { Mode } from "../../../shared/modes"
45
import { fileExistsAtPath } from "../../../utils/fs"
56

@@ -50,15 +51,34 @@ export function getSystemPromptFilePath(cwd: string, mode: Mode): string {
5051
}
5152

5253
/**
53-
* Loads custom system prompt from a file at .roo/system-prompt-[mode slug]
54-
* If the file doesn't exist, returns an empty string
54+
* Get the path to a global system prompt file for a specific mode
55+
* Located in the user's home directory under .roo/system-prompt-[mode slug]
56+
*/
57+
export function getGlobalSystemPromptFilePath(mode: Mode): string {
58+
return path.join(os.homedir(), ".roo", `system-prompt-${mode}`)
59+
}
60+
61+
/**
62+
* Loads custom system prompt from a file, checking in the following order:
63+
* 1. Local project: .roo/system-prompt-[mode slug]
64+
* 2. Global (home directory): ~/.roo/system-prompt-[mode slug]
65+
* If neither file exists, returns an empty string
5566
*/
5667
export async function loadSystemPromptFile(cwd: string, mode: Mode, variables: PromptVariables): Promise<string> {
57-
const filePath = getSystemPromptFilePath(cwd, mode)
58-
const rawContent = await safeReadFile(filePath)
68+
// First, check for local project-specific system prompt
69+
const localFilePath = getSystemPromptFilePath(cwd, mode)
70+
let rawContent = await safeReadFile(localFilePath)
71+
72+
// If no local file exists, check for global system prompt
73+
if (!rawContent) {
74+
const globalFilePath = getGlobalSystemPromptFilePath(mode)
75+
rawContent = await safeReadFile(globalFilePath)
76+
}
77+
5978
if (!rawContent) {
6079
return ""
6180
}
81+
6282
const interpolatedContent = interpolatePromptContent(rawContent, variables)
6383
return interpolatedContent
6484
}

src/core/webview/ClineProvider.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ import { ContextProxy } from "../config/ContextProxy"
7878
import { ProviderSettingsManager } from "../config/ProviderSettingsManager"
7979
import { CustomModesManager } from "../config/CustomModesManager"
8080
import { Task, TaskOptions } from "../task/Task"
81-
import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt"
81+
import { getSystemPromptFilePath, getGlobalSystemPromptFilePath } from "../prompts/sections/custom-system-prompt"
8282

8383
import { webviewMessageHandler } from "./webviewMessageHandler"
8484
import { getNonce } from "./getNonce"
@@ -1480,10 +1480,18 @@ export class ClineProvider
14801480

14811481
/**
14821482
* Checks if there is a file-based system prompt override for the given mode
1483+
* Checks both local project directory and global home directory
14831484
*/
14841485
async hasFileBasedSystemPromptOverride(mode: Mode): Promise<boolean> {
1485-
const promptFilePath = getSystemPromptFilePath(this.cwd, mode)
1486-
return await fileExistsAtPath(promptFilePath)
1486+
// Check local project directory first
1487+
const localPromptFilePath = getSystemPromptFilePath(this.cwd, mode)
1488+
if (await fileExistsAtPath(localPromptFilePath)) {
1489+
return true
1490+
}
1491+
1492+
// Check global home directory
1493+
const globalPromptFilePath = getGlobalSystemPromptFilePath(mode)
1494+
return await fileExistsAtPath(globalPromptFilePath)
14871495
}
14881496

14891497
/**

0 commit comments

Comments
 (0)