diff --git a/.gitignore b/.gitignore index 7bc5b5448..92d2c2976 100644 --- a/.gitignore +++ b/.gitignore @@ -235,6 +235,7 @@ rulesync.local.jsonc **/.takt/config.yaml **/.augment-guidelines **/.agents/skills/ +**/.rovodev/prompts.yml **/.codeium/windsurf/skills/ **/.copilot/agents/ **/.copilot/mcp-config.json @@ -290,6 +291,7 @@ rulesync.local.jsonc **/.qwen/commands/ **/.reasonix/commands/ **/.roo/commands/ +**/.rovodev/prompts/ **/.takt/facets/instructions/ **/.devin/workflows/ **/.augment/skills/ diff --git a/README.md b/README.md index bba676caa..827a28374 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte | Cline | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | Kilo Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Roo Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | -| Rovodev (Atlassian) | ✅ | | ✅ | | ✅ | ✅ | | ✅ | +| Rovodev (Atlassian) | ✅ | | ✅ | ✅ | ✅ | ✅ | | ✅ | | Takt | ✅ | | ✅ | ✅ | ✅ | ✅ | | ✅ | | Vibe Code | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | | Qwen Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 0cd978e10..de7a90baa 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -287,6 +287,8 @@ The command body itself uses a Claude Code-compatible **universal syntax** (e.g. > **Reasonix note:** Custom slash commands are Markdown files under `.reasonix/commands/` (project) / `~/.reasonix/commands/` (global, via `--global`) — directly analogous to Claude Code's `.claude/commands/`, since Reasonix explicitly mirrors Claude Code's conventions. Frontmatter supports `description` and `argument-hint`, and the body uses the same `$ARGUMENTS` / `$1`…`$N` placeholder syntax. Subdirectory namespacing is supported (`git/commit.md` → `/git:commit`). Any extra fields are preserved on round-trip under the `reasonix:` block. See the [Reasonix GUIDE](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md#slash-commands). +> **Rovo Dev CLI note:** Rovo Dev's "saved prompts" are a file-based custom-command surface made of a `prompts.yml` manifest plus per-prompt Markdown content files, invoked via `/prompts [title] [extra]`. Rulesync writes the content (no frontmatter) to `.rovodev/prompts/.md` (project) / `~/.rovodev/prompts/.md` (global, via `--global`), and rebuilds the sibling `.rovodev/prompts.yml` / `~/.rovodev/prompts.yml` manifest with one `{ name, description, content_file }` entry per prompt, `content_file` pointing at `prompts/.md` (resolved relative to `prompts.yml`, matching Rovo Dev's own resolution order). The `prompts` array is fully replaced from the current rulesync commands on each generate (mirrors the Rovodev MCP adapter fully replacing `mcpServers`); any other top-level key in an existing manifest is preserved, and the manifest is never deleted. See the [saved prompts](https://support.atlassian.com/rovo/docs/save-and-reuse-a-prompt-in-rovo-dev-cli/) and [CLI commands](https://support.atlassian.com/rovo/docs/rovo-dev-cli-commands/) docs. + ## `rulesync/subagents/*.md` Example: diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 22ccd0244..ea93d1519 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -23,7 +23,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Cline | cline | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ | | Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Roo Code | roo | ✅ 🌏 | ✅ | ✅ | ✅ | ✅ | ✅ 🌏 | | | -| Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | | ✅ 🌏 | ✅ 🌏 | | 🌏 | +| Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | 🌏 | | Takt | takt | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | | Vibe Code | vibe | ✅ 🌏 | ✅ | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Qwen Code | qwencode | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 0cd978e10..de7a90baa 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -287,6 +287,8 @@ The command body itself uses a Claude Code-compatible **universal syntax** (e.g. > **Reasonix note:** Custom slash commands are Markdown files under `.reasonix/commands/` (project) / `~/.reasonix/commands/` (global, via `--global`) — directly analogous to Claude Code's `.claude/commands/`, since Reasonix explicitly mirrors Claude Code's conventions. Frontmatter supports `description` and `argument-hint`, and the body uses the same `$ARGUMENTS` / `$1`…`$N` placeholder syntax. Subdirectory namespacing is supported (`git/commit.md` → `/git:commit`). Any extra fields are preserved on round-trip under the `reasonix:` block. See the [Reasonix GUIDE](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md#slash-commands). +> **Rovo Dev CLI note:** Rovo Dev's "saved prompts" are a file-based custom-command surface made of a `prompts.yml` manifest plus per-prompt Markdown content files, invoked via `/prompts [title] [extra]`. Rulesync writes the content (no frontmatter) to `.rovodev/prompts/.md` (project) / `~/.rovodev/prompts/.md` (global, via `--global`), and rebuilds the sibling `.rovodev/prompts.yml` / `~/.rovodev/prompts.yml` manifest with one `{ name, description, content_file }` entry per prompt, `content_file` pointing at `prompts/.md` (resolved relative to `prompts.yml`, matching Rovo Dev's own resolution order). The `prompts` array is fully replaced from the current rulesync commands on each generate (mirrors the Rovodev MCP adapter fully replacing `mcpServers`); any other top-level key in an existing manifest is preserved, and the manifest is never deleted. See the [saved prompts](https://support.atlassian.com/rovo/docs/save-and-reuse-a-prompt-in-rovo-dev-cli/) and [CLI commands](https://support.atlassian.com/rovo/docs/rovo-dev-cli-commands/) docs. + ## `rulesync/subagents/*.md` Example: diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 22ccd0244..ea93d1519 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -23,7 +23,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Cline | cline | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ | | Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Roo Code | roo | ✅ 🌏 | ✅ | ✅ | ✅ | ✅ | ✅ 🌏 | | | -| Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | | ✅ 🌏 | ✅ 🌏 | | 🌏 | +| Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | 🌏 | | Takt | takt | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | | Vibe Code | vibe | ✅ 🌏 | ✅ | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Qwen Code | qwencode | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | diff --git a/src/cli/commands/gitignore-entries.test.ts b/src/cli/commands/gitignore-entries.test.ts index cc2723eaf..6f1f4631d 100644 --- a/src/cli/commands/gitignore-entries.test.ts +++ b/src/cli/commands/gitignore-entries.test.ts @@ -117,6 +117,7 @@ describe("registry derivation", () => { "codexcli::ignore::**/.codexignore", // Shared trees and global-scope outputs (emitted under the home dir). "rovodev::skills::**/.agents/skills/", + "rovodev::commands::**/.rovodev/prompts.yml", "devin::skills::**/.codeium/windsurf/skills/", "copilotcli::subagents::**/.copilot/agents/", "copilotcli::mcp::**/.copilot/mcp-config.json", diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index f76d3bcde..9adb0b914 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -79,6 +79,10 @@ export const HAND_MAINTAINED_GITIGNORE_ENTRIES: ReadonlyArray // Shared trees and global-scope outputs not produced via project getSettablePaths. { target: "rovodev", feature: "skills", entry: "**/.agents/skills/" }, + // The `prompts.yml` manifest is produced via `RovodevCommand.getAuxiliaryFiles`, + // not `getSettablePaths` (only the sibling `.rovodev/prompts/` content-file + // directory is derived automatically), so it needs a hand-maintained entry. + { target: "rovodev", feature: "commands", entry: "**/.rovodev/prompts.yml" }, { target: "devin", feature: "skills", entry: "**/.codeium/windsurf/skills/" }, { target: "copilotcli", feature: "subagents", entry: "**/.copilot/agents/" }, { target: "copilotcli", feature: "mcp", entry: "**/.copilot/mcp-config.json" }, diff --git a/src/constants/rovodev-paths.ts b/src/constants/rovodev-paths.ts index bbe383937..87351c86a 100644 --- a/src/constants/rovodev-paths.ts +++ b/src/constants/rovodev-paths.ts @@ -9,3 +9,5 @@ export const ROVODEV_LEGACY_RULE_FILE_NAME = "AGENTS.local.md"; export const ROVODEV_MCP_FILE_NAME = "mcp.json"; export const ROVODEV_CONFIG_FILE_NAME = "config.yml"; export const ROVODEV_AGENTS_SKILLS_DIR_PATH = join(".agents", "skills"); +export const ROVODEV_PROMPTS_FILE_NAME = "prompts.yml"; +export const ROVODEV_PROMPTS_DIR_PATH = join(ROVODEV_DIR, "prompts"); diff --git a/src/e2e/e2e-commands.spec.ts b/src/e2e/e2e-commands.spec.ts index 3420dba09..1e9e813a4 100644 --- a/src/e2e/e2e-commands.spec.ts +++ b/src/e2e/e2e-commands.spec.ts @@ -34,6 +34,7 @@ describe("E2E: commands", () => { { target: "goose", outputPath: join(".goose", "recipes", "review-pr.yaml") }, { target: "qwencode", outputPath: join(".qwen", "commands", "review-pr.md") }, { target: "reasonix", outputPath: join(".reasonix", "commands", "review-pr.md") }, + { target: "rovodev", outputPath: join(".rovodev", "prompts", "review-pr.md") }, ])("should generate $target commands", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -57,6 +58,33 @@ Check the PR diff and provide feedback. expect(generatedContent).toContain("Check the PR diff and provide feedback."); }); + it("should generate a rovodev prompts.yml manifest alongside the content file", async () => { + const testDir = getTestDir(); + + const commandContent = `--- +description: "Review a pull request" +targets: ["*"] +--- +Check the PR diff and provide feedback. +`; + await writeFileContent( + join(testDir, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "review-pr.md"), + commandContent, + ); + + await runGenerate({ target: "rovodev", features: "commands" }); + + // The content file holds the raw prompt body (no frontmatter). + const contentFile = await readFileContent(join(testDir, ".rovodev", "prompts", "review-pr.md")); + expect(contentFile.trim()).toBe("Check the PR diff and provide feedback."); + + // The manifest indexes the prompt by name/description/content_file. + const manifest = await readFileContent(join(testDir, ".rovodev", "prompts.yml")); + expect(manifest).toContain("name: review-pr"); + expect(manifest).toContain("description: Review a pull request"); + expect(manifest).toContain("content_file: prompts/review-pr.md"); + }); + it.each([{ target: "agentsmd", outputPath: join(".agents", "commands", "review-pr.md") }])( "should generate $target simulated commands", async ({ target, outputPath }) => { @@ -97,6 +125,7 @@ Check the PR diff and provide feedback. { target: "devin", orphanPath: join(".devin", "workflows", "orphan.md") }, { target: "factorydroid", orphanPath: join(".factory", "commands", "orphan.md") }, { target: "goose", orphanPath: join(".goose", "recipes", "orphan.yaml") }, + { target: "rovodev", orphanPath: join(".rovodev", "prompts", "orphan.md") }, ])( "should fail in check mode when delete would remove an orphan $target command file", async ({ target, orphanPath }) => { @@ -145,6 +174,7 @@ describe("E2E: commands (import)", () => { { target: "devin", sourcePath: join(".devin", "workflows", "review-pr.md") }, { target: "factorydroid", sourcePath: join(".factory", "commands", "review-pr.md") }, { target: "reasonix", sourcePath: join(".reasonix", "commands", "review-pr.md") }, + { target: "rovodev", sourcePath: join(".rovodev", "prompts", "review-pr.md") }, ])("should import $target commands", async ({ target, sourcePath }) => { const testDir = getTestDir(); @@ -216,6 +246,7 @@ describe("E2E: commands (global mode)", () => { // as Hermes skills under ~/.hermes/skills//SKILL.md (global only). { target: "hermesagent", outputPath: join(".hermes", "skills", "review-pr", "SKILL.md") }, { target: "reasonix", outputPath: join(".reasonix", "commands", "review-pr.md") }, + { target: "rovodev", outputPath: join(".rovodev", "prompts", "review-pr.md") }, ])("should generate $target commands in home directory", async ({ target, outputPath }) => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); diff --git a/src/features/commands/commands-processor.test.ts b/src/features/commands/commands-processor.test.ts index be494dead..1e12ac5e0 100644 --- a/src/features/commands/commands-processor.test.ts +++ b/src/features/commands/commands-processor.test.ts @@ -1082,6 +1082,7 @@ describe("CommandsProcessor", () => { "qwencode", "reasonix", "roo", + "rovodev", "takt", "devin", ]), @@ -1113,6 +1114,7 @@ describe("CommandsProcessor", () => { "qwencode", "reasonix", "roo", + "rovodev", "takt", "devin", ]), @@ -1143,6 +1145,7 @@ describe("CommandsProcessor", () => { "pi", "qwencode", "reasonix", + "rovodev", "takt", "devin", ]), diff --git a/src/features/commands/commands-processor.ts b/src/features/commands/commands-processor.ts index 7aa3faf76..5f561b8c2 100644 --- a/src/features/commands/commands-processor.ts +++ b/src/features/commands/commands-processor.ts @@ -34,6 +34,7 @@ import { PiCommand } from "./pi-command.js"; import { QwencodeCommand } from "./qwencode-command.js"; import { ReasonixCommand } from "./reasonix-command.js"; import { RooCommand } from "./roo-command.js"; +import { RovodevCommand } from "./rovodev-command.js"; import { RulesyncCommand } from "./rulesync-command.js"; import { TaktCommand } from "./takt-command.js"; import { @@ -65,6 +66,16 @@ type ToolCommandFactory = { outputRoot: string; global: boolean; }): Promise; + /** + * Optional hook for tools that need a shared/aggregate file alongside the + * per-command files (e.g. Rovo Dev's `prompts.yml` manifest). See + * {@link ToolCommand.getAuxiliaryFiles}. + */ + getAuxiliaryFiles?(params: { + toolCommands: ToolCommand[]; + outputRoot?: string; + global?: boolean; + }): Promise | ToolFile[]; }; meta: { /** File extension for the command file */ @@ -413,6 +424,26 @@ export const toolCommandFactories = new Map command !== null); - return toolCommands; + const auxiliaryFiles = await factory.class.getAuxiliaryFiles?.({ + toolCommands, + outputRoot: this.outputRoot, + global: this.global, + }); + + const result: ToolFile[] = [...toolCommands]; + if (auxiliaryFiles && auxiliaryFiles.length > 0) { + result.push(...auxiliaryFiles); + } + + return result; } async convertToolFilesToRulesyncFiles(toolFiles: ToolFile[]): Promise { diff --git a/src/features/commands/rovodev-command.test.ts b/src/features/commands/rovodev-command.test.ts new file mode 100644 index 000000000..38c81f04f --- /dev/null +++ b/src/features/commands/rovodev-command.test.ts @@ -0,0 +1,453 @@ +import { join } from "node:path"; + +import { dump, load } from "js-yaml"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { isRecord } from "../../utils/type-guards.js"; +import { RovodevCommand } from "./rovodev-command.js"; +import { RulesyncCommand } from "./rulesync-command.js"; +import { ToolCommand } from "./tool-command.js"; + +describe("RovodevCommand", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("constructor", () => { + it("should create a valid RovodevCommand instance", () => { + const command = new RovodevCommand({ + outputRoot: testDir, + relativeDirPath: join(".rovodev", "prompts"), + relativeFilePath: "review-pr.md", + name: "review-pr", + description: "Review a pull request", + body: "Check the PR diff and provide feedback.", + }); + + expect(command).toBeInstanceOf(RovodevCommand); + expect(command.getName()).toBe("review-pr"); + expect(command.getDescription()).toBe("Review a pull request"); + expect(command.getBody()).toBe("Check the PR diff and provide feedback."); + }); + + it("should write the body verbatim as file content (no frontmatter)", () => { + const command = new RovodevCommand({ + outputRoot: testDir, + relativeDirPath: join(".rovodev", "prompts"), + relativeFilePath: "review-pr.md", + name: "review-pr", + description: "Review a pull request", + body: "Check the PR diff and provide feedback.", + }); + + expect(command.getFileContent()).toBe("Check the PR diff and provide feedback."); + }); + + it("should throw when validate is true and name is empty", () => { + expect(() => { + new RovodevCommand({ + outputRoot: testDir, + relativeDirPath: join(".rovodev", "prompts"), + relativeFilePath: "review-pr.md", + name: "", + description: "Review a pull request", + body: "Body", + validate: true, + }); + }).toThrow(/must not be empty/); + }); + + it("should skip validation when validate is false", () => { + expect(() => { + new RovodevCommand({ + outputRoot: testDir, + relativeDirPath: join(".rovodev", "prompts"), + relativeFilePath: "review-pr.md", + name: "", + description: "", + body: "", + validate: false, + }); + }).not.toThrow(); + }); + }); + + describe("getSettablePaths", () => { + it("should return .rovodev/prompts as relativeDirPath", () => { + const paths = RovodevCommand.getSettablePaths(); + expect(paths.relativeDirPath).toBe(join(".rovodev", "prompts")); + }); + + it("should return the same relativeDirPath in global mode (outputRoot switches to home)", () => { + const paths = RovodevCommand.getSettablePaths({ global: true }); + expect(paths.relativeDirPath).toBe(join(".rovodev", "prompts")); + }); + }); + + describe("toRulesyncCommand", () => { + it("should convert to RulesyncCommand correctly", () => { + const command = new RovodevCommand({ + outputRoot: testDir, + relativeDirPath: join(".rovodev", "prompts"), + relativeFilePath: "review-pr.md", + name: "review-pr", + description: "Review a pull request", + body: "Check the PR diff and provide feedback.", + }); + + const rulesyncCommand = command.toRulesyncCommand(); + + expect(rulesyncCommand).toBeInstanceOf(RulesyncCommand); + expect(rulesyncCommand.getRelativeFilePath()).toBe("review-pr.md"); + expect(rulesyncCommand.getFrontmatter()).toEqual({ + targets: ["*"], + description: "Review a pull request", + }); + expect(rulesyncCommand.getBody()).toBe("Check the PR diff and provide feedback."); + }); + }); + + describe("fromRulesyncCommand", () => { + it("should create RovodevCommand from RulesyncCommand", () => { + const rulesyncCommand = new RulesyncCommand({ + outputRoot: testDir, + relativeDirPath: ".rulesync/commands", + relativeFilePath: "review-pr.md", + fileContent: "", + frontmatter: { + targets: ["rovodev"], + description: "Review a pull request", + }, + body: "Check the PR diff and provide feedback.", + }); + + const command = RovodevCommand.fromRulesyncCommand({ rulesyncCommand }); + + expect(command).toBeInstanceOf(RovodevCommand); + expect(command.getRelativeDirPath()).toBe(join(".rovodev", "prompts")); + expect(command.getRelativeFilePath()).toBe("review-pr.md"); + expect(command.getName()).toBe("review-pr"); + expect(command.getDescription()).toBe("Review a pull request"); + expect(command.getBody()).toBe("Check the PR diff and provide feedback."); + }); + + it("should use global path when global is true", () => { + const rulesyncCommand = new RulesyncCommand({ + outputRoot: testDir, + relativeDirPath: ".rulesync/commands", + relativeFilePath: "review-pr.md", + fileContent: "", + frontmatter: { + targets: ["rovodev"], + description: "Review a pull request", + }, + body: "Body", + }); + + const command = RovodevCommand.fromRulesyncCommand({ rulesyncCommand, global: true }); + + expect(command.getRelativeDirPath()).toBe(join(".rovodev", "prompts")); + }); + + it("should default description to an empty string when missing", () => { + const rulesyncCommand = new RulesyncCommand({ + outputRoot: testDir, + relativeDirPath: ".rulesync/commands", + relativeFilePath: "no-desc.md", + fileContent: "", + frontmatter: { + targets: ["rovodev"], + }, + body: "Body", + }); + + const command = RovodevCommand.fromRulesyncCommand({ rulesyncCommand }); + + expect(command.getDescription()).toBe(""); + }); + }); + + describe("isTargetedByRulesyncCommand", () => { + it("should return true if targets includes *", () => { + const rulesyncCommand = new RulesyncCommand({ + outputRoot: testDir, + relativeDirPath: ".rulesync/commands", + relativeFilePath: "test.md", + fileContent: "", + frontmatter: { targets: ["*"], description: "Test" }, + body: "Body", + }); + + expect(RovodevCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true); + }); + + it("should return true if targets includes rovodev", () => { + const rulesyncCommand = new RulesyncCommand({ + outputRoot: testDir, + relativeDirPath: ".rulesync/commands", + relativeFilePath: "test.md", + fileContent: "", + frontmatter: { targets: ["rovodev"], description: "Test" }, + body: "Body", + }); + + expect(RovodevCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true); + }); + + it("should return false if targets does not include rovodev or *", () => { + const rulesyncCommand = new RulesyncCommand({ + outputRoot: testDir, + relativeDirPath: ".rulesync/commands", + relativeFilePath: "test.md", + fileContent: "", + frontmatter: { targets: ["other-tool"] as any, description: "Test" }, + body: "Body", + }); + + expect(RovodevCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(false); + }); + }); + + describe("fromFile", () => { + it("should load RovodevCommand from a content file with no manifest present", async () => { + const relativeDirPath = join(".rovodev", "prompts"); + const relativeFilePath = "review-pr.md"; + const body = "Check the PR diff and provide feedback."; + + await ensureDir(join(testDir, relativeDirPath)); + await writeFileContent(join(testDir, relativeDirPath, relativeFilePath), body); + + const command = await RovodevCommand.fromFile({ + outputRoot: testDir, + relativeFilePath, + }); + + expect(command).toBeInstanceOf(RovodevCommand); + expect(command.getName()).toBe("review-pr"); + expect(command.getDescription()).toBe(""); + expect(command.getBody()).toBe(body); + }); + + it("should recover the description from the sibling prompts.yml manifest", async () => { + const relativeDirPath = join(".rovodev", "prompts"); + const relativeFilePath = "review-pr.md"; + const body = "Check the PR diff and provide feedback."; + + await ensureDir(join(testDir, relativeDirPath)); + await writeFileContent(join(testDir, relativeDirPath, relativeFilePath), body); + await writeFileContent( + join(testDir, ".rovodev", "prompts.yml"), + dump({ + prompts: [ + { + name: "review-pr", + description: "Review a pull request", + content_file: "prompts/review-pr.md", + }, + ], + }), + ); + + const command = await RovodevCommand.fromFile({ + outputRoot: testDir, + relativeFilePath, + }); + + expect(command.getDescription()).toBe("Review a pull request"); + }); + + it("should load RovodevCommand from a global content file", async () => { + const relativeDirPath = join(".rovodev", "prompts"); + const relativeFilePath = "global-prompt.md"; + const body = "Global body"; + + await ensureDir(join(testDir, relativeDirPath)); + await writeFileContent(join(testDir, relativeDirPath, relativeFilePath), body); + + const command = await RovodevCommand.fromFile({ + outputRoot: testDir, + relativeFilePath, + global: true, + }); + + expect(command).toBeInstanceOf(RovodevCommand); + expect(command.getRelativeDirPath()).toBe(join(".rovodev", "prompts")); + expect(command.getBody()).toBe(body); + }); + + it("should tolerate an unparsable prompts.yml manifest", async () => { + const relativeDirPath = join(".rovodev", "prompts"); + const relativeFilePath = "review-pr.md"; + const body = "Body"; + + await ensureDir(join(testDir, relativeDirPath)); + await writeFileContent(join(testDir, relativeDirPath, relativeFilePath), body); + await writeFileContent(join(testDir, ".rovodev", "prompts.yml"), "not: [valid: yaml"); + + const command = await RovodevCommand.fromFile({ + outputRoot: testDir, + relativeFilePath, + }); + + expect(command.getDescription()).toBe(""); + }); + }); + + describe("forDeletion", () => { + it("should create a minimal RovodevCommand for deletion", () => { + const command = RovodevCommand.forDeletion({ + outputRoot: testDir, + relativeDirPath: join(".rovodev", "prompts"), + relativeFilePath: "review-pr.md", + }); + + expect(command).toBeInstanceOf(RovodevCommand); + expect(command.getRelativeDirPath()).toBe(join(".rovodev", "prompts")); + expect(command.getRelativeFilePath()).toBe("review-pr.md"); + expect(command.getBody()).toBe(""); + expect(command.isDeletable()).toBe(true); + }); + }); + + describe("getAuxiliaryFiles", () => { + it("should return an empty array when there are no RovodevCommand instances", async () => { + const auxiliaryFiles = await RovodevCommand.getAuxiliaryFiles({ + toolCommands: [], + outputRoot: testDir, + }); + + expect(auxiliaryFiles).toEqual([]); + }); + + it("should build a prompts.yml manifest entry per command, sorted by name", async () => { + const first = new RovodevCommand({ + outputRoot: testDir, + relativeDirPath: join(".rovodev", "prompts"), + relativeFilePath: "zebra.md", + name: "zebra", + description: "Zebra prompt", + body: "Zebra body", + }); + const second = new RovodevCommand({ + outputRoot: testDir, + relativeDirPath: join(".rovodev", "prompts"), + relativeFilePath: "alpha.md", + name: "alpha", + description: "Alpha prompt", + body: "Alpha body", + }); + + const auxiliaryFiles = await RovodevCommand.getAuxiliaryFiles({ + toolCommands: [first, second], + outputRoot: testDir, + }); + + expect(auxiliaryFiles).toHaveLength(1); + const manifest = auxiliaryFiles[0]; + expect(manifest).toBeDefined(); + expect(manifest?.getRelativeDirPath()).toBe(".rovodev"); + expect(manifest?.getRelativeFilePath()).toBe("prompts.yml"); + expect(manifest?.isDeletable()).toBe(false); + + const parsed = load(manifest?.getFileContent() ?? ""); + expect(parsed).toEqual({ + prompts: [ + { name: "alpha", description: "Alpha prompt", content_file: "prompts/alpha.md" }, + { name: "zebra", description: "Zebra prompt", content_file: "prompts/zebra.md" }, + ], + }); + }); + + it("should ignore non-RovodevCommand tool commands", async () => { + const other = { relativeFilePath: "not-a-rovodev-command" } as unknown as ToolCommand; + + const auxiliaryFiles = await RovodevCommand.getAuxiliaryFiles({ + toolCommands: [other], + outputRoot: testDir, + }); + + expect(auxiliaryFiles).toEqual([]); + }); + + it("should fully replace the prompts array while preserving other top-level keys", async () => { + await ensureDir(join(testDir, ".rovodev")); + await writeFileContent( + join(testDir, ".rovodev", "prompts.yml"), + dump({ + some_other_setting: true, + prompts: [ + { name: "stale", description: "Stale prompt", content_file: "prompts/stale.md" }, + ], + }), + ); + + const command = new RovodevCommand({ + outputRoot: testDir, + relativeDirPath: join(".rovodev", "prompts"), + relativeFilePath: "review-pr.md", + name: "review-pr", + description: "Review a pull request", + body: "Body", + }); + + const auxiliaryFiles = await RovodevCommand.getAuxiliaryFiles({ + toolCommands: [command], + outputRoot: testDir, + }); + + const parsed = load(auxiliaryFiles[0]?.getFileContent() ?? ""); + expect(isRecord(parsed) && parsed.some_other_setting).toBe(true); + expect(parsed).toEqual({ + some_other_setting: true, + prompts: [ + { + name: "review-pr", + description: "Review a pull request", + content_file: "prompts/review-pr.md", + }, + ], + }); + }); + + it("should start fresh when the existing manifest cannot be parsed", async () => { + await ensureDir(join(testDir, ".rovodev")); + await writeFileContent(join(testDir, ".rovodev", "prompts.yml"), "not: [valid: yaml"); + + const command = new RovodevCommand({ + outputRoot: testDir, + relativeDirPath: join(".rovodev", "prompts"), + relativeFilePath: "review-pr.md", + name: "review-pr", + description: "Review a pull request", + body: "Body", + }); + + const auxiliaryFiles = await RovodevCommand.getAuxiliaryFiles({ + toolCommands: [command], + outputRoot: testDir, + }); + + const parsed = load(auxiliaryFiles[0]?.getFileContent() ?? ""); + expect(parsed).toEqual({ + prompts: [ + { + name: "review-pr", + description: "Review a pull request", + content_file: "prompts/review-pr.md", + }, + ], + }); + }); + }); +}); diff --git a/src/features/commands/rovodev-command.ts b/src/features/commands/rovodev-command.ts new file mode 100644 index 000000000..38d730834 --- /dev/null +++ b/src/features/commands/rovodev-command.ts @@ -0,0 +1,301 @@ +import { basename, join } from "node:path"; + +import { dump, load } from "js-yaml"; + +import { + ROVODEV_DIR, + ROVODEV_PROMPTS_DIR_PATH, + ROVODEV_PROMPTS_FILE_NAME, +} from "../../constants/rovodev-paths.js"; +import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { ToolFile } from "../../types/tool-file.js"; +import { readFileContent, readFileContentOrNull, toPosixPath } from "../../utils/file.js"; +import { stringifyFrontmatter } from "../../utils/frontmatter.js"; +import { isPlainObject, isRecord } from "../../utils/type-guards.js"; +import { RulesyncCommand, RulesyncCommandFrontmatter } from "./rulesync-command.js"; +import { + ToolCommand, + ToolCommandForDeletionParams, + ToolCommandFromFileParams, + ToolCommandFromRulesyncCommandParams, + ToolCommandSettablePaths, +} from "./tool-command.js"; + +export type RovodevCommandParams = { + name: string; + description: string; + body: string; +} & Omit; + +/** + * Rovo Dev CLI "saved prompts": a file-based custom-command surface made of a + * `prompts.yml` manifest (`{ name, description, content_file }` entries) plus + * per-prompt Markdown content files, discovered in repo-root `.rovodev/`, cwd + * `.rovodev/`, and global `~/.rovodev/`, and invoked via `/prompts [title] [extra]`. + * + * This class represents a single prompt's **content file** — pure Markdown, + * no frontmatter — written to `.rovodev/prompts/.md` (project) or + * `~/.rovodev/prompts/.md` (global). The `name`/`description` are kept + * on the instance (not serialized into the content file itself) so that + * {@link RovodevCommand.getAuxiliaryFiles} can build the sibling + * `prompts.yml` manifest that indexes every generated prompt. + * + * @see https://support.atlassian.com/rovo/docs/save-and-reuse-a-prompt-in-rovo-dev-cli/ + * @see https://support.atlassian.com/rovo/docs/rovo-dev-cli-commands/ + */ +export class RovodevCommand extends ToolCommand { + private readonly name: string; + private readonly description: string; + private readonly body: string; + + constructor({ name, description, body, ...rest }: RovodevCommandParams) { + super({ ...rest, fileContent: body }); + this.name = name; + this.description = description; + this.body = body; + + if (rest.validate) { + const result = this.validate(); + if (!result.success) { + throw result.error; + } + } + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolCommandSettablePaths { + return { + relativeDirPath: ROVODEV_PROMPTS_DIR_PATH, + }; + } + + getName(): string { + return this.name; + } + + getDescription(): string { + return this.description; + } + + getBody(): string { + return this.body; + } + + validate(): ValidationResult { + if (!this.name) { + return { + success: false, + error: new Error( + `${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: Rovo Dev saved-prompt name must not be empty`, + ), + }; + } + return { success: true, error: null }; + } + + toRulesyncCommand(): RulesyncCommand { + const rulesyncFrontmatter: RulesyncCommandFrontmatter = { + targets: ["*"], + description: this.description, + }; + const fileContent = stringifyFrontmatter(this.body, rulesyncFrontmatter); + + return new RulesyncCommand({ + outputRoot: ".", // RulesyncCommand outputRoot is always the project root directory + frontmatter: rulesyncFrontmatter, + body: this.body, + relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath, + relativeFilePath: this.getRelativeFilePath(), + fileContent, + validate: true, + }); + } + + static fromRulesyncCommand({ + outputRoot = process.cwd(), + rulesyncCommand, + validate = true, + global = false, + }: ToolCommandFromRulesyncCommandParams): RovodevCommand { + const rulesyncFrontmatter = rulesyncCommand.getFrontmatter(); + const relativeFilePath = rulesyncCommand.getRelativeFilePath(); + const name = basename(relativeFilePath, ".md"); + const paths = this.getSettablePaths({ global }); + + return new RovodevCommand({ + outputRoot, + name, + description: rulesyncFrontmatter.description ?? "", + body: rulesyncCommand.getBody(), + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + validate, + global, + }); + } + + static isTargetedByRulesyncCommand(rulesyncCommand: RulesyncCommand): boolean { + return this.isTargetedByRulesyncCommandDefault({ + rulesyncCommand, + toolTarget: "rovodev", + }); + } + + static async fromFile({ + outputRoot = process.cwd(), + relativeFilePath, + validate = true, + global = false, + }: ToolCommandFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath); + const body = (await readFileContent(filePath)).trim(); + const name = basename(relativeFilePath, ".md"); + const description = await lookupPromptDescription({ outputRoot, relativeFilePath, name }); + + return new RovodevCommand({ + outputRoot, + name, + description, + body, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + validate, + global, + }); + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + global = false, + }: ToolCommandForDeletionParams): RovodevCommand { + return new RovodevCommand({ + outputRoot, + name: basename(relativeFilePath, ".md"), + description: "", + body: "", + relativeDirPath, + relativeFilePath, + validate: false, + global, + }); + } + + /** + * Rebuilds `.rovodev/prompts.yml` (project) / `~/.rovodev/prompts.yml` + * (global) from every `RovodevCommand` generated in this pass. The `prompts` + * array is fully replaced with the current set of rulesync-managed prompts + * (mirrors `RovodevMcp` fully replacing `mcpServers`); any other top-level + * key in an existing manifest is preserved. + */ + static async getAuxiliaryFiles({ + toolCommands, + outputRoot = process.cwd(), + global = false, + }: { + toolCommands: ToolCommand[]; + outputRoot?: string; + global?: boolean; + }): Promise { + const rovodevCommands = toolCommands.filter( + (command): command is RovodevCommand => command instanceof RovodevCommand, + ); + if (rovodevCommands.length === 0) { + return []; + } + + const manifestPath = join(outputRoot, ROVODEV_DIR, ROVODEV_PROMPTS_FILE_NAME); + const existingContent = await readFileContentOrNull(manifestPath); + let existing: Record = {}; + if (existingContent) { + try { + const parsed = load(existingContent); + // `isPlainObject` (not `isRecord`) rejects class instances / non-plain + // objects for prototype-pollution hardening before this gets spread + // into a new object below, mirroring rovodev-mcp.ts's convention. + if (isPlainObject(parsed)) { + existing = parsed; + } + } catch { + // The existing manifest is not valid YAML; start fresh rather than + // throwing, consistent with how a corrupt file is handled elsewhere. + } + } + + const prompts = rovodevCommands + .map((command) => ({ + name: command.getName(), + description: command.getDescription(), + content_file: toPosixPath(join("prompts", command.getRelativeFilePath())), + })) + .toSorted((a, b) => a.name.localeCompare(b.name)); + + return [ + new RovodevPromptsManifest({ + outputRoot, + relativeDirPath: ROVODEV_DIR, + relativeFilePath: ROVODEV_PROMPTS_FILE_NAME, + fileContent: dump({ ...existing, prompts }), + global, + }), + ]; + } +} + +/** + * The `prompts` entry shape read back from `content_file`, matched against + * either the resolved path (relative to `prompts.yml`, i.e. the `.rovodev/` + * directory) or the entry `name`, to recover the `description` that isn't + * stored in the content file itself. + */ +async function lookupPromptDescription({ + outputRoot, + relativeFilePath, + name, +}: { + outputRoot: string; + relativeFilePath: string; + name: string; +}): Promise { + const manifestPath = join(outputRoot, ROVODEV_DIR, ROVODEV_PROMPTS_FILE_NAME); + const manifestContent = await readFileContentOrNull(manifestPath); + if (!manifestContent) { + return ""; + } + + let parsed: unknown; + try { + parsed = load(manifestContent); + } catch { + return ""; + } + + if (!isPlainObject(parsed) || !Array.isArray(parsed.prompts)) { + return ""; + } + + const expectedContentFile = toPosixPath(join("prompts", relativeFilePath)); + const entry = parsed.prompts.find( + (candidate: unknown) => + isRecord(candidate) && + (candidate.content_file === expectedContentFile || candidate.name === name), + ); + + return isRecord(entry) && typeof entry.description === "string" ? entry.description : ""; +} + +/** + * The shared `.rovodev/prompts.yml` manifest that indexes every saved prompt. + * Never deleted by orphan cleanup: it is regenerated (not individually + * discovered) alongside the per-prompt content files it references. + */ +export class RovodevPromptsManifest extends ToolFile { + override isDeletable(): boolean { + return false; + } + + validate(): ValidationResult { + return { success: true, error: null }; + } +} diff --git a/src/features/commands/tool-command.ts b/src/features/commands/tool-command.ts index 0c41dd3c9..db3d1b529 100644 --- a/src/features/commands/tool-command.ts +++ b/src/features/commands/tool-command.ts @@ -1,4 +1,5 @@ import { AiFile, AiFileFromFileParams, AiFileParams } from "../../types/ai-file.js"; +import type { ToolFile } from "../../types/tool-file.js"; import type { ToolTarget } from "../../types/tool-targets.js"; import type { RulesyncCommand } from "./rulesync-command.js"; @@ -71,6 +72,22 @@ export abstract class ToolCommand extends AiFile { throw new Error("Please implement this method in the subclass."); } + /** + * Optional hook for tools whose commands are not purely one-file-per-command + * (e.g. Rovo Dev's `prompts.yml` manifest that indexes every saved-prompt + * content file). Given the full set of ToolCommand instances just generated + * for this target, returns any additional shared/aggregate files that must + * be written alongside them. Most tools don't need this and inherit the + * empty default (mirrors `ToolHooks.getAuxiliaryFiles`). + */ + static async getAuxiliaryFiles(_params: { + toolCommands: ToolCommand[]; + outputRoot?: string; + global?: boolean; + }): Promise { + return []; + } + /** * Convert a RulesyncCommand to the tool-specific command format. * diff --git a/src/types/tool-target-tuples.ts b/src/types/tool-target-tuples.ts index ce6f8152f..16e47fca3 100644 --- a/src/types/tool-target-tuples.ts +++ b/src/types/tool-target-tuples.ts @@ -120,6 +120,7 @@ export const commandsProcessorToolTargetTuple = [ "qwencode", "reasonix", "roo", + "rovodev", "takt", "devin", ] as const;