diff --git a/src/core/config/CustomModesSchema.ts b/src/core/config/CustomModesSchema.ts index a511367..0d2d14b 100644 --- a/src/core/config/CustomModesSchema.ts +++ b/src/core/config/CustomModesSchema.ts @@ -22,6 +22,7 @@ const GroupOptionsSchema = z.object({ }, { message: "Invalid regular expression pattern" }, ), + fileRegexDescription: z.string().optional(), }) // Schema for a group entry - either a tool group string or a tuple of [group, options] diff --git a/src/core/config/__tests__/CustomModesSchema.test.ts b/src/core/config/__tests__/CustomModesSchema.test.ts index c1e319e..3a9d7a1 100644 --- a/src/core/config/__tests__/CustomModesSchema.test.ts +++ b/src/core/config/__tests__/CustomModesSchema.test.ts @@ -1,72 +1,194 @@ -import { CustomModeSchema } from "../CustomModesSchema" +import { ZodError } from "zod" +import { CustomModeSchema, validateCustomMode } from "../CustomModesSchema" +import { ModeConfig } from "../../../shared/modes" describe("CustomModeSchema", () => { - it("validates a basic mode configuration", () => { - const validMode = { - slug: "test-mode", - name: "Test Mode", - roleDefinition: "Test role definition", - groups: ["read", "browser"], - } - - expect(() => CustomModeSchema.parse(validMode)).not.toThrow() - }) - - it("validates a mode with file restrictions", () => { - const modeWithFileRestrictions = { - slug: "markdown-editor", - name: "Markdown Editor", - roleDefinition: "Markdown editing mode", - groups: ["read", ["edit", { fileRegex: "\\.md$" }], "browser"], - } - - expect(() => CustomModeSchema.parse(modeWithFileRestrictions)).not.toThrow() - }) - - it("validates file regex patterns", () => { - const validPatterns = ["\\.md$", ".*\\.txt$", "[a-z]+\\.js$"] - const invalidPatterns = ["[", "(unclosed", "\\"] - - validPatterns.forEach((pattern) => { - const mode = { + describe("validateCustomMode", () => { + test("accepts valid mode configuration", () => { + const validMode = { slug: "test", - name: "Test", - roleDefinition: "Test", - groups: ["read", ["edit", { fileRegex: pattern }]], - } - expect(() => CustomModeSchema.parse(mode)).not.toThrow() + name: "Test Mode", + roleDefinition: "Test role definition", + groups: ["read"] as const, + } satisfies ModeConfig + + expect(() => validateCustomMode(validMode)).not.toThrow() }) - invalidPatterns.forEach((pattern) => { - const mode = { + test("accepts mode with multiple groups", () => { + const validMode = { slug: "test", - name: "Test", - roleDefinition: "Test", - groups: ["read", ["edit", { fileRegex: pattern }]], + name: "Test Mode", + roleDefinition: "Test role definition", + groups: ["read", "edit", "browser"] as const, + } satisfies ModeConfig + + expect(() => validateCustomMode(validMode)).not.toThrow() + }) + + test("accepts mode with optional customInstructions", () => { + const validMode = { + slug: "test", + name: "Test Mode", + roleDefinition: "Test role definition", + customInstructions: "Custom instructions", + groups: ["read"] as const, + } satisfies ModeConfig + + expect(() => validateCustomMode(validMode)).not.toThrow() + }) + + test("rejects missing required fields", () => { + const invalidModes = [ + {}, // All fields missing + { name: "Test" }, // Missing most fields + { + name: "Test", + roleDefinition: "Role", + }, // Missing slug and groups + ] + + invalidModes.forEach((invalidMode) => { + expect(() => validateCustomMode(invalidMode)).toThrow(ZodError) + }) + }) + + test("rejects invalid slug format", () => { + const invalidMode = { + slug: "not@a@valid@slug", + name: "Test Mode", + roleDefinition: "Test role definition", + groups: ["read"] as const, + } satisfies Omit & { slug: string } + + expect(() => validateCustomMode(invalidMode)).toThrow(ZodError) + expect(() => validateCustomMode(invalidMode)).toThrow("Slug must contain only letters numbers and dashes") + }) + + test("rejects empty strings in required fields", () => { + const emptyNameMode = { + slug: "123e4567-e89b-12d3-a456-426614174000", + name: "", + roleDefinition: "Test role definition", + groups: ["read"] as const, + } satisfies ModeConfig + + const emptyRoleMode = { + slug: "123e4567-e89b-12d3-a456-426614174000", + name: "Test Mode", + roleDefinition: "", + groups: ["read"] as const, + } satisfies ModeConfig + + expect(() => validateCustomMode(emptyNameMode)).toThrow("Name is required") + expect(() => validateCustomMode(emptyRoleMode)).toThrow("Role definition is required") + }) + + test("rejects invalid group configurations", () => { + const invalidGroupMode = { + slug: "123e4567-e89b-12d3-a456-426614174000", + name: "Test Mode", + roleDefinition: "Test role definition", + groups: ["not-a-valid-group"] as any, } - expect(() => CustomModeSchema.parse(mode)).toThrow() + + expect(() => validateCustomMode(invalidGroupMode)).toThrow(ZodError) + }) + + test("rejects empty groups array", () => { + const invalidMode = { + slug: "123e4567-e89b-12d3-a456-426614174000", + name: "Test Mode", + roleDefinition: "Test role definition", + groups: [] as const, + } satisfies ModeConfig + + expect(() => validateCustomMode(invalidMode)).toThrow("At least one tool group is required") + }) + + test("handles null and undefined gracefully", () => { + expect(() => validateCustomMode(null)).toThrow(ZodError) + expect(() => validateCustomMode(undefined)).toThrow(ZodError) + }) + + test("rejects non-object inputs", () => { + const invalidInputs = [42, "string", true, [], () => {}] + + invalidInputs.forEach((input) => { + expect(() => validateCustomMode(input)).toThrow(ZodError) + }) }) }) - it("prevents duplicate groups", () => { - const modeWithDuplicates = { - slug: "test", - name: "Test", - roleDefinition: "Test", - groups: ["read", "read", ["edit", { fileRegex: "\\.md$" }], ["edit", { fileRegex: "\\.txt$" }]], - } + describe("fileRegex", () => { + it("validates a mode with file restrictions and descriptions", () => { + const modeWithJustRegex = { + slug: "markdown-editor", + name: "Markdown Editor", + roleDefinition: "Markdown editing mode", + groups: ["read", ["edit", { fileRegex: "\\.md$" }], "browser"], + } - expect(() => CustomModeSchema.parse(modeWithDuplicates)).toThrow(/Duplicate groups/) - }) + const modeWithDescription = { + slug: "docs-editor", + name: "Documentation Editor", + roleDefinition: "Documentation editing mode", + groups: [ + "read", + ["edit", { fileRegex: "\\.(md|txt)$", fileRegexDescription: "Documentation files only" }], + "browser", + ], + } - it("requires at least one group", () => { - const modeWithNoGroups = { - slug: "test", - name: "Test", - roleDefinition: "Test", - groups: [], - } + expect(() => CustomModeSchema.parse(modeWithJustRegex)).not.toThrow() + expect(() => CustomModeSchema.parse(modeWithDescription)).not.toThrow() + }) - expect(() => CustomModeSchema.parse(modeWithNoGroups)).toThrow(/At least one tool group is required/) + it("validates file regex patterns", () => { + const validPatterns = ["\\.md$", ".*\\.txt$", "[a-z]+\\.js$"] + const invalidPatterns = ["[", "(unclosed", "\\"] + + validPatterns.forEach((pattern) => { + const mode = { + slug: "test", + name: "Test", + roleDefinition: "Test", + groups: ["read", ["edit", { fileRegex: pattern }]], + } + expect(() => CustomModeSchema.parse(mode)).not.toThrow() + }) + + invalidPatterns.forEach((pattern) => { + const mode = { + slug: "test", + name: "Test", + roleDefinition: "Test", + groups: ["read", ["edit", { fileRegex: pattern }]], + } + expect(() => CustomModeSchema.parse(mode)).toThrow() + }) + }) + + it("prevents duplicate groups", () => { + const modeWithDuplicates = { + slug: "test", + name: "Test", + roleDefinition: "Test", + groups: ["read", "read", ["edit", { fileRegex: "\\.md$" }], ["edit", { fileRegex: "\\.txt$" }]], + } + + expect(() => CustomModeSchema.parse(modeWithDuplicates)).toThrow(/Duplicate groups/) + }) + + it("requires at least one group", () => { + const modeWithNoGroups = { + slug: "test", + name: "Test", + roleDefinition: "Test", + groups: [], + } + + expect(() => CustomModeSchema.parse(modeWithNoGroups)).toThrow(/At least one tool group is required/) + }) }) }) diff --git a/src/core/prompts/sections/rules.ts b/src/core/prompts/sections/rules.ts index b0b3cff..cfb8a24 100644 --- a/src/core/prompts/sections/rules.ts +++ b/src/core/prompts/sections/rules.ts @@ -8,7 +8,6 @@ export function getRulesSection( supportsComputerUse: boolean, diffStrategy?: DiffStrategy, context?: vscode.ExtensionContext, - diffEnabled?: boolean, ): string { const settingsDir = context ? path.join(context.globalStorageUri.fsPath, "settings") : "" const customModesPath = path.join(settingsDir, "cline_custom_modes.json") diff --git a/src/shared/__tests__/modes.test.ts b/src/shared/__tests__/modes.test.ts index c438ba8..56e0fe8 100644 --- a/src/shared/__tests__/modes.test.ts +++ b/src/shared/__tests__/modes.test.ts @@ -84,6 +84,62 @@ describe("isToolAllowedForMode", () => { expect(diffError).toBeInstanceOf(FileRestrictionError) }) + it("uses description in file restriction error for custom modes", () => { + const customModesWithDescription: ModeConfig[] = [ + { + slug: "docs-editor", + name: "Documentation Editor", + roleDefinition: "You are a documentation editor", + groups: [ + "read", + ["edit", { fileRegex: "\\.(md|txt)$", description: "Documentation files only" }], + "browser", + ], + }, + ] + + // Test write_to_file with non-matching file + const writeError = isToolAllowedForMode( + "write_to_file", + "docs-editor", + customModesWithDescription, + undefined, + "test.js", + ) + expect(writeError).toBeInstanceOf(FileRestrictionError) + expect((writeError as FileRestrictionError).message).toContain("Documentation files only") + + // Test apply_diff with non-matching file + const diffError = isToolAllowedForMode( + "apply_diff", + "docs-editor", + customModesWithDescription, + undefined, + "test.js", + ) + expect(diffError).toBeInstanceOf(FileRestrictionError) + expect((diffError as FileRestrictionError).message).toContain("Documentation files only") + + // Test that matching files are allowed + const mdResult = isToolAllowedForMode( + "write_to_file", + "docs-editor", + customModesWithDescription, + undefined, + "test.md", + ) + expect(mdResult).toBe(true) + + const txtResult = isToolAllowedForMode( + "write_to_file", + "docs-editor", + customModesWithDescription, + undefined, + "test.txt", + ) + expect(txtResult).toBe(true) + }) + it("allows ask mode to edit markdown files only", () => { // Should allow editing markdown files const mdResult = isToolAllowedForMode("write_to_file", "ask", [], undefined, "test.md")