From cfc8a556cf9f2da69d015ddeb122694f2e697c47 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 27 Feb 2026 21:32:38 +0000 Subject: [PATCH 1/4] feat: add correct JSON schema for .roomodes configuration files Adds schemas/roomodes.json that accurately reflects the actual .roomodes format as defined in packages/types/src/mode.ts. The schema supports: - All CustomMode properties including optional description and source - Tuple-style group entries like ["edit", { fileRegex, description }] - All five tool groups: read, edit, command, mcp, modes Also adds comprehensive tests validating the schema against valid and invalid configurations. Closes #11790 --- pnpm-lock.yaml | 33 ++- schemas/roomodes.json | 99 +++++++ src/package.json | 1 + src/utils/__tests__/roomodes-schema.spec.ts | 298 ++++++++++++++++++++ 4 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 schemas/roomodes.json create mode 100644 src/utils/__tests__/roomodes-schema.spec.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d95c2f02346..722a6028c02 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 00000000000..14e6b53b4b5 --- /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 241312ac8c6..46b554d5b0d 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 00000000000..6e410a40bc8 --- /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) + }) +}) From d399085cafa374556f645c51c6410cc64c336cc6 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 27 Feb 2026 22:07:39 +0000 Subject: [PATCH 2/4] fix: add browser tool group and rulesFiles property to roomodes schema - Add "browser" to ToolGroup enum (deprecated but accepted for backward compat) - Add RuleFile definition with relativePath and content properties - Add optional rulesFiles array property to CustomMode - Add 6 new tests covering browser groups and rulesFiles validation --- schemas/roomodes.json | 27 ++++- src/utils/__tests__/roomodes-schema.spec.ts | 113 ++++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/schemas/roomodes.json b/schemas/roomodes.json index 14e6b53b4b5..90b6f55fa0b 100644 --- a/schemas/roomodes.json +++ b/schemas/roomodes.json @@ -18,8 +18,8 @@ "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." + "enum": ["read", "edit", "browser", "command", "mcp", "modes"], + "description": "A tool group name that grants the mode access to a set of tools. Note: 'browser' is deprecated but still accepted for backward compatibility." }, "GroupOptions": { "type": "object", @@ -48,6 +48,22 @@ "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" }] }, + "RuleFile": { + "type": "object", + "description": "A rules file associated with a mode, used during import/export.", + "required": ["relativePath", "content"], + "additionalProperties": false, + "properties": { + "relativePath": { + "type": "string", + "description": "The relative file path for the rules file." + }, + "content": { + "type": "string", + "description": "The text content of the rules file." + } + } + }, "CustomMode": { "type": "object", "description": "A custom mode definition.", @@ -92,6 +108,13 @@ "type": "string", "enum": ["global", "project"], "description": "Where this mode was defined. Automatically set by Roo Code." + }, + "rulesFiles": { + "type": "array", + "description": "Rules files associated with this mode, used during import/export.", + "items": { + "$ref": "#/definitions/RuleFile" + } } } } diff --git a/src/utils/__tests__/roomodes-schema.spec.ts b/src/utils/__tests__/roomodes-schema.spec.ts index 6e410a40bc8..52784ee4304 100644 --- a/src/utils/__tests__/roomodes-schema.spec.ts +++ b/src/utils/__tests__/roomodes-schema.spec.ts @@ -273,6 +273,119 @@ describe("roomodes JSON schema", () => { expect(valid).toBe(true) }) + it("should accept the browser tool group (deprecated but valid)", () => { + const config = { + customModes: [ + { + slug: "browser-mode", + name: "Browser Mode", + roleDefinition: "A mode that uses the browser tool group.", + groups: ["read", "browser", "command"], + }, + ], + } + + const valid = validate(config) + expect(validate.errors).toBeNull() + expect(valid).toBe(true) + }) + + it("should accept a browser tuple group entry", () => { + const config = { + customModes: [ + { + slug: "browser-tuple", + name: "Browser Tuple", + roleDefinition: "A mode with browser tuple.", + groups: [["browser", { fileRegex: "\\.html$", description: "HTML files only" }]], + }, + ], + } + + const valid = validate(config) + expect(validate.errors).toBeNull() + expect(valid).toBe(true) + }) + + it("should accept a mode with rulesFiles", () => { + const config = { + customModes: [ + { + slug: "rules-mode", + name: "Rules Mode", + roleDefinition: "A mode with rules files.", + groups: ["read"], + rulesFiles: [ + { + relativePath: "rule1.md", + content: "# Rule 1\nFollow this rule.", + }, + { + relativePath: "subfolder/rule2.md", + content: "# Rule 2\nFollow this other rule.", + }, + ], + }, + ], + } + + const valid = validate(config) + expect(validate.errors).toBeNull() + expect(valid).toBe(true) + }) + + it("should accept a mode with empty rulesFiles array", () => { + const config = { + customModes: [ + { + slug: "empty-rules", + name: "Empty Rules", + roleDefinition: "A mode with empty rules files.", + groups: ["read"], + rulesFiles: [], + }, + ], + } + + const valid = validate(config) + expect(validate.errors).toBeNull() + expect(valid).toBe(true) + }) + + it("should reject rulesFiles entries missing required fields", () => { + const config = { + customModes: [ + { + slug: "bad-rules", + name: "Bad Rules", + roleDefinition: "A mode with invalid rules files.", + groups: ["read"], + rulesFiles: [{ relativePath: "rule1.md" }], + }, + ], + } + + const valid = validate(config) + expect(valid).toBe(false) + }) + + it("should reject rulesFiles entries with extra properties", () => { + const config = { + customModes: [ + { + slug: "extra-rules", + name: "Extra Rules", + roleDefinition: "A mode with extra rule properties.", + groups: ["read"], + rulesFiles: [{ relativePath: "rule1.md", content: "content", extra: true }], + }, + ], + } + + const valid = validate(config) + expect(valid).toBe(false) + }) + it("should accept multiple modes", () => { const config = { customModes: [ From 70db387a699a5acabdca7d659547a006b1871896 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 27 Feb 2026 22:14:43 +0000 Subject: [PATCH 3/4] fix: make content optional in RuleFile schema definition The content property in RuleFile is now optional, matching the defensive runtime check in CustomModesManager that handles missing content. Only relativePath remains required. --- schemas/roomodes.json | 2 +- src/utils/__tests__/roomodes-schema.spec.ts | 22 +++++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/schemas/roomodes.json b/schemas/roomodes.json index 90b6f55fa0b..593382f6930 100644 --- a/schemas/roomodes.json +++ b/schemas/roomodes.json @@ -51,7 +51,7 @@ "RuleFile": { "type": "object", "description": "A rules file associated with a mode, used during import/export.", - "required": ["relativePath", "content"], + "required": ["relativePath"], "additionalProperties": false, "properties": { "relativePath": { diff --git a/src/utils/__tests__/roomodes-schema.spec.ts b/src/utils/__tests__/roomodes-schema.spec.ts index 52784ee4304..335e400645a 100644 --- a/src/utils/__tests__/roomodes-schema.spec.ts +++ b/src/utils/__tests__/roomodes-schema.spec.ts @@ -352,7 +352,25 @@ describe("roomodes JSON schema", () => { expect(valid).toBe(true) }) - it("should reject rulesFiles entries missing required fields", () => { + it("should accept rulesFiles entries with only relativePath (content is optional)", () => { + const config = { + customModes: [ + { + slug: "path-only-rules", + name: "Path Only Rules", + roleDefinition: "A mode with rules files that only have relativePath.", + groups: ["read"], + rulesFiles: [{ relativePath: "rule1.md" }], + }, + ], + } + + const valid = validate(config) + expect(validate.errors).toBeNull() + expect(valid).toBe(true) + }) + + it("should reject rulesFiles entries missing required relativePath", () => { const config = { customModes: [ { @@ -360,7 +378,7 @@ describe("roomodes JSON schema", () => { name: "Bad Rules", roleDefinition: "A mode with invalid rules files.", groups: ["read"], - rulesFiles: [{ relativePath: "rule1.md" }], + rulesFiles: [{ content: "some content" }], }, ], } From 3fd2ca47c8c9ffda7e170af21f6712dd8669c126 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 2 Mar 2026 15:31:19 +0000 Subject: [PATCH 4/4] refactor: dynamically generate roomodes JSON schema from Zod types Replace the hand-crafted schemas/roomodes.json with one generated from the Zod schemas in packages/types/src/mode.ts using zod-to-json-schema. This ensures the schema stays in sync when TypeScript types change. - Add zod-to-json-schema dev dependency to packages/types - Create packages/types/scripts/generate-roomodes-schema.ts - Add generate:schema npm script to packages/types - Add drift-detection test in packages/types to catch schema/type mismatches - Update existing AJV validation tests with documentation comment --- packages/types/package.json | 6 +- .../types/scripts/generate-roomodes-schema.ts | 83 ++++++++ .../__tests__/roomodes-schema-sync.spec.ts | 54 +++++ pnpm-lock.yaml | 14 +- schemas/roomodes.json | 196 ++++++++---------- src/utils/__tests__/roomodes-schema.spec.ts | 8 + 6 files changed, 247 insertions(+), 114 deletions(-) create mode 100644 packages/types/scripts/generate-roomodes-schema.ts create mode 100644 packages/types/src/__tests__/roomodes-schema-sync.spec.ts diff --git a/packages/types/package.json b/packages/types/package.json index d66d87ac72d..538268df471 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -20,7 +20,8 @@ "build": "tsup", "build:watch": "tsup --watch --outDir npm/dist --onSuccess 'echo ✅ Types rebuilt to npm/dist'", "npm:publish": "node scripts/publish-npm.cjs", - "clean": "rimraf dist .turbo" + "clean": "rimraf dist .turbo", + "generate:schema": "tsx scripts/generate-roomodes-schema.ts" }, "dependencies": { "zod": "3.25.76" @@ -31,6 +32,7 @@ "@types/node": "^24.1.0", "globals": "^16.3.0", "tsup": "^8.4.0", - "vitest": "^3.2.3" + "vitest": "^3.2.3", + "zod-to-json-schema": "^3.25.1" } } diff --git a/packages/types/scripts/generate-roomodes-schema.ts b/packages/types/scripts/generate-roomodes-schema.ts new file mode 100644 index 00000000000..ce22957504c --- /dev/null +++ b/packages/types/scripts/generate-roomodes-schema.ts @@ -0,0 +1,83 @@ +/** + * Generates the JSON Schema for .roomodes configuration files from the Zod + * schemas defined in packages/types/src/mode.ts. + * + * This ensures the schema stays in sync with the TypeScript types. Run via: + * pnpm --filter @roo-code/types generate:schema + * + * The output is written to schemas/roomodes.json at the repository root. + */ + +import * as fs from "fs" +import * as path from "path" +import { fileURLToPath } from "url" +import { zodToJsonSchema } from "zod-to-json-schema" +import { z } from "zod" + +import { toolGroups, deprecatedToolGroups } from "../src/tool.js" +import { groupOptionsSchema, modeConfigSchema } from "../src/mode.js" + +// --------------------------------------------------------------------------- +// 1. Build a ToolGroup enum that includes deprecated groups so existing +// configs still validate. +// --------------------------------------------------------------------------- +const allToolGroups = [...toolGroups, ...deprecatedToolGroups] as [string, ...string[]] +const allToolGroupsSchema = z.enum(allToolGroups) + +// --------------------------------------------------------------------------- +// 2. Build a GroupEntry schema that uses the extended tool group list. +// --------------------------------------------------------------------------- +const groupEntrySchema = z.union([allToolGroupsSchema, z.tuple([allToolGroupsSchema, groupOptionsSchema])]) + +// --------------------------------------------------------------------------- +// 3. Build the RuleFile schema (used during import/export but not part of +// the core Zod types). +// --------------------------------------------------------------------------- +const ruleFileSchema = z.object({ + relativePath: z.string(), + content: z.string().optional(), +}) + +// --------------------------------------------------------------------------- +// 4. Build an extended ModeConfig schema that includes rulesFiles and uses +// the extended groups (with deprecated entries). +// --------------------------------------------------------------------------- +const exportedModeConfigSchema = modeConfigSchema.omit({ groups: true }).extend({ + groups: z.array(groupEntrySchema), + rulesFiles: z.array(ruleFileSchema).optional(), +}) + +// --------------------------------------------------------------------------- +// 5. Build the top-level .roomodes schema. +// --------------------------------------------------------------------------- +const roomodesSchema = z + .object({ + customModes: z.array(exportedModeConfigSchema), + }) + .strict() + +// --------------------------------------------------------------------------- +// 6. Convert to JSON Schema (draft-07). +// --------------------------------------------------------------------------- +const jsonSchema = zodToJsonSchema(roomodesSchema, { + $refStrategy: "none", + target: "jsonSchema7", +}) as Record + +// --------------------------------------------------------------------------- +// 7. Add metadata. +// --------------------------------------------------------------------------- +jsonSchema["$id"] = "https://github.com/RooCodeInc/Roo-Code/blob/main/schemas/roomodes.json" +jsonSchema["title"] = "Roo Code Custom Modes" +jsonSchema["description"] = "Schema for .roomodes configuration files used by Roo Code to define custom modes." + +// --------------------------------------------------------------------------- +// 8. Write to disk. +// --------------------------------------------------------------------------- +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(__dirname, "../../..") +const outPath = path.join(repoRoot, "schemas", "roomodes.json") +fs.mkdirSync(path.dirname(outPath), { recursive: true }) +fs.writeFileSync(outPath, JSON.stringify(jsonSchema, null, "\t") + "\n", "utf-8") + +console.log(`Generated ${path.relative(repoRoot, outPath)}`) diff --git a/packages/types/src/__tests__/roomodes-schema-sync.spec.ts b/packages/types/src/__tests__/roomodes-schema-sync.spec.ts new file mode 100644 index 00000000000..1347bb49b56 --- /dev/null +++ b/packages/types/src/__tests__/roomodes-schema-sync.spec.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest" +import * as fs from "fs" +import * as path from "path" +import { fileURLToPath } from "url" +import { zodToJsonSchema } from "zod-to-json-schema" +import { z } from "zod" + +import { toolGroups, deprecatedToolGroups } from "../tool.js" +import { groupOptionsSchema, modeConfigSchema } from "../mode.js" + +/** + * This test verifies that the checked-in schemas/roomodes.json matches what + * would be generated from the current Zod schemas. If this test fails, run: + * + * pnpm --filter @roo-code/types generate:schema + * + * to regenerate the schema file. + */ +describe("roomodes schema sync", () => { + it("should match the dynamically generated schema from Zod types", () => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const schemaPath = path.resolve(__dirname, "../../../../schemas/roomodes.json") + const checkedIn = JSON.parse(fs.readFileSync(schemaPath, "utf-8")) + + // Reproduce the same generation logic as scripts/generate-roomodes-schema.ts + const allToolGroups = [...toolGroups, ...deprecatedToolGroups] as [string, ...string[]] + const allToolGroupsSchema = z.enum(allToolGroups) + const groupEntrySchema = z.union([allToolGroupsSchema, z.tuple([allToolGroupsSchema, groupOptionsSchema])]) + const ruleFileSchema = z.object({ + relativePath: z.string(), + content: z.string().optional(), + }) + const exportedModeConfigSchema = modeConfigSchema.omit({ groups: true }).extend({ + groups: z.array(groupEntrySchema), + rulesFiles: z.array(ruleFileSchema).optional(), + }) + const roomodesSchema = z + .object({ + customModes: z.array(exportedModeConfigSchema), + }) + .strict() + + const generated = zodToJsonSchema(roomodesSchema, { + $refStrategy: "none", + target: "jsonSchema7", + }) as Record + + generated["$id"] = "https://github.com/RooCodeInc/Roo-Code/blob/main/schemas/roomodes.json" + generated["title"] = "Roo Code Custom Modes" + generated["description"] = "Schema for .roomodes configuration files used by Roo Code to define custom modes." + + expect(checkedIn).toEqual(generated) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 722a6028c02..17c9cc12fd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -731,6 +731,9 @@ importers: vitest: specifier: ^3.2.3 version: 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) + zod-to-json-schema: + specifier: ^3.25.1 + version: 3.25.1(zod@3.25.76) packages/vscode-shim: devDependencies: @@ -11020,6 +11023,11 @@ packages: peerDependencies: zod: 3.25.76 + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: 3.25.76 + zod-to-ts@1.2.0: resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} peerDependencies: @@ -14992,7 +15000,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@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: 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/utils@3.2.4': dependencies: @@ -22298,6 +22306,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod-to-ts@1.2.0(typescript@5.8.3)(zod@3.25.76): dependencies: typescript: 5.8.3 diff --git a/schemas/roomodes.json b/schemas/roomodes.json index 593382f6930..2b4349afcbc 100644 --- a/schemas/roomodes.json +++ b/schemas/roomodes.json @@ -1,122 +1,96 @@ { - "$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", "browser", "command", "mcp", "modes"], - "description": "A tool group name that grants the mode access to a set of tools. Note: 'browser' is deprecated but still accepted for backward compatibility." - }, - "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" }] - }, - "RuleFile": { - "type": "object", - "description": "A rules file associated with a mode, used during import/export.", - "required": ["relativePath"], - "additionalProperties": false, - "properties": { - "relativePath": { - "type": "string", - "description": "The relative file path for the rules file." - }, - "content": { - "type": "string", - "description": "The text content of the rules file." - } - } - }, - "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" + "type": "object", + "properties": { + "slug": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "roleDefinition": { + "type": "string", + "minLength": 1 + }, + "whenToUse": { + "type": "string" + }, + "description": { + "type": "string" + }, + "customInstructions": { + "type": "string" + }, + "source": { + "type": "string", + "enum": ["global", "project"] + }, + "groups": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "enum": ["read", "edit", "command", "mcp", "modes", "browser"] + }, + { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": [ + { + "type": "string", + "enum": ["read", "edit", "command", "mcp", "modes", "browser"] + }, + { + "type": "object", + "properties": { + "fileRegex": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + } + ] + } + }, + "rulesFiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "relativePath": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "required": ["relativePath"], + "additionalProperties": false + } } }, - "source": { - "type": "string", - "enum": ["global", "project"], - "description": "Where this mode was defined. Automatically set by Roo Code." - }, - "rulesFiles": { - "type": "array", - "description": "Rules files associated with this mode, used during import/export.", - "items": { - "$ref": "#/definitions/RuleFile" - } - } + "required": ["slug", "name", "roleDefinition", "groups"], + "additionalProperties": false } } - } + }, + "required": ["customModes"], + "additionalProperties": false, + "$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." } diff --git a/src/utils/__tests__/roomodes-schema.spec.ts b/src/utils/__tests__/roomodes-schema.spec.ts index 335e400645a..2b20dcb41da 100644 --- a/src/utils/__tests__/roomodes-schema.spec.ts +++ b/src/utils/__tests__/roomodes-schema.spec.ts @@ -1,3 +1,11 @@ +/** + * Validates the generated schemas/roomodes.json against sample configurations + * using AJV. The schema itself is dynamically generated from the Zod types in + * packages/types/src/mode.ts -- see packages/types/scripts/generate-roomodes-schema.ts. + * + * A separate drift-detection test in packages/types ensures the checked-in + * schema stays in sync with the Zod source of truth. + */ import { describe, it, expect, beforeAll } from "vitest" import Ajv from "ajv" import * as fs from "fs"