From 1c0c1e876e0ee6cd293e0f6f41557a7ac9e49ee2 Mon Sep 17 00:00:00 2001 From: David Gray Date: Mon, 9 Mar 2026 11:08:16 +0700 Subject: [PATCH 1/3] feat: add support for JetBrains Junie commands Implements the JunieCommand class and integrates it into the CommandsProcessor to enable command generation and import for JetBrains Junie. This allows rulesync to manage AI commands stored in the .junie/commands directory, supporting both project-level and global configurations with specific frontmatter handling. --- README.md | 2 +- docs/reference/supported-tools.md | 2 +- skills/rulesync/supported-tools.md | 2 +- .../commands/commands-processor.test.ts | 23 +++ src/features/commands/commands-processor.ts | 15 ++ src/features/commands/junie-command.test.ts | 120 +++++++++++ src/features/commands/junie-command.ts | 191 ++++++++++++++++++ 7 files changed, 352 insertions(+), 3 deletions(-) create mode 100644 src/features/commands/junie-command.test.ts create mode 100644 src/features/commands/junie-command.ts diff --git a/README.md b/README.md index 64b520c59..241b5988e 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ See [Quick Start guide](https://dyoshikawa.github.io/rulesync/getting-started/qu | Qwen Code | qwencode | ✅ | ✅ | | | | | | | Kiro | kiro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | Google Antigravity | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | -| JetBrains Junie | junie | ✅ | ✅ | ✅ | | ✅ | ✅ | | +| JetBrains Junie | junie | ✅ | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | | AugmentCode | augmentcode | ✅ | ✅ | | | | | | | Windsurf | windsurf | ✅ | ✅ | | | | | | | Warp | warp | ✅ | | | | | | | diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 7b6170fab..4331a3433 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -20,7 +20,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Qwen Code | qwencode | ✅ | ✅ | | | | | | | Kiro | kiro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | Google Antigravity | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | -| JetBrains Junie | junie | ✅ | ✅ | ✅ | | ✅ | ✅ | | +| JetBrains Junie | junie | ✅ | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | | AugmentCode | augmentcode | ✅ | ✅ | | | | | | | Windsurf | windsurf | ✅ | ✅ | | | | | | | Warp | warp | ✅ | | | | | | | diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 7b6170fab..4331a3433 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -20,7 +20,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Qwen Code | qwencode | ✅ | ✅ | | | | | | | Kiro | kiro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | Google Antigravity | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | -| JetBrains Junie | junie | ✅ | ✅ | ✅ | | ✅ | ✅ | | +| JetBrains Junie | junie | ✅ | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | | AugmentCode | augmentcode | ✅ | ✅ | | | | | | | Windsurf | windsurf | ✅ | ✅ | | | | | | | Warp | warp | ✅ | | | | | | | diff --git a/src/features/commands/commands-processor.test.ts b/src/features/commands/commands-processor.test.ts index 8b1beaa85..cfa5f8016 100644 --- a/src/features/commands/commands-processor.test.ts +++ b/src/features/commands/commands-processor.test.ts @@ -11,6 +11,7 @@ import { ClineCommand } from "./cline-command.js"; import { CommandsProcessor, CommandsProcessorToolTarget } from "./commands-processor.js"; import { CursorCommand } from "./cursor-command.js"; import { GeminiCliCommand } from "./geminicli-command.js"; +import { JunieCommand } from "./junie-command.js"; import { KiloCommand } from "./kilo-command.js"; import { OpenCodeCommand } from "./opencode-command.js"; import { RooCommand } from "./roo-command.js"; @@ -52,6 +53,11 @@ vi.mock("./geminicli-command.js", () => ({ return { ...config, isDeletable: () => true }; }), })); +vi.mock("./junie-command.js", () => ({ + JunieCommand: vi.fn().mockImplementation(function (config) { + return { ...config, isDeletable: () => true }; + }), +})); vi.mock("./kilo-command.js", () => ({ KiloCommand: vi.fn().mockImplementation(function (config) { return { ...config, isDeletable: () => true }; @@ -114,6 +120,19 @@ vi.mocked(ClaudecodeCommand).forDeletion = vi.fn().mockImplementation((params) = getRelativeFilePath: () => params.relativeFilePath, })); +// Set up static methods after mocking +vi.mocked(JunieCommand).fromFile = vi.fn(); +vi.mocked(JunieCommand).fromRulesyncCommand = vi.fn(); +vi.mocked(JunieCommand).isTargetedByRulesyncCommand = vi.fn().mockReturnValue(true); +vi.mocked(JunieCommand).getSettablePaths = vi.fn().mockImplementation((_options = {}) => ({ + relativeDirPath: join(".junie", "commands"), +})); +vi.mocked(JunieCommand).forDeletion = vi.fn().mockImplementation((params) => ({ + ...params, + isDeletable: () => true, + getRelativeFilePath: () => params.relativeFilePath, +})); + // Set up static methods after mocking vi.mocked(GeminiCliCommand).fromFile = vi.fn(); vi.mocked(GeminiCliCommand).fromRulesyncCommand = vi.fn(); @@ -1168,6 +1187,7 @@ describe("CommandsProcessor", () => { "copilot", "cursor", "geminicli", + "junie", "kilo", "kiro", "opencode", @@ -1189,6 +1209,7 @@ describe("CommandsProcessor", () => { "cursor", "factorydroid", "geminicli", + "junie", "kilo", "kiro", "opencode", @@ -1209,6 +1230,7 @@ describe("CommandsProcessor", () => { "cursor", "factorydroid", "geminicli", + "junie", "codexcli", "kilo", "opencode", @@ -1244,6 +1266,7 @@ describe("CommandsProcessor", () => { "claudecode-legacy", "cline", "geminicli", + "junie", "kilo", "roo", ]; diff --git a/src/features/commands/commands-processor.ts b/src/features/commands/commands-processor.ts index 9b3842358..4ab28efcf 100644 --- a/src/features/commands/commands-processor.ts +++ b/src/features/commands/commands-processor.ts @@ -18,6 +18,7 @@ import { CopilotCommand } from "./copilot-command.js"; import { CursorCommand } from "./cursor-command.js"; import { FactorydroidCommand } from "./factorydroid-command.js"; import { GeminiCliCommand } from "./geminicli-command.js"; +import { JunieCommand } from "./junie-command.js"; import { KiloCommand } from "./kilo-command.js"; import { KiroCommand } from "./kiro-command.js"; import { OpenCodeCommand } from "./opencode-command.js"; @@ -72,6 +73,7 @@ const commandsProcessorToolTargetTuple = [ "cursor", "factorydroid", "geminicli", + "junie", "kilo", "kiro", "opencode", @@ -218,6 +220,19 @@ const toolCommandFactories = new Map { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("constructor", () => { + it("should create an instance with correct properties", () => { + const params = { + baseDir: "/project", + relativeDirPath: ".junie/commands", + relativeFilePath: "test-command.md", + frontmatter: { description: "Test description" }, + body: "Test body", + validate: true, + }; + + const command = new JunieCommand(params); + + expect(command.getRelativeDirPath()).toBe(".junie/commands"); + expect(command.getRelativeFilePath()).toBe("test-command.md"); + expect(command.getFrontmatter()).toEqual({ description: "Test description" }); + expect(command.getBody()).toBe("Test body"); + }); + + it("should throw error if frontmatter is invalid and validate is true", () => { + const params = { + baseDir: "/project", + relativeDirPath: ".junie/commands", + relativeFilePath: "test-command.md", + frontmatter: { description: 123 as any }, + body: "Test body", + validate: true, + }; + + expect(() => new JunieCommand(params)).toThrow(/Invalid frontmatter/); + }); + }); + + describe("getSettablePaths", () => { + it("should return correct paths", () => { + const paths = JunieCommand.getSettablePaths(); + expect(paths.relativeDirPath).toBe(join(".junie", "commands")); + }); + }); + + describe("toRulesyncCommand", () => { + it("should convert to RulesyncCommand correctly", () => { + const command = new JunieCommand({ + baseDir: "/project", + relativeDirPath: ".junie/commands", + relativeFilePath: "test-command.md", + frontmatter: { description: "Test description" }, + body: "Test body", + }); + + const rulesyncCommand = command.toRulesyncCommand(); + + expect(rulesyncCommand).toBeInstanceOf(RulesyncCommand); + expect(rulesyncCommand.getRelativeFilePath()).toBe("test-command.md"); + expect(rulesyncCommand.getFrontmatter()).toEqual({ + targets: ["*"], + description: "Test description", + }); + expect(rulesyncCommand.getBody()).toBe("Test body"); + }); + }); + + describe("fromRulesyncCommand", () => { + it("should create JunieCommand from RulesyncCommand", () => { + const rulesyncCommand = new RulesyncCommand({ + baseDir: "/project", + relativeDirPath: ".rulesync/command", + relativeFilePath: "test-command.md", + fileContent: "", + frontmatter: { + targets: ["junie"], + description: "Test description", + }, + body: "Test body", + }); + + const command = JunieCommand.fromRulesyncCommand({ + rulesyncCommand, + }); + + expect(command).toBeInstanceOf(JunieCommand); + expect(command.getRelativeDirPath()).toBe(join(".junie", "commands")); + expect(command.getRelativeFilePath()).toBe("test-command.md"); + expect(command.getFrontmatter()).toEqual({ description: "Test description" }); + expect(command.getBody()).toBe("Test body"); + }); + }); + + describe("fromFile", () => { + it("should load JunieCommand from file", async () => { + const fileContent = stringifyFrontmatter("Test body", { description: "Test description" }); + vi.mocked(readFileContent).mockResolvedValue(fileContent); + + const command = await JunieCommand.fromFile({ + baseDir: "/project", + relativeFilePath: "test-command.md", + }); + + expect(command).toBeInstanceOf(JunieCommand); + expect(command.getBody()).toBe("Test body"); + expect(command.getFrontmatter()).toEqual({ description: "Test description" }); + }); + }); +}); diff --git a/src/features/commands/junie-command.ts b/src/features/commands/junie-command.ts new file mode 100644 index 000000000..74f117b2d --- /dev/null +++ b/src/features/commands/junie-command.ts @@ -0,0 +1,191 @@ +import { join } from "node:path"; + +import { z } from "zod/mini"; + +import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContent } from "../../utils/file.js"; +import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter.js"; +import { RulesyncCommand, RulesyncCommandFrontmatter } from "./rulesync-command.js"; +import { + ToolCommand, + ToolCommandForDeletionParams, + ToolCommandFromFileParams, + ToolCommandFromRulesyncCommandParams, + ToolCommandSettablePaths, +} from "./tool-command.js"; + +// looseObject preserves unknown keys during parsing (like passthrough in Zod 3) +export const JunieCommandFrontmatterSchema = z.looseObject({ + description: z.optional(z.string()), +}); + +export type JunieCommandFrontmatter = z.infer; + +export type JunieCommandParams = { + frontmatter: JunieCommandFrontmatter; + body: string; +} & Omit; + +export class JunieCommand extends ToolCommand { + private readonly frontmatter: JunieCommandFrontmatter; + private readonly body: string; + + constructor({ frontmatter, body, ...rest }: JunieCommandParams) { + // Validate frontmatter before calling super to avoid validation order issues + if (rest.validate) { + const result = JunieCommandFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error( + `Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`, + ); + } + } + + super({ + ...rest, + fileContent: stringifyFrontmatter(body, frontmatter), + }); + + this.frontmatter = frontmatter; + this.body = body; + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolCommandSettablePaths { + return { + relativeDirPath: join(".junie", "commands"), + }; + } + + getBody(): string { + return this.body; + } + + getFrontmatter(): Record { + return this.frontmatter; + } + + toRulesyncCommand(): RulesyncCommand { + const { description, ...restFields } = this.frontmatter; + + const rulesyncFrontmatter: RulesyncCommandFrontmatter = { + targets: ["*"], + description, + // Preserve extra fields in junie section + ...(Object.keys(restFields).length > 0 && { junie: restFields }), + }; + + // Generate proper file content with Rulesync specific frontmatter + const fileContent = stringifyFrontmatter(this.body, rulesyncFrontmatter); + + return new RulesyncCommand({ + baseDir: process.cwd(), // RulesyncCommand baseDir is always the project root directory + frontmatter: rulesyncFrontmatter, + body: this.body, + relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath, + relativeFilePath: this.relativeFilePath, + fileContent, + validate: true, + }); + } + + static fromRulesyncCommand({ + baseDir = process.cwd(), + rulesyncCommand, + validate = true, + global = false, + }: ToolCommandFromRulesyncCommandParams): JunieCommand { + const rulesyncFrontmatter = rulesyncCommand.getFrontmatter(); + + // Merge junie-specific fields from rulesync frontmatter + const junieFields = rulesyncFrontmatter.junie ?? {}; + + const junieFrontmatter: JunieCommandFrontmatter = { + description: rulesyncFrontmatter.description, + ...junieFields, + }; + + // Generate proper file content with Junie specific frontmatter + const body = rulesyncCommand.getBody(); + + const paths = this.getSettablePaths({ global }); + + return new JunieCommand({ + baseDir: baseDir, + frontmatter: junieFrontmatter, + body, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: rulesyncCommand.getRelativeFilePath(), + validate, + }); + } + + validate(): ValidationResult { + // Check if frontmatter is set (may be undefined during construction) + if (!this.frontmatter) { + return { success: true, error: null }; + } + + const result = JunieCommandFrontmatterSchema.safeParse(this.frontmatter); + if (result.success) { + return { success: true, error: null }; + } else { + return { + success: false, + error: new Error( + `Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`, + ), + }; + } + } + + static isTargetedByRulesyncCommand(rulesyncCommand: RulesyncCommand): boolean { + return this.isTargetedByRulesyncCommandDefault({ + rulesyncCommand, + toolTarget: "junie", + }); + } + + static async fromFile({ + baseDir = process.cwd(), + relativeFilePath, + validate = true, + global = false, + }: ToolCommandFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(baseDir, paths.relativeDirPath, relativeFilePath); + // Read file content + const fileContent = await readFileContent(filePath); + const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath); + + // Validate required fields using JunieCommandFrontmatterSchema + const result = JunieCommandFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`); + } + + return new JunieCommand({ + baseDir: baseDir, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + frontmatter: result.data, + body: content.trim(), + validate, + }); + } + + static forDeletion({ + baseDir = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolCommandForDeletionParams): JunieCommand { + return new JunieCommand({ + baseDir, + relativeDirPath, + relativeFilePath, + frontmatter: { description: "" }, + body: "", + validate: false, + }); + } +} From 76d3fb6af82a922eaa50b8b327a57219f47c2745 Mon Sep 17 00:00:00 2001 From: David Gray Date: Mon, 9 Mar 2026 13:26:57 +0700 Subject: [PATCH 2/3] test: improve command class test coverage Adds test cases for body retrieval and validation skipping in Cline and Kilo commands. Refactors the Junie command test suite to use real file system utilities and provide comprehensive coverage for conversion logic and file loading. --- src/features/commands/cline-command.test.ts | 26 ++ src/features/commands/junie-command.test.ts | 306 +++++++++++++++++--- src/features/commands/kilo-command.test.ts | 41 +++ 3 files changed, 336 insertions(+), 37 deletions(-) diff --git a/src/features/commands/cline-command.test.ts b/src/features/commands/cline-command.test.ts index bb4a61e0c..0c731e15a 100644 --- a/src/features/commands/cline-command.test.ts +++ b/src/features/commands/cline-command.test.ts @@ -82,6 +82,32 @@ Step 1`; expect(command.getFileContent()).toBe(""); }); + + it("should skip validation when validate is false", () => { + const command = new ClineCommand({ + baseDir: testDir, + relativeDirPath: ".clinerules/workflows", + relativeFilePath: "test.md", + fileContent: validContent, + validate: false, + }); + + expect(command).toBeInstanceOf(ClineCommand); + expect(command.getFileContent()).toBe(validContent); + }); + }); + + describe("getBody", () => { + it("should return the command body", () => { + const command = new ClineCommand({ + baseDir: testDir, + relativeDirPath: ".clinerules/workflows", + relativeFilePath: "test.md", + fileContent: validContent, + }); + + expect(command.getBody()).toBe(validContent); + }); }); describe("toRulesyncCommand", () => { diff --git a/src/features/commands/junie-command.test.ts b/src/features/commands/junie-command.test.ts index c0ac95e93..18e215766 100644 --- a/src/features/commands/junie-command.test.ts +++ b/src/features/commands/junie-command.test.ts @@ -1,49 +1,145 @@ import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { readFileContent } from "../../utils/file.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; import { stringifyFrontmatter } from "../../utils/frontmatter.js"; import { JunieCommand } from "./junie-command.js"; import { RulesyncCommand } from "./rulesync-command.js"; -vi.mock("../../utils/file.js"); - describe("JunieCommand", () => { - afterEach(() => { - vi.clearAllMocks(); + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + const result = await setupTestDirectory(); + testDir = result.testDir; + cleanup = result.cleanup; + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); }); describe("constructor", () => { - it("should create an instance with correct properties", () => { - const params = { - baseDir: "/project", + it("should create a valid JunieCommand instance", () => { + const command = new JunieCommand({ + baseDir: testDir, relativeDirPath: ".junie/commands", - relativeFilePath: "test-command.md", - frontmatter: { description: "Test description" }, - body: "Test body", - validate: true, - }; + relativeFilePath: "test.md", + frontmatter: { description: "Test command" }, + body: "This is a test command body", + }); - const command = new JunieCommand(params); + expect(command).toBeInstanceOf(JunieCommand); + expect(command.getBody()).toBe("This is a test command body"); + expect(command.getFrontmatter()).toEqual({ description: "Test command" }); + }); - expect(command.getRelativeDirPath()).toBe(".junie/commands"); - expect(command.getRelativeFilePath()).toBe("test-command.md"); - expect(command.getFrontmatter()).toEqual({ description: "Test description" }); - expect(command.getBody()).toBe("Test body"); + it("should validate frontmatter during construction by default", () => { + expect(() => { + new JunieCommand({ + baseDir: testDir, + relativeDirPath: ".junie/commands", + relativeFilePath: "test.md", + frontmatter: { description: 123 as any }, + body: "This is a test command body", + validate: true, + }); + }).toThrow(/Invalid frontmatter/); }); - it("should throw error if frontmatter is invalid and validate is true", () => { - const params = { - baseDir: "/project", + it("should skip validation when validate is false", () => { + const command = new JunieCommand({ + baseDir: testDir, relativeDirPath: ".junie/commands", - relativeFilePath: "test-command.md", + relativeFilePath: "test.md", frontmatter: { description: 123 as any }, - body: "Test body", - validate: true, - }; + body: "This is a test command body", + validate: false, + }); + + expect(command).toBeInstanceOf(JunieCommand); + expect(command.getBody()).toBe("This is a test command body"); + }); + + it("should generate correct file content with frontmatter", () => { + const command = new JunieCommand({ + baseDir: testDir, + relativeDirPath: ".junie/commands", + relativeFilePath: "test.md", + frontmatter: { description: "Test command" }, + body: "This is a test command body", + }); - expect(() => new JunieCommand(params)).toThrow(/Invalid frontmatter/); + const fileContent = command.getFileContent(); + expect(fileContent).toContain("---"); + expect(fileContent).toContain("description: Test command"); + expect(fileContent).toContain("This is a test command body"); + }); + }); + + describe("getBody", () => { + it("should return the command body", () => { + const command = new JunieCommand({ + baseDir: testDir, + relativeDirPath: ".junie/commands", + relativeFilePath: "test.md", + frontmatter: { description: "Test command" }, + body: "Command body content", + }); + + expect(command.getBody()).toBe("Command body content"); + }); + }); + + describe("getFrontmatter", () => { + it("should return the frontmatter", () => { + const frontmatter = { description: "Test command description" }; + const command = new JunieCommand({ + baseDir: testDir, + relativeDirPath: ".junie/commands", + relativeFilePath: "test.md", + frontmatter, + body: "Command body", + }); + + expect(command.getFrontmatter()).toEqual(frontmatter); + }); + }); + + describe("validate", () => { + it("should return success for valid frontmatter", () => { + const command = new JunieCommand({ + baseDir: testDir, + relativeDirPath: ".junie/commands", + relativeFilePath: "test.md", + frontmatter: { description: "Valid description" }, + body: "Body", + validate: false, + }); + + const result = command.validate(); + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + }); + + it("should return error for invalid frontmatter", () => { + const command = new JunieCommand({ + baseDir: testDir, + relativeDirPath: ".junie/commands", + relativeFilePath: "test.md", + frontmatter: { description: 123 as any }, + body: "Body", + validate: false, + }); + + const result = command.validate(); + expect(result.success).toBe(false); + expect(result.error?.message).toMatch(/Invalid frontmatter/); }); }); @@ -57,9 +153,9 @@ describe("JunieCommand", () => { describe("toRulesyncCommand", () => { it("should convert to RulesyncCommand correctly", () => { const command = new JunieCommand({ - baseDir: "/project", + baseDir: testDir, relativeDirPath: ".junie/commands", - relativeFilePath: "test-command.md", + relativeFilePath: "test.md", frontmatter: { description: "Test description" }, body: "Test body", }); @@ -67,19 +163,40 @@ describe("JunieCommand", () => { const rulesyncCommand = command.toRulesyncCommand(); expect(rulesyncCommand).toBeInstanceOf(RulesyncCommand); - expect(rulesyncCommand.getRelativeFilePath()).toBe("test-command.md"); + expect(rulesyncCommand.getRelativeFilePath()).toBe("test.md"); expect(rulesyncCommand.getFrontmatter()).toEqual({ targets: ["*"], description: "Test description", }); expect(rulesyncCommand.getBody()).toBe("Test body"); }); + + it("should preserve extra fields in junie section", () => { + const command = new JunieCommand({ + baseDir: testDir, + relativeDirPath: ".junie/commands", + relativeFilePath: "test.md", + frontmatter: { + description: "Test description", + extra: "field", + } as any, + body: "Test body", + }); + + const rulesyncCommand = command.toRulesyncCommand(); + + expect(rulesyncCommand.getFrontmatter()).toEqual({ + targets: ["*"], + description: "Test description", + junie: { extra: "field" }, + }); + }); }); describe("fromRulesyncCommand", () => { it("should create JunieCommand from RulesyncCommand", () => { const rulesyncCommand = new RulesyncCommand({ - baseDir: "/project", + baseDir: testDir, relativeDirPath: ".rulesync/command", relativeFilePath: "test-command.md", fileContent: "", @@ -100,21 +217,136 @@ describe("JunieCommand", () => { expect(command.getFrontmatter()).toEqual({ description: "Test description" }); expect(command.getBody()).toBe("Test body"); }); + + it("should merge junie-specific fields from rulesync frontmatter", () => { + const rulesyncCommand = new RulesyncCommand({ + baseDir: testDir, + relativeDirPath: ".rulesync/command", + relativeFilePath: "test-command.md", + fileContent: "", + frontmatter: { + targets: ["junie"], + description: "Test description", + junie: { extra: "field" }, + }, + body: "Test body", + }); + + const command = JunieCommand.fromRulesyncCommand({ + rulesyncCommand, + }); + + expect(command.getFrontmatter()).toEqual({ + description: "Test description", + extra: "field", + }); + }); }); describe("fromFile", () => { it("should load JunieCommand from file", async () => { - const fileContent = stringifyFrontmatter("Test body", { description: "Test description" }); - vi.mocked(readFileContent).mockResolvedValue(fileContent); + const relativeDirPath = join(".junie", "commands"); + const relativeFilePath = "test-command.md"; + const body = "Test body"; + const frontmatter = { description: "Test description" }; + const fileContent = stringifyFrontmatter(body, frontmatter); + + await ensureDir(join(testDir, relativeDirPath)); + await writeFileContent(join(testDir, relativeDirPath, relativeFilePath), fileContent); const command = await JunieCommand.fromFile({ - baseDir: "/project", - relativeFilePath: "test-command.md", + baseDir: testDir, + relativeFilePath, }); expect(command).toBeInstanceOf(JunieCommand); - expect(command.getBody()).toBe("Test body"); - expect(command.getFrontmatter()).toEqual({ description: "Test description" }); + expect(command.getBody()).toBe(body); + expect(command.getFrontmatter()).toEqual(frontmatter); + }); + + it("should throw error if frontmatter in file is invalid", async () => { + const relativeDirPath = join(".junie", "commands"); + const relativeFilePath = "invalid-command.md"; + const fileContent = stringifyFrontmatter("Body", { description: 123 as any }); + + await ensureDir(join(testDir, relativeDirPath)); + await writeFileContent(join(testDir, relativeDirPath, relativeFilePath), fileContent); + + await expect( + JunieCommand.fromFile({ + baseDir: testDir, + relativeFilePath, + }), + ).rejects.toThrow(/Invalid frontmatter/); + }); + }); + + describe("isTargetedByRulesyncCommand", () => { + it("should return true if targets includes *", () => { + const rulesyncCommand = new RulesyncCommand({ + baseDir: testDir, + relativeDirPath: ".rulesync/command", + relativeFilePath: "test.md", + fileContent: "", + frontmatter: { targets: ["*"], description: "Test" }, + body: "Body", + }); + + expect(JunieCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true); + }); + + it("should return true if targets includes junie", () => { + const rulesyncCommand = new RulesyncCommand({ + baseDir: testDir, + relativeDirPath: ".rulesync/command", + relativeFilePath: "test.md", + fileContent: "", + frontmatter: { targets: ["junie"], description: "Test" }, + body: "Body", + }); + + expect(JunieCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true); + }); + + it("should return false if targets does not include junie or *", () => { + const rulesyncCommand = new RulesyncCommand({ + baseDir: testDir, + relativeDirPath: ".rulesync/command", + relativeFilePath: "test.md", + fileContent: "", + frontmatter: { targets: ["claudecode"], description: "Test" }, + body: "Body", + }); + + expect(JunieCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(false); + }); + + it("should return true if targets is undefined", () => { + const rulesyncCommand = new RulesyncCommand({ + baseDir: testDir, + relativeDirPath: ".rulesync/command", + relativeFilePath: "test.md", + fileContent: "", + frontmatter: { targets: undefined, description: "Test" } as any, + body: "Body", + }); + + expect(JunieCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true); + }); + }); + + describe("forDeletion", () => { + it("should create a minimal JunieCommand for deletion", () => { + const command = JunieCommand.forDeletion({ + baseDir: testDir, + relativeDirPath: ".junie/commands", + relativeFilePath: "test.md", + }); + + expect(command).toBeInstanceOf(JunieCommand); + expect(command.getRelativeDirPath()).toBe(".junie/commands"); + expect(command.getRelativeFilePath()).toBe("test.md"); + expect(command.getBody()).toBe(""); }); }); }); diff --git a/src/features/commands/kilo-command.test.ts b/src/features/commands/kilo-command.test.ts index c6f24685b..744df0c78 100644 --- a/src/features/commands/kilo-command.test.ts +++ b/src/features/commands/kilo-command.test.ts @@ -45,6 +45,47 @@ Step 1`; }); }); + describe("constructor", () => { + it("should create instance with valid content", () => { + const command = new KiloCommand({ + baseDir: testDir, + relativeDirPath: ".kilocode/workflows", + relativeFilePath: "test.md", + fileContent: validContent, + validate: true, + }); + + expect(command).toBeInstanceOf(KiloCommand); + expect(command.getFileContent()).toBe(validContent); + }); + + it("should skip validation when validate is false", () => { + const command = new KiloCommand({ + baseDir: testDir, + relativeDirPath: ".kilocode/workflows", + relativeFilePath: "test.md", + fileContent: validContent, + validate: false, + }); + + expect(command).toBeInstanceOf(KiloCommand); + expect(command.getFileContent()).toBe(validContent); + }); + }); + + describe("getBody", () => { + it("should return the command body", () => { + const command = new KiloCommand({ + baseDir: testDir, + relativeDirPath: ".kilocode/workflows", + relativeFilePath: "test.md", + fileContent: validContent, + }); + + expect(command.getBody()).toBe(validContent); + }); + }); + describe("toRulesyncCommand", () => { it("should convert to RulesyncCommand with default frontmatter", () => { const kiloCommand = new KiloCommand({ From 88be2709e432f4aee1f5c1c0502d46195ec53aed Mon Sep 17 00:00:00 2001 From: David Gray Date: Mon, 9 Mar 2026 14:13:08 +0700 Subject: [PATCH 3/3] test: improve Junie command test coverage Adds test cases to verify handling of extra frontmatter fields, global path resolution, and default frontmatter initialization. Includes an explanatory comment in the source code regarding the current directory structure logic for JetBrains Junie commands. --- src/features/commands/junie-command.test.ts | 23 ++++++++++++++++++++- src/features/commands/junie-command.ts | 3 +++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/features/commands/junie-command.test.ts b/src/features/commands/junie-command.test.ts index 18e215766..fef3787ab 100644 --- a/src/features/commands/junie-command.test.ts +++ b/src/features/commands/junie-command.test.ts @@ -141,6 +141,21 @@ describe("JunieCommand", () => { expect(result.success).toBe(false); expect(result.error?.message).toMatch(/Invalid frontmatter/); }); + + it("should return success for frontmatter with extra fields", () => { + const command = new JunieCommand({ + baseDir: testDir, + relativeDirPath: ".junie/commands", + relativeFilePath: "test.md", + frontmatter: { description: "Test", extra: "field" } as any, + body: "Body", + validate: false, + }); + + const result = command.validate(); + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + }); }); describe("getSettablePaths", () => { @@ -148,6 +163,11 @@ describe("JunieCommand", () => { const paths = JunieCommand.getSettablePaths(); expect(paths.relativeDirPath).toBe(join(".junie", "commands")); }); + + it("should return the same paths even if global is true", () => { + const paths = JunieCommand.getSettablePaths({ global: true }); + expect(paths.relativeDirPath).toBe(join(".junie", "commands")); + }); }); describe("toRulesyncCommand", () => { @@ -314,7 +334,7 @@ describe("JunieCommand", () => { relativeDirPath: ".rulesync/command", relativeFilePath: "test.md", fileContent: "", - frontmatter: { targets: ["claudecode"], description: "Test" }, + frontmatter: { targets: ["other-tool"] as any, description: "Test" }, body: "Body", }); @@ -347,6 +367,7 @@ describe("JunieCommand", () => { expect(command.getRelativeDirPath()).toBe(".junie/commands"); expect(command.getRelativeFilePath()).toBe("test.md"); expect(command.getBody()).toBe(""); + expect(command.getFrontmatter()).toEqual({ description: "" }); }); }); }); diff --git a/src/features/commands/junie-command.ts b/src/features/commands/junie-command.ts index 74f117b2d..7895cf8c6 100644 --- a/src/features/commands/junie-command.ts +++ b/src/features/commands/junie-command.ts @@ -52,6 +52,9 @@ export class JunieCommand extends ToolCommand { } static getSettablePaths(_options: { global?: boolean } = {}): ToolCommandSettablePaths { + // JetBrains Junie (AI Assistant) currently stores commands in the project's .junie directory. + // If a future version of Junie introduces a global-scope directory (e.g., under user home), + // this method should branch based on the global option. return { relativeDirPath: join(".junie", "commands"), };