diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d95c2f0234..722a6028c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1101,6 +1101,9 @@ importers: ai: specifier: ^6.0.75 version: 6.0.77(zod@3.25.76) + ajv: + specifier: ^8.18.0 + version: 8.18.0 esbuild-wasm: specifier: ^0.25.0 version: 0.25.12 @@ -4895,6 +4898,9 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -5130,6 +5136,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.0, please upgrade better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} @@ -6577,6 +6584,9 @@ packages: fast-shallow-equal@1.0.0: resolution: {integrity: sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-parser@5.2.3: resolution: {integrity: sha512-OdCYfRqfpuLUFonTNjvd30rCBZUneHpSQkCqfaeWQ9qrKcl6XlWeDBNVwGb+INAIxRshuN2jF+BE0L6gbBO2mw==} hasBin: true @@ -7605,6 +7615,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -8976,6 +8989,7 @@ packages: prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prelude-ls@1.2.1: @@ -9386,6 +9400,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} @@ -14974,7 +14992,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -15144,6 +15162,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-escapes@7.0.0: @@ -16986,6 +17011,8 @@ snapshots: fast-shallow-equal@1.0.0: {} + fast-uri@3.1.0: {} + fast-xml-parser@5.2.3: dependencies: strnum: 2.1.1 @@ -18115,6 +18142,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -20328,6 +20357,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resize-observer-polyfill@1.5.1: {} resolve-from@4.0.0: {} diff --git a/schemas/roomodes.json b/schemas/roomodes.json new file mode 100644 index 0000000000..14e6b53b4b --- /dev/null +++ b/schemas/roomodes.json @@ -0,0 +1,99 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/RooCodeInc/Roo-Code/blob/main/schemas/roomodes.json", + "title": "Roo Code Custom Modes", + "description": "Schema for .roomodes configuration files used by Roo Code to define custom modes.", + "type": "object", + "required": ["customModes"], + "additionalProperties": false, + "properties": { + "customModes": { + "type": "array", + "description": "List of custom mode definitions.", + "items": { + "$ref": "#/definitions/CustomMode" + } + } + }, + "definitions": { + "ToolGroup": { + "type": "string", + "enum": ["read", "edit", "command", "mcp", "modes"], + "description": "A tool group name that grants the mode access to a set of tools." + }, + "GroupOptions": { + "type": "object", + "description": "Options that restrict a tool group's file access.", + "properties": { + "fileRegex": { + "type": "string", + "description": "A regular expression pattern to restrict which files the tool group can access." + }, + "description": { + "type": "string", + "description": "A human-readable description of the file restriction." + } + }, + "additionalProperties": false + }, + "GroupEntryTuple": { + "type": "array", + "description": "A tuple of [toolGroupName, options] for tool groups with file restrictions.", + "items": [{ "$ref": "#/definitions/ToolGroup" }, { "$ref": "#/definitions/GroupOptions" }], + "additionalItems": false, + "minItems": 2, + "maxItems": 2 + }, + "GroupEntry": { + "description": "A tool group permission entry. Either a simple tool group name string, or a [toolGroupName, options] tuple for groups with file restrictions.", + "oneOf": [{ "$ref": "#/definitions/ToolGroup" }, { "$ref": "#/definitions/GroupEntryTuple" }] + }, + "CustomMode": { + "type": "object", + "description": "A custom mode definition.", + "required": ["slug", "name", "roleDefinition", "groups"], + "additionalProperties": false, + "properties": { + "slug": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$", + "description": "A unique identifier for the mode, containing only letters, numbers, and dashes." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "The display name of the mode." + }, + "roleDefinition": { + "type": "string", + "minLength": 1, + "description": "The system prompt that defines the mode's role and behavior." + }, + "whenToUse": { + "type": "string", + "description": "A description of when this mode should be used, shown in the mode selection UI." + }, + "description": { + "type": "string", + "description": "A short description of the mode." + }, + "customInstructions": { + "type": "string", + "description": "Additional instructions appended to the system prompt." + }, + "groups": { + "type": "array", + "description": "The tool groups this mode has access to.", + "items": { + "$ref": "#/definitions/GroupEntry" + } + }, + "source": { + "type": "string", + "enum": ["global", "project"], + "description": "Where this mode was defined. Automatically set by Roo Code." + } + } + } + } +} diff --git a/src/package.json b/src/package.json index 241312ac8c..46b554d5b0 100644 --- a/src/package.json +++ b/src/package.json @@ -567,6 +567,7 @@ "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "3.3.2", "ai": "^6.0.75", + "ajv": "^8.18.0", "esbuild-wasm": "^0.25.0", "execa": "^9.5.2", "glob": "^11.1.0", diff --git a/src/utils/__tests__/roomodes-schema.spec.ts b/src/utils/__tests__/roomodes-schema.spec.ts new file mode 100644 index 0000000000..6e410a40bc --- /dev/null +++ b/src/utils/__tests__/roomodes-schema.spec.ts @@ -0,0 +1,298 @@ +import { describe, it, expect, beforeAll } from "vitest" +import Ajv from "ajv" +import * as fs from "fs" +import * as path from "path" + +describe("roomodes JSON schema", () => { + let ajv: Ajv + let schema: Record + let validate: ReturnType + + beforeAll(() => { + const schemaPath = path.resolve(__dirname, "../../../schemas/roomodes.json") + schema = JSON.parse(fs.readFileSync(schemaPath, "utf-8")) + ajv = new Ajv({ strict: false }) + validate = ajv.compile(schema) + }) + + it("should be a valid JSON Schema", () => { + expect(validate).toBeDefined() + }) + + it("should accept a minimal valid .roomodes config", () => { + const config = { + customModes: [ + { + slug: "my-mode", + name: "My Mode", + roleDefinition: "You are a helpful assistant.", + groups: ["read"], + }, + ], + } + + const valid = validate(config) + expect(validate.errors).toBeNull() + expect(valid).toBe(true) + }) + + it("should accept a mode with all optional properties", () => { + const config = { + customModes: [ + { + slug: "full-mode", + name: "Full Mode", + roleDefinition: "A complete mode definition.", + whenToUse: "Use when you need everything.", + description: "A mode with all properties.", + customInstructions: "Follow these additional rules.", + groups: ["read", "edit", "command", "mcp"], + source: "project", + }, + ], + } + + const valid = validate(config) + expect(validate.errors).toBeNull() + expect(valid).toBe(true) + }) + + it("should accept the built-in architect mode with tuple-style edit group", () => { + const config = { + customModes: [ + { + slug: "architect", + name: "Architect", + roleDefinition: "You are an experienced technical leader.", + whenToUse: "Use this mode when you need to plan.", + description: "Plan and design before implementation", + groups: ["read", ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }], "mcp"], + source: "project", + }, + ], + } + + const valid = validate(config) + expect(validate.errors).toBeNull() + expect(valid).toBe(true) + }) + + it("should accept a tuple group entry with only fileRegex", () => { + const config = { + customModes: [ + { + slug: "restricted", + name: "Restricted", + roleDefinition: "Limited editor.", + groups: [["edit", { fileRegex: "\\.ts$" }]], + }, + ], + } + + const valid = validate(config) + expect(validate.errors).toBeNull() + expect(valid).toBe(true) + }) + + it("should accept a tuple group entry with empty options", () => { + const config = { + customModes: [ + { + slug: "empty-opts", + name: "Empty Options", + roleDefinition: "Mode with empty group options.", + groups: [["edit", {}]], + }, + ], + } + + const valid = validate(config) + expect(validate.errors).toBeNull() + expect(valid).toBe(true) + }) + + it("should accept the modes tool group", () => { + const config = { + customModes: [ + { + slug: "orchestrator", + name: "Orchestrator", + roleDefinition: "You orchestrate other modes.", + groups: ["read", "modes"], + }, + ], + } + + const valid = validate(config) + expect(validate.errors).toBeNull() + expect(valid).toBe(true) + }) + + it("should reject a config missing customModes", () => { + const config = {} + + const valid = validate(config) + expect(valid).toBe(false) + }) + + it("should reject a mode missing required slug", () => { + const config = { + customModes: [ + { + name: "No Slug", + roleDefinition: "Missing slug.", + groups: ["read"], + }, + ], + } + + const valid = validate(config) + expect(valid).toBe(false) + }) + + it("should reject a mode missing required groups", () => { + const config = { + customModes: [ + { + slug: "no-groups", + name: "No Groups", + roleDefinition: "Missing groups.", + }, + ], + } + + const valid = validate(config) + expect(valid).toBe(false) + }) + + it("should reject a slug with invalid characters", () => { + const config = { + customModes: [ + { + slug: "invalid slug!", + name: "Bad Slug", + roleDefinition: "Invalid slug characters.", + groups: ["read"], + }, + ], + } + + const valid = validate(config) + expect(valid).toBe(false) + }) + + it("should reject an invalid tool group name", () => { + const config = { + customModes: [ + { + slug: "bad-group", + name: "Bad Group", + roleDefinition: "Invalid group name.", + groups: ["nonexistent"], + }, + ], + } + + const valid = validate(config) + expect(valid).toBe(false) + }) + + it("should reject additional properties on CustomMode", () => { + const config = { + customModes: [ + { + slug: "extra-props", + name: "Extra Props", + roleDefinition: "Has extra properties.", + groups: ["read"], + unknownField: true, + }, + ], + } + + const valid = validate(config) + expect(valid).toBe(false) + }) + + it("should reject additional properties on GroupOptions", () => { + const config = { + customModes: [ + { + slug: "bad-opts", + name: "Bad Options", + roleDefinition: "Extra options properties.", + groups: [["edit", { fileRegex: "\\.md$", unknownOpt: true }]], + }, + ], + } + + const valid = validate(config) + expect(valid).toBe(false) + }) + + it("should reject a tuple with more than 2 elements", () => { + const config = { + customModes: [ + { + slug: "big-tuple", + name: "Big Tuple", + roleDefinition: "Too many tuple elements.", + groups: [["edit", { fileRegex: "\\.md$" }, "extra"]], + }, + ], + } + + const valid = validate(config) + expect(valid).toBe(false) + }) + + it("should reject an invalid source value", () => { + const config = { + customModes: [ + { + slug: "bad-source", + name: "Bad Source", + roleDefinition: "Invalid source.", + groups: ["read"], + source: "unknown", + }, + ], + } + + const valid = validate(config) + expect(valid).toBe(false) + }) + + it("should accept an empty customModes array", () => { + const config = { + customModes: [], + } + + const valid = validate(config) + expect(validate.errors).toBeNull() + expect(valid).toBe(true) + }) + + it("should accept multiple modes", () => { + const config = { + customModes: [ + { + slug: "mode-a", + name: "Mode A", + roleDefinition: "First mode.", + groups: ["read"], + }, + { + slug: "mode-b", + name: "Mode B", + roleDefinition: "Second mode.", + groups: ["read", "edit"], + }, + ], + } + + const valid = validate(config) + expect(validate.errors).toBeNull() + expect(valid).toBe(true) + }) +})