diff --git a/.gitignore b/.gitignore index 8935aa0f7..1f1c8ee8c 100644 --- a/.gitignore +++ b/.gitignore @@ -303,7 +303,7 @@ rulesync.local.jsonc **/.kilo/skills/ **/.kilo/workflows/ **/.kilo/mcp.json -**/.kiloignore +**/.kilocodeignore **/.kiro/steering/ **/.kiro/prompts/ **/.kiro/skills/ diff --git a/cspell.json b/cspell.json index 09a5de211..2ea2787df 100644 --- a/cspell.json +++ b/cspell.json @@ -149,6 +149,7 @@ "kimuson", "kilo", "Kilo", + "kilocodeignore", "kiloignore", "kilorules", "kiro", diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index c3f8207a6..3211588f8 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -219,6 +219,8 @@ takt: # takt specific parameters (optional; emitted under .takt/facets/instructi name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") pi: # pi coding agent specific parameters (optional) argument-hint: "[message]" # Hint shown in Pi's command palette +roo: # Roo Code specific parameters (optional) + mode: "architect" # (optional) mode slug to switch to before running the command body (e.g. "code", "architect") --- target_pr = $ARGUMENTS @@ -364,6 +366,11 @@ agentsskills: # for the Agent Skills standard target (optional; supports project copilot: # for GitHub Copilot-specific parameters (optional) license: MIT # (optional) allowed-tools: "shell" # (optional) tools pre-approved without per-use confirmation +rovodev: # for Rovo Dev CLI-specific parameters (optional; Agent Skills standard) + allowed-tools: "grep bash" # (optional) space-separated string (a YAML list is also accepted) + license: MIT # (optional) + metadata: # (optional) free-form metadata + author: rulesync zed: # for Zed-specific parameters (optional) disable-model-invocation: true # (optional) prevent the model from auto-invoking this skill cursor: # for Cursor-specific parameters (optional) diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index c3f8207a6..3211588f8 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -219,6 +219,8 @@ takt: # takt specific parameters (optional; emitted under .takt/facets/instructi name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") pi: # pi coding agent specific parameters (optional) argument-hint: "[message]" # Hint shown in Pi's command palette +roo: # Roo Code specific parameters (optional) + mode: "architect" # (optional) mode slug to switch to before running the command body (e.g. "code", "architect") --- target_pr = $ARGUMENTS @@ -364,6 +366,11 @@ agentsskills: # for the Agent Skills standard target (optional; supports project copilot: # for GitHub Copilot-specific parameters (optional) license: MIT # (optional) allowed-tools: "shell" # (optional) tools pre-approved without per-use confirmation +rovodev: # for Rovo Dev CLI-specific parameters (optional; Agent Skills standard) + allowed-tools: "grep bash" # (optional) space-separated string (a YAML list is also accepted) + license: MIT # (optional) + metadata: # (optional) free-form metadata + author: rulesync zed: # for Zed-specific parameters (optional) disable-model-invocation: true # (optional) prevent the model from auto-invoking this skill cursor: # for Cursor-specific parameters (optional) diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index 29d60152d..1c021c290 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -222,7 +222,7 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ { target: "kilo", feature: "skills", entry: "**/.kilo/skills/" }, { target: "kilo", feature: "commands", entry: "**/.kilo/workflows/" }, { target: "kilo", feature: "mcp", entry: "**/.kilo/mcp.json" }, - { target: "kilo", feature: "ignore", entry: "**/.kiloignore" }, + { target: "kilo", feature: "ignore", entry: "**/.kilocodeignore" }, // No `**/kilo.jsonc` entry: structurally identical to `opencode.jsonc` (no // entry). The Kilo translator preserves non-permissions Kilo settings on // round-trip, so the file is intended to be checked in by the user — adding diff --git a/src/e2e/e2e-ignore.spec.ts b/src/e2e/e2e-ignore.spec.ts index 2e561deab..97bba0a18 100644 --- a/src/e2e/e2e-ignore.spec.ts +++ b/src/e2e/e2e-ignore.spec.ts @@ -19,7 +19,7 @@ describe("E2E: ignore", () => { { target: "geminicli", outputPath: ".geminiignore", format: "plaintext" as const }, { target: "goose", outputPath: ".gooseignore", format: "plaintext" as const }, { target: "cline", outputPath: ".clineignore", format: "plaintext" as const }, - { target: "kilo", outputPath: ".kiloignore", format: "plaintext" as const }, + { target: "kilo", outputPath: ".kilocodeignore", format: "plaintext" as const }, { target: "roo", outputPath: ".rooignore", format: "plaintext" as const }, { target: "qwencode", outputPath: ".geminiignore", format: "plaintext" as const }, { target: "kiro", outputPath: ".aiignore", format: "plaintext" as const }, @@ -72,7 +72,7 @@ credentials/ { target: "geminicli", orphanPath: ".geminiignore" }, { target: "goose", orphanPath: ".gooseignore" }, { target: "cline", orphanPath: ".clineignore" }, - { target: "kilo", orphanPath: ".kiloignore" }, + { target: "kilo", orphanPath: ".kilocodeignore" }, { target: "roo", orphanPath: ".rooignore" }, { target: "qwencode", orphanPath: ".geminiignore" }, { target: "kiro", orphanPath: ".aiignore" }, @@ -136,7 +136,7 @@ describe("E2E: ignore (import)", () => { { target: "geminicli", sourcePath: ".geminiignore" }, { target: "goose", sourcePath: ".gooseignore" }, { target: "cline", sourcePath: ".clineignore" }, - { target: "kilo", sourcePath: ".kiloignore" }, + { target: "kilo", sourcePath: ".kilocodeignore" }, { target: "roo", sourcePath: ".rooignore" }, { target: "qwencode", sourcePath: ".geminiignore" }, { target: "kiro", sourcePath: ".aiignore" }, diff --git a/src/features/commands/roo-command.test.ts b/src/features/commands/roo-command.test.ts index 0abac5beb..001741b2b 100644 --- a/src/features/commands/roo-command.test.ts +++ b/src/features/commands/roo-command.test.ts @@ -173,6 +173,36 @@ describe("RooCommand", () => { expect(rulesyncCommand.getRelativeFilePath()).toBe("convert-test.md"); expect(rulesyncCommand.getOutputRoot()).toBe("."); }); + + it("should carry the mode field through the roo section and round-trip back", () => { + const frontmatter: RooCommandFrontmatter = { + description: "Switches to architect mode", + mode: "architect", + }; + const body = "Plan the change"; + + const rooCommand = new RooCommand({ + outputRoot: "/test/base", + relativeDirPath: ".roo/commands", + relativeFilePath: "plan.md", + frontmatter, + body, + fileContent: stringifyFrontmatter(body, frontmatter), + }); + + const rulesyncCommand = rooCommand.toRulesyncCommand(); + expect(rulesyncCommand.getFrontmatter()).toEqual({ + targets: ["roo"], + description: "Switches to architect mode", + roo: { mode: "architect" }, + }); + + const roundTripped = RooCommand.fromRulesyncCommand({ rulesyncCommand }); + expect(roundTripped.getFrontmatter()).toEqual({ + description: "Switches to architect mode", + mode: "architect", + }); + }); }); describe("fromRulesyncCommand", () => { @@ -444,6 +474,20 @@ This file has invalid frontmatter`; expect(result.success).toBe(false); }); + it("should accept a mode slug and reject a non-string mode", () => { + const valid = RooCommandFrontmatterSchema.safeParse({ + description: "d", + mode: "code", + }); + expect(valid.success).toBe(true); + if (valid.success) { + expect(valid.data.mode).toBe("code"); + } + + const invalid = RooCommandFrontmatterSchema.safeParse({ description: "d", mode: 1 }); + expect(invalid.success).toBe(false); + }); + it("should reject frontmatter with invalid argument-hint type", () => { const invalidFrontmatter = { description: "Valid description", diff --git a/src/features/commands/roo-command.ts b/src/features/commands/roo-command.ts index 542ea0540..8cfb9a8d8 100644 --- a/src/features/commands/roo-command.ts +++ b/src/features/commands/roo-command.ts @@ -18,6 +18,9 @@ import { export const RooCommandFrontmatterSchema = z.looseObject({ description: z.optional(z.string()), "argument-hint": optional(z.string()), + // Mode slug (e.g. "code", "architect") to switch to before running the command body. + // https://roocodeinc.github.io/Roo-Code/features/slash-commands + mode: z.optional(z.string()), }); export type RooCommandFrontmatter = z.infer; diff --git a/src/features/ignore/kilo-ignore.test.ts b/src/features/ignore/kilo-ignore.test.ts index 69a2ad961..43e4f9f4d 100644 --- a/src/features/ignore/kilo-ignore.test.ts +++ b/src/features/ignore/kilo-ignore.test.ts @@ -29,13 +29,13 @@ describe("KiloIgnore", () => { it("should create instance with default parameters", () => { const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent: "*.log\nnode_modules/", }); expect(kiloIgnore).toBeInstanceOf(KiloIgnore); expect(kiloIgnore.getRelativeDirPath()).toBe("."); - expect(kiloIgnore.getRelativeFilePath()).toBe(".kiloignore"); + expect(kiloIgnore.getRelativeFilePath()).toBe(".kilocodeignore"); expect(kiloIgnore.getFileContent()).toBe("*.log\nnode_modules/"); }); @@ -43,18 +43,18 @@ describe("KiloIgnore", () => { const kiloIgnore = new KiloIgnore({ outputRoot: "/custom/path", relativeDirPath: "subdir", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent: "*.tmp", }); - expect(kiloIgnore.getFilePath()).toBe("/custom/path/subdir/.kiloignore"); + expect(kiloIgnore.getFilePath()).toBe("/custom/path/subdir/.kilocodeignore"); }); it("should validate content by default", () => { expect(() => { const _instance = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent: "", // empty content should be valid }); }).not.toThrow(); @@ -64,7 +64,7 @@ describe("KiloIgnore", () => { expect(() => { const _instance = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent: "any content", validate: false, }); @@ -78,7 +78,7 @@ describe("KiloIgnore", () => { const kiloIgnore = new KiloIgnore({ outputRoot: testDir, relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent, }); @@ -94,7 +94,7 @@ describe("KiloIgnore", () => { const kiloIgnore = new KiloIgnore({ outputRoot: testDir, relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent: "", }); @@ -108,7 +108,7 @@ describe("KiloIgnore", () => { const kiloIgnore = new KiloIgnore({ outputRoot: testDir, relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent, }); @@ -134,7 +134,7 @@ describe("KiloIgnore", () => { expect(kiloIgnore).toBeInstanceOf(KiloIgnore); expect(kiloIgnore.getOutputRoot()).toBe(testDir); expect(kiloIgnore.getRelativeDirPath()).toBe("."); - expect(kiloIgnore.getRelativeFilePath()).toBe(".kiloignore"); + expect(kiloIgnore.getRelativeFilePath()).toBe(".kilocodeignore"); expect(kiloIgnore.getFileContent()).toBe(fileContent); }); @@ -152,7 +152,7 @@ describe("KiloIgnore", () => { }); expect(kiloIgnore.getOutputRoot()).toBe("/custom/base"); - expect(kiloIgnore.getFilePath()).toBe("/custom/base/.kiloignore"); + expect(kiloIgnore.getFilePath()).toBe("/custom/base/.kilocodeignore"); expect(kiloIgnore.getFileContent()).toBe(fileContent); }); @@ -187,9 +187,9 @@ describe("KiloIgnore", () => { }); describe("fromFile", () => { - it("should read .kiloignore file from outputRoot with default outputRoot", async () => { + it("should read .kilocodeignore file from outputRoot with default outputRoot", async () => { const fileContent = "*.log\nnode_modules/\n.env"; - const kiloignorePath = join(testDir, ".kiloignore"); + const kiloignorePath = join(testDir, ".kilocodeignore"); await writeFileContent(kiloignorePath, fileContent); const kiloIgnore = await KiloIgnore.fromFile({ @@ -199,13 +199,13 @@ describe("KiloIgnore", () => { expect(kiloIgnore).toBeInstanceOf(KiloIgnore); expect(kiloIgnore.getOutputRoot()).toBe(testDir); expect(kiloIgnore.getRelativeDirPath()).toBe("."); - expect(kiloIgnore.getRelativeFilePath()).toBe(".kiloignore"); + expect(kiloIgnore.getRelativeFilePath()).toBe(".kilocodeignore"); expect(kiloIgnore.getFileContent()).toBe(fileContent); }); - it("should read .kiloignore file with validation enabled by default", async () => { + it("should read .kilocodeignore file with validation enabled by default", async () => { const fileContent = "*.log\nnode_modules/"; - const kiloignorePath = join(testDir, ".kiloignore"); + const kiloignorePath = join(testDir, ".kilocodeignore"); await writeFileContent(kiloignorePath, fileContent); const kiloIgnore = await KiloIgnore.fromFile({ @@ -215,9 +215,9 @@ describe("KiloIgnore", () => { expect(kiloIgnore.getFileContent()).toBe(fileContent); }); - it("should read .kiloignore file with validation disabled", async () => { + it("should read .kilocodeignore file with validation disabled", async () => { const fileContent = "*.log\nnode_modules/"; - const kiloignorePath = join(testDir, ".kiloignore"); + const kiloignorePath = join(testDir, ".kilocodeignore"); await writeFileContent(kiloignorePath, fileContent); const kiloIgnore = await KiloIgnore.fromFile({ @@ -228,8 +228,8 @@ describe("KiloIgnore", () => { expect(kiloIgnore.getFileContent()).toBe(fileContent); }); - it("should handle empty .kiloignore file", async () => { - const kiloignorePath = join(testDir, ".kiloignore"); + it("should handle empty .kilocodeignore file", async () => { + const kiloignorePath = join(testDir, ".kilocodeignore"); await writeFileContent(kiloignorePath, ""); const kiloIgnore = await KiloIgnore.fromFile({ @@ -239,7 +239,7 @@ describe("KiloIgnore", () => { expect(kiloIgnore.getFileContent()).toBe(""); }); - it("should handle .kiloignore file with complex patterns", async () => { + it("should handle .kilocodeignore file with complex patterns", async () => { const fileContent = `# Build outputs build/ dist/ @@ -270,7 +270,7 @@ logs/ .DS_Store Thumbs.db`; - const kiloignorePath = join(testDir, ".kiloignore"); + const kiloignorePath = join(testDir, ".kilocodeignore"); await writeFileContent(kiloignorePath, fileContent); const kiloIgnore = await KiloIgnore.fromFile({ @@ -283,7 +283,7 @@ Thumbs.db`; it("should default outputRoot to process.cwd() when not provided", async () => { // process.cwd() is already mocked to return testDir in beforeEach const fileContent = "*.log\nnode_modules/"; - const kiloignorePath = join(testDir, ".kiloignore"); + const kiloignorePath = join(testDir, ".kilocodeignore"); await writeFileContent(kiloignorePath, fileContent); const kiloIgnore = await KiloIgnore.fromFile({}); @@ -292,7 +292,7 @@ Thumbs.db`; expect(kiloIgnore.getFileContent()).toBe(fileContent); }); - it("should throw error when .kiloignore file does not exist", async () => { + it("should throw error when .kilocodeignore file does not exist", async () => { await expect( KiloIgnore.fromFile({ outputRoot: testDir, @@ -302,7 +302,7 @@ Thumbs.db`; it("should handle file with Windows line endings", async () => { const fileContent = "*.log\r\nnode_modules/\r\n.env"; - const kiloignorePath = join(testDir, ".kiloignore"); + const kiloignorePath = join(testDir, ".kilocodeignore"); await writeFileContent(kiloignorePath, fileContent); const kiloIgnore = await KiloIgnore.fromFile({ @@ -318,7 +318,7 @@ Thumbs.db`; const fileContent = "*.log\nnode_modules/\n.env"; const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent, }); @@ -331,7 +331,7 @@ Thumbs.db`; it("should inherit validation method", () => { const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent: "*.log\nnode_modules/", }); @@ -345,14 +345,14 @@ Thumbs.db`; const kiloIgnore = new KiloIgnore({ outputRoot: "/test/base", relativeDirPath: "subdir", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent: "*.log", }); expect(kiloIgnore.getOutputRoot()).toBe("/test/base"); expect(kiloIgnore.getRelativeDirPath()).toBe("subdir"); - expect(kiloIgnore.getRelativeFilePath()).toBe(".kiloignore"); - expect(kiloIgnore.getFilePath()).toBe("/test/base/subdir/.kiloignore"); + expect(kiloIgnore.getRelativeFilePath()).toBe(".kilocodeignore"); + expect(kiloIgnore.getFilePath()).toBe("/test/base/subdir/.kilocodeignore"); expect(kiloIgnore.getFileContent()).toBe("*.log"); }); }); @@ -371,7 +371,7 @@ dist/ const originalKiloIgnore = new KiloIgnore({ outputRoot: testDir, relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent: originalContent, }); @@ -384,7 +384,7 @@ dist/ expect(roundTripKiloIgnore.getFileContent()).toBe(originalContent); expect(roundTripKiloIgnore.getOutputRoot()).toBe(testDir); expect(roundTripKiloIgnore.getRelativeDirPath()).toBe("."); - expect(roundTripKiloIgnore.getRelativeFilePath()).toBe(".kiloignore"); + expect(roundTripKiloIgnore.getRelativeFilePath()).toBe(".kilocodeignore"); }); it("should maintain patterns in round-trip conversion", () => { @@ -393,7 +393,7 @@ dist/ const originalKiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent: originalContent, }); @@ -410,7 +410,7 @@ dist/ it("should handle file content with only whitespace", () => { const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent: " \n\t\n ", }); @@ -423,7 +423,7 @@ dist/ const fileContent = "*.log\r\nnode_modules/\n.env\r\nbuild/"; const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent, }); @@ -434,7 +434,7 @@ dist/ const longPattern = "a".repeat(1000); const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent: longPattern, }); @@ -446,7 +446,7 @@ dist/ const unicodeContent = "*.log\nnode_modules/\nenvironment.env\nbuild/"; const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent: unicodeContent, }); @@ -466,7 +466,7 @@ dist/ const kiloIgnore = new KiloIgnore({ outputRoot: testDir, relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent, }); @@ -490,7 +490,7 @@ dist/ const kiloIgnore = new KiloIgnore({ outputRoot: testDir, relativeDirPath: "project/config", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent, }); @@ -506,14 +506,14 @@ dist/ }); describe("Kilo Code-specific behavior", () => { - it("should use .kiloignore as the filename", () => { + it("should use .kilocodeignore as the filename", () => { const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent: "*.log", }); - expect(kiloIgnore.getRelativeFilePath()).toBe(".kiloignore"); + expect(kiloIgnore.getRelativeFilePath()).toBe(".kilocodeignore"); }); it("should work with gitignore syntax patterns", () => { @@ -530,7 +530,7 @@ temp*/ const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent, }); @@ -555,7 +555,7 @@ temp*/ const fileContent = "# This should reflect immediately\n*.log\ntemp/"; const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", fileContent, }); @@ -573,10 +573,10 @@ temp*/ }), }); - // Should always place .kiloignore in root (relativeDirPath: ".") + // Should always place .kilocodeignore in root (relativeDirPath: ".") expect(kiloIgnore.getRelativeDirPath()).toBe("."); - expect(kiloIgnore.getRelativeFilePath()).toBe(".kiloignore"); - expect(kiloIgnore.getFilePath()).toBe("/workspace/root/.kiloignore"); + expect(kiloIgnore.getRelativeFilePath()).toBe(".kilocodeignore"); + expect(kiloIgnore.getFilePath()).toBe("/workspace/root/.kilocodeignore"); }); }); }); diff --git a/src/features/ignore/kilo-ignore.ts b/src/features/ignore/kilo-ignore.ts index b4ddff779..a7fec3cd4 100644 --- a/src/features/ignore/kilo-ignore.ts +++ b/src/features/ignore/kilo-ignore.ts @@ -14,17 +14,20 @@ import { * KiloIgnore represents ignore patterns for the Kilo Code VSCode extension. * * Based on the Kilo Code specification: - * - File location: Workspace root folder only (.kiloignore) + * - File location: Workspace root folder only (.kilocodeignore) * - Syntax: Same as .gitignore * - Immediate reflection when saved * - Complete blocking of file access for ignored patterns * - Shows lock icon for ignored files in listings + * + * Kilo reads `.kilocodeignore` (not `.kiloignore`), so emitting `.kiloignore` + * left the file inert. https://kilo.ai/docs/customize/context/kilocodeignore */ export class KiloIgnore extends ToolIgnore { static getSettablePaths(): ToolIgnoreSettablePaths { return { relativeDirPath: ".", - relativeFilePath: ".kiloignore", + relativeFilePath: ".kilocodeignore", }; } @@ -53,7 +56,7 @@ export class KiloIgnore extends ToolIgnore { } /** - * Load KiloIgnore from .kiloignore file + * Load KiloIgnore from .kilocodeignore file */ static async fromFile({ outputRoot = process.cwd(), diff --git a/src/features/skills/rovodev-skill.test.ts b/src/features/skills/rovodev-skill.test.ts index 4de449ac8..868df219d 100644 --- a/src/features/skills/rovodev-skill.test.ts +++ b/src/features/skills/rovodev-skill.test.ts @@ -285,6 +285,40 @@ body`, expect(rulesync.getBody()).toBe("Export body"); expect(rulesync.getRelativeDirPath()).toBe(RULESYNC_SKILLS_RELATIVE_DIR_PATH); }); + + it("should carry the standard optional frontmatter into the rovodev section", () => { + const skill = new RovodevSkill({ + outputRoot: testDir, + dirName: "with-meta", + frontmatter: { + name: "with-meta", + description: "Desc", + "allowed-tools": "grep bash", + license: "MIT", + metadata: { author: "rulesync" }, + }, + body: "Body", + validate: true, + }); + + const rulesync = skill.toRulesyncSkill(); + expect(rulesync.getFrontmatter().rovodev).toEqual({ + "allowed-tools": "grep bash", + license: "MIT", + metadata: { author: "rulesync" }, + }); + + // round-trip back into a RovodevSkill preserves the fields + const roundTripped = RovodevSkill.fromRulesyncSkill({ + outputRoot: testDir, + rulesyncSkill: rulesync, + validate: true, + }); + const fm = roundTripped.getFrontmatter(); + expect(fm["allowed-tools"]).toBe("grep bash"); + expect(fm.license).toBe("MIT"); + expect(fm.metadata).toEqual({ author: "rulesync" }); + }); }); describe("round-trip", () => { diff --git a/src/features/skills/rovodev-skill.ts b/src/features/skills/rovodev-skill.ts index a01bf6585..ac33bad87 100644 --- a/src/features/skills/rovodev-skill.ts +++ b/src/features/skills/rovodev-skill.ts @@ -18,6 +18,12 @@ import { export const RovodevSkillFrontmatterSchema = z.looseObject({ name: z.string(), description: z.string(), + // Optional Agent Skills frontmatter that Rovo Dev documents besides name/description. + // `allowed-tools` is a space-separated string upstream; a YAML list is also accepted. + // https://support.atlassian.com/rovo/docs/extend-rovo-dev-cli-with-agent-skills/ + "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])), + license: z.optional(z.string()), + metadata: z.optional(z.looseObject({})), }); export type RovodevSkillFrontmatter = z.infer; @@ -123,10 +129,18 @@ export class RovodevSkill extends ToolSkill { toRulesyncSkill(): RulesyncSkill { const frontmatter = this.getFrontmatter(); + const rovodevSection = { + ...(frontmatter["allowed-tools"] !== undefined && { + "allowed-tools": frontmatter["allowed-tools"], + }), + ...(frontmatter.license !== undefined && { license: frontmatter.license }), + ...(frontmatter.metadata !== undefined && { metadata: frontmatter.metadata }), + }; const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { name: frontmatter.name, description: frontmatter.description, targets: ["*"], + ...(Object.keys(rovodevSection).length > 0 && { rovodev: rovodevSection }), }; return new RulesyncSkill({ @@ -149,10 +163,16 @@ export class RovodevSkill extends ToolSkill { }: ToolSkillFromRulesyncSkillParams): RovodevSkill { const settablePaths = RovodevSkill.getSettablePaths({ global }); const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + const rovodevSection = rulesyncFrontmatter.rovodev; const rovodevFrontmatter: RovodevSkillFrontmatter = { name: rulesyncFrontmatter.name, description: rulesyncFrontmatter.description, + ...(rovodevSection?.["allowed-tools"] !== undefined && { + "allowed-tools": rovodevSection["allowed-tools"], + }), + ...(rovodevSection?.license !== undefined && { license: rovodevSection.license }), + ...(rovodevSection?.metadata !== undefined && { metadata: rovodevSection.metadata }), }; return new RovodevSkill({ diff --git a/src/features/skills/rulesync-skill.ts b/src/features/skills/rulesync-skill.ts index 86bec225d..3e6a83c3f 100644 --- a/src/features/skills/rulesync-skill.ts +++ b/src/features/skills/rulesync-skill.ts @@ -106,6 +106,13 @@ const RulesyncSkillFrontmatterSchemaInternal = z.looseObject({ ), cline: z.optional(z.looseObject({})), roo: z.optional(z.looseObject({})), + rovodev: z.optional( + z.looseObject({ + "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])), + license: z.optional(z.string()), + metadata: z.optional(z.looseObject({})), + }), + ), cursor: z.optional( z.looseObject({ paths: z.optional(z.union([z.string(), z.array(z.string())])), @@ -199,6 +206,11 @@ export type RulesyncSkillFrontmatterInput = { }; roo?: Record; cline?: Record; + rovodev?: { + "allowed-tools"?: string | string[]; + license?: string; + metadata?: Record; + }; cursor?: { paths?: string | string[]; "disable-model-invocation"?: boolean;