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 d95c2f02346..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: @@ -1101,6 +1104,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 +4901,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 +5139,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 +6587,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 +7618,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 +8992,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 +9403,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==} @@ -11002,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: @@ -15144,6 +15170,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 +17019,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 +18150,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 +20365,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resize-observer-polyfill@1.5.1: {} resolve-from@4.0.0: {} @@ -22267,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 new file mode 100644 index 00000000000..2b4349afcbc --- /dev/null +++ b/schemas/roomodes.json @@ -0,0 +1,96 @@ +{ + "type": "object", + "properties": { + "customModes": { + "type": "array", + "items": { + "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 + } + } + }, + "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/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..2b20dcb41da --- /dev/null +++ b/src/utils/__tests__/roomodes-schema.spec.ts @@ -0,0 +1,437 @@ +/** + * 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" +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 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 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: [ + { + slug: "bad-rules", + name: "Bad Rules", + roleDefinition: "A mode with invalid rules files.", + groups: ["read"], + rulesFiles: [{ content: "some content" }], + }, + ], + } + + 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: [ + { + 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) + }) +})