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
This commit is contained in:
Roo Code 2026-02-27 21:32:38 +00:00
parent 193053110b
commit cfc8a556cf
4 changed files with 430 additions and 1 deletions

33
pnpm-lock.yaml generated
View file

@ -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: {}

99
schemas/roomodes.json Normal file
View file

@ -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."
}
}
}
}
}

View file

@ -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",

View file

@ -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<string, unknown>
let validate: ReturnType<Ajv["compile"]>
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)
})
})