Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ jest.*
.roomodes
.rooignore
.roo/**
.pearai-agent/**
.pearai-agent-ignore
.pearai-agent-modes
benchmark/**
cline_docs/**
e2e/**
Expand Down
14 changes: 7 additions & 7 deletions src/core/config/CustomModesManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import { fileExistsAtPath } from "../../utils/fs"
import { arePathsEqual, getWorkspacePath } from "../../utils/path"
import { logger } from "../../utils/logging"
import { GlobalFileNames } from "../../shared/globalFileNames"
import { AGENT_MODES_FILE_NAME } from "../../shared/constants"

const ROOMODES_FILENAME = ".roomodes"

const ROOMODES_FILENAME = AGENT_MODES_FILE_NAME
export class CustomModesManager {
private disposables: vscode.Disposable[] = []
private isWriting = false
Expand Down Expand Up @@ -152,27 +152,27 @@ export class CustomModesManager {
return
}

// Get modes from .roomodes if it exists (takes precedence)
// Get modes from .pearai-agent-modes if it exists (takes precedence)
const roomodesPath = await this.getWorkspaceRoomodes()
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []

// Merge modes from both sources (.roomodes takes precedence)
// Merge modes from both sources (.pearai-agent-modes takes precedence)
const mergedModes = await this.mergeCustomModes(roomodesModes, result.data.customModes)
await this.context.globalState.update("customModes", mergedModes)
await this.onUpdate()
}
}),
)

// Watch .roomodes file if it exists
// Watch .pearai-agent-modes file if it exists
const roomodesPath = await this.getWorkspaceRoomodes()
if (roomodesPath) {
this.disposables.push(
vscode.workspace.onDidSaveTextDocument(async (document) => {
if (arePathsEqual(document.uri.fsPath, roomodesPath)) {
const settingsModes = await this.loadModesFromFile(settingsPath)
const roomodesModes = await this.loadModesFromFile(roomodesPath)
// .roomodes takes precedence
// .pearai-agent-modes takes precedence
const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
await this.context.globalState.update("customModes", mergedModes)
await this.onUpdate()
Expand All @@ -187,7 +187,7 @@ export class CustomModesManager {
const settingsPath = await this.getCustomModesFilePath()
const settingsModes = await this.loadModesFromFile(settingsPath)

// Get modes from .roomodes if it exists
// Get modes from .pearai-agent-modes if it exists
const roomodesPath = await this.getWorkspaceRoomodes()
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []

Expand Down
26 changes: 13 additions & 13 deletions src/core/config/__tests__/CustomModesManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe("CustomModesManager", () => {
// Use path.sep to ensure correct path separators for the current platform
const mockStoragePath = `${path.sep}mock${path.sep}settings`
const mockSettingsPath = path.join(mockStoragePath, "settings", GlobalFileNames.customModes)
const mockRoomodes = `${path.sep}mock${path.sep}workspace${path.sep}.roomodes`
const mockRoomodes = `${path.sep}mock${path.sep}workspace${path.sep}.pearai-agent-ignore`

beforeEach(() => {
mockOnUpdate = jest.fn()
Expand Down Expand Up @@ -60,7 +60,7 @@ describe("CustomModesManager", () => {
})

describe("getCustomModes", () => {
it("should merge modes with .roomodes taking precedence", async () => {
it("should merge modes with .pearai-agent-ignore taking precedence", async () => {
const settingsModes = [
{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] },
{ slug: "mode2", name: "Mode 2", roleDefinition: "Role 2", groups: ["read"] },
Expand All @@ -87,13 +87,13 @@ describe("CustomModesManager", () => {
expect(modes).toHaveLength(3)
expect(modes.map((m) => m.slug)).toEqual(["mode2", "mode3", "mode1"])

// mode2 should come from .roomodes since it takes precedence
// mode2 should come from .pearai-agent-ignore since it takes precedence
const mode2 = modes.find((m) => m.slug === "mode2")
expect(mode2?.name).toBe("Mode 2 Override")
expect(mode2?.roleDefinition).toBe("Role 2 Override")
})

it("should handle missing .roomodes file", async () => {
it("should handle missing .pearai-agent-ignore file", async () => {
const settingsModes = [{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] }]

;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
Expand All @@ -112,7 +112,7 @@ describe("CustomModesManager", () => {
expect(modes[0].slug).toBe("mode1")
})

it("should handle invalid JSON in .roomodes", async () => {
it("should handle invalid JSON in .pearai-agent-ignore", async () => {
const settingsModes = [{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] }]

;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
Expand All @@ -127,14 +127,14 @@ describe("CustomModesManager", () => {

const modes = await manager.getCustomModes()

// Should fall back to settings modes when .roomodes is invalid
// Should fall back to settings modes when .pearai-agent-ignore is invalid
expect(modes).toHaveLength(1)
expect(modes[0].slug).toBe("mode1")
})
})

describe("updateCustomMode", () => {
it("should update mode in settings file while preserving .roomodes precedence", async () => {
it("should update mode in settings file while preserving .pearai-agent-ignore precedence", async () => {
const newMode: ModeConfig = {
slug: "mode1",
name: "Updated Mode 1",
Expand Down Expand Up @@ -198,13 +198,13 @@ describe("CustomModesManager", () => {
}),
)

// Should update global state with merged modes where .roomodes takes precedence
// Should update global state with merged modes where .pearai-agent-ignore takes precedence
expect(mockContext.globalState.update).toHaveBeenCalledWith(
"customModes",
expect.arrayContaining([
expect.objectContaining({
slug: "mode1",
name: "Roomodes Mode 1", // .roomodes version should take precedence
name: "Roomodes Mode 1", // .pearai-agent-ignore version should take precedence
source: "project",
}),
]),
Expand All @@ -214,7 +214,7 @@ describe("CustomModesManager", () => {
expect(mockOnUpdate).toHaveBeenCalled()
})

it("creates .roomodes file when adding project-specific mode", async () => {
it("creates .pearai-agent-ignore file when adding project-specific mode", async () => {
const projectMode: ModeConfig = {
slug: "project-mode",
name: "Project Mode",
Expand All @@ -223,7 +223,7 @@ describe("CustomModesManager", () => {
source: "project",
}

// Mock .roomodes to not exist initially
// Mock .pearai-agent-ignore to not exist initially
let roomodesContent: any = null
;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
return path === mockSettingsPath
Expand All @@ -249,7 +249,7 @@ describe("CustomModesManager", () => {

await manager.updateCustomMode("project-mode", projectMode)

// Verify .roomodes was created with the project mode
// Verify .pearai-agent-ignore was created with the project mode
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String), // Don't check exact path as it may have different separators on different platforms
expect.stringContaining("project-mode"),
Expand All @@ -260,7 +260,7 @@ describe("CustomModesManager", () => {
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
expect(path.normalize(writeCall[0])).toBe(path.normalize(mockRoomodes))

// Verify the content written to .roomodes
// Verify the content written to .pearai-agent-ignore
expect(roomodesContent).toEqual({
customModes: [
expect.objectContaining({
Expand Down
28 changes: 14 additions & 14 deletions src/core/ignore/RooIgnoreController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import { fileExistsAtPath } from "../../utils/fs"
import fs from "fs/promises"
import ignore, { Ignore } from "ignore"
import * as vscode from "vscode"
import { AGENT_IGNORE_FILE_NAME } from "../../shared/constants"

export const LOCK_TEXT_SYMBOL = "\u{1F512}"

/**
* Controls LLM access to files by enforcing ignore patterns.
* Designed to be instantiated once in Cline.ts and passed to file manipulation services.
* Uses the 'ignore' library to support standard .gitignore syntax in .rooignore files.
* Uses the 'ignore' library to support standard .gitignore syntax in .pearai-agent-ignore files.
*/
export class RooIgnoreController {
private cwd: string
Expand All @@ -21,7 +21,7 @@ export class RooIgnoreController {
this.cwd = cwd
this.ignoreInstance = ignore()
this.rooIgnoreContent = undefined
// Set up file watcher for .rooignore
// Set up file watcher for .pearai-agent-ignore
this.setupFileWatcher()
}

Expand All @@ -34,10 +34,10 @@ export class RooIgnoreController {
}

/**
* Set up the file watcher for .rooignore changes
* Set up the file watcher for .pearai-agent-ignore changes
*/
private setupFileWatcher(): void {
const rooignorePattern = new vscode.RelativePattern(this.cwd, ".rooignore")
const rooignorePattern = new vscode.RelativePattern(this.cwd, AGENT_IGNORE_FILE_NAME)
const fileWatcher = vscode.workspace.createFileSystemWatcher(rooignorePattern)

// Watch for changes and updates
Expand All @@ -58,24 +58,24 @@ export class RooIgnoreController {
}

/**
* Load custom patterns from .rooignore if it exists
* Load custom patterns from .pearai-agent-ignore if it exists
*/
private async loadRooIgnore(): Promise<void> {
try {
// Reset ignore instance to prevent duplicate patterns
this.ignoreInstance = ignore()
const ignorePath = path.join(this.cwd, ".rooignore")
const ignorePath = path.join(this.cwd, AGENT_IGNORE_FILE_NAME)
if (await fileExistsAtPath(ignorePath)) {
const content = await fs.readFile(ignorePath, "utf8")
this.rooIgnoreContent = content
this.ignoreInstance.add(content)
this.ignoreInstance.add(".rooignore")
this.ignoreInstance.add(AGENT_IGNORE_FILE_NAME)
} else {
this.rooIgnoreContent = undefined
}
} catch (error) {
// Should never happen: reading file failed even though it exists
console.error("Unexpected error loading .rooignore:", error)
console.error(`Unexpected error loading ${AGENT_IGNORE_FILE_NAME}:`, error)
}
}

Expand All @@ -85,7 +85,7 @@ export class RooIgnoreController {
* @returns true if file is accessible, false if ignored
*/
validateAccess(filePath: string): boolean {
// Always allow access if .rooignore does not exist
// Always allow access if .pearai-agent-ignore does not exist
if (!this.rooIgnoreContent) {
return true
}
Expand All @@ -109,7 +109,7 @@ export class RooIgnoreController {
* @returns path of file that is being accessed if it is being accessed, undefined if command is allowed
*/
validateCommand(command: string): string | undefined {
// Always allow if no .rooignore exists
// Always allow if no .pearai-agent-ignore exists
if (!this.rooIgnoreContent) {
return undefined
}
Expand Down Expand Up @@ -188,14 +188,14 @@ export class RooIgnoreController {
}

/**
* Get formatted instructions about the .rooignore file for the LLM
* @returns Formatted instructions or undefined if .rooignore doesn't exist
* Get formatted instructions about the .pearai-agent-ignore file for the LLM
* @returns Formatted instructions or undefined if .pearai-agent-ignore doesn't exist
*/
getInstructions(): string | undefined {
if (!this.rooIgnoreContent) {
return undefined
}

return `# .rooignore\n\n(The following is provided by a root-level .rooignore file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${this.rooIgnoreContent}\n.rooignore`
return `# ${AGENT_IGNORE_FILE_NAME}\n\n(The following is provided by a root-level ${AGENT_IGNORE_FILE_NAME} file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${this.rooIgnoreContent}\n${AGENT_IGNORE_FILE_NAME}`
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe("RooIgnoreController Security Tests", () => {
mockFileExists = fileExistsAtPath as jest.MockedFunction<typeof fileExistsAtPath>
mockReadFile = fs.readFile as jest.MockedFunction<typeof fs.readFile>

// By default, setup .rooignore to exist with some patterns
// By default, setup .pearai-agent-ignore to exist with some patterns
mockFileExists.mockResolvedValue(true)
mockReadFile.mockResolvedValue("node_modules\n.git\nsecrets/**\n*.log\nprivate/")

Expand Down
5 changes: 3 additions & 2 deletions src/core/prompts/__tests__/custom-system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { defaultModeSlug, modes } from "../../../shared/modes"
import * as vscode from "vscode"
import * as fs from "fs/promises"
import { toPosix } from "./utils"
import { AGENT_RULES_DIR } from "../../../shared/constants"

// Mock the fs/promises module
jest.mock("fs/promises", () => ({
Expand Down Expand Up @@ -90,7 +91,7 @@ describe("File-Based Custom System Prompt", () => {
const fileCustomSystemPrompt = "Custom system prompt from file"
// When called with utf-8 encoding, return a string
mockedFs.readFile.mockImplementation((filePath, options) => {
if (toPosix(filePath).includes(`.roo/system-prompt-${defaultModeSlug}`) && options === "utf-8") {
if (toPosix(filePath).includes(`${AGENT_RULES_DIR}/system-prompt-${defaultModeSlug}`) && options === "utf-8") {
return Promise.resolve(fileCustomSystemPrompt)
}
return Promise.reject({ code: "ENOENT" })
Expand Down Expand Up @@ -125,7 +126,7 @@ describe("File-Based Custom System Prompt", () => {
// Mock the readFile to return content from a file
const fileCustomSystemPrompt = "Custom system prompt from file"
mockedFs.readFile.mockImplementation((filePath, options) => {
if (toPosix(filePath).includes(`.roo/system-prompt-${defaultModeSlug}`) && options === "utf-8") {
if (toPosix(filePath).includes(`${AGENT_RULES_DIR}/system-prompt-${defaultModeSlug}`) && options === "utf-8") {
return Promise.resolve(fileCustomSystemPrompt)
}
return Promise.reject({ code: "ENOENT" })
Expand Down
14 changes: 7 additions & 7 deletions src/core/prompts/__tests__/responses-rooignore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ describe("RooIgnore Response Formatting", () => {
const errorMessage = formatResponse.rooIgnoreError("secrets/api-keys.json")

// Verify error message format
expect(errorMessage).toContain("Access to secrets/api-keys.json is blocked by the .rooignore file settings")
expect(errorMessage).toContain("Access to secrets/api-keys.json is blocked by the .pearai-agent-ignore file settings")
expect(errorMessage).toContain("continue in the task without using this file")
expect(errorMessage).toContain("ask the user to update the .rooignore file")
expect(errorMessage).toContain("ask the user to update the .pearai-agent-ignore file")
})

/**
Expand Down Expand Up @@ -207,7 +207,7 @@ describe("RooIgnore Response Formatting", () => {
/**
* Tests the instructions format
*/
it("should format .rooignore instructions for the LLM", async () => {
it("should format .pearai-agent-ignore instructions for the LLM", async () => {
// Create controller
const controller = new RooIgnoreController(TEST_CWD)
await controller.initialize()
Expand All @@ -216,7 +216,7 @@ describe("RooIgnore Response Formatting", () => {
const instructions = controller.getInstructions()

// Verify format and content
expect(instructions).toContain("# .rooignore")
expect(instructions).toContain("# .pearai-agent-ignore")
expect(instructions).toContain(LOCK_TEXT_SYMBOL)
expect(instructions).toContain("node_modules")
expect(instructions).toContain(".git")
Expand All @@ -231,11 +231,11 @@ describe("RooIgnore Response Formatting", () => {
/**
* Tests null/undefined case
*/
it("should return undefined when no .rooignore exists", async () => {
// Set up no .rooignore
it("should return undefined when no .pearai-agent-ignore exists", async () => {
// Set up no .pearai-agent-ignore
mockFileExists.mockResolvedValue(false)

// Create controller without .rooignore
// Create controller without .pearai-agent-ignore
const controller = new RooIgnoreController(TEST_CWD)
await controller.initialize()

Expand Down
7 changes: 4 additions & 3 deletions src/core/prompts/instructions/create-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as path from "path"
import * as vscode from "vscode"

import { GlobalFileNames } from "../../../shared/globalFileNames"
import { AGENT_MODES_FILE_NAME } from "../../../shared/constants"

export async function createModeInstructions(context: vscode.ExtensionContext | undefined): Promise<string> {
if (!context) throw new Error("Missing VSCode Extension Context")
Expand All @@ -12,12 +13,12 @@ export async function createModeInstructions(context: vscode.ExtensionContext |
return `
Custom modes can be configured in two ways:
1. Globally via '${customModesPath}' (created automatically on startup)
2. Per-workspace via '.roomodes' in the workspace root directory
2. Per-workspace via '${AGENT_MODES_FILE_NAME}' in the workspace root directory

When modes with the same slug exist in both files, the workspace-specific .roomodes version takes precedence. This allows projects to override global modes or define project-specific modes.
When modes with the same slug exist in both files, the workspace-specific ${AGENT_MODES_FILE_NAME} version takes precedence. This allows projects to override global modes or define project-specific modes.


If asked to create a project mode, create it in .roomodes in the workspace root. If asked to create a global mode, use the global custom modes file.
If asked to create a project mode, create it in ${AGENT_MODES_FILE_NAME} in the workspace root. If asked to create a global mode, use the global custom modes file.

- The following fields are required and must not be empty:
* slug: A valid slug (lowercase letters, numbers, and hyphens). Must be unique, and shorter is better.
Expand Down
3 changes: 2 additions & 1 deletion src/core/prompts/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
import * as path from "path"
import * as diff from "diff"
import { RooIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/RooIgnoreController"
import { AGENT_IGNORE_FILE_NAME } from "../../shared/constants"

export const formatResponse = {
toolDenied: () => `The user denied this operation.`,
Expand All @@ -15,7 +16,7 @@ export const formatResponse = {
toolError: (error?: string) => `The tool execution failed with the following error:\n<error>\n${error}\n</error>`,

rooIgnoreError: (path: string) =>
`Access to ${path} is blocked by the .rooignore file settings. You must try to continue in the task without using this file, or ask the user to update the .rooignore file.`,
`Access to ${path} is blocked by the ${AGENT_IGNORE_FILE_NAME} file settings. You must try to continue in the task without using this file, or ask the user to update the ${AGENT_IGNORE_FILE_NAME} file.`,

noToolsUsed: () =>
`[ERROR] You did not use a tool in your previous response! Please retry with a tool use.
Expand Down
Loading