From 57aa97eea223be5e2d53f300de87bf85e815ea99 Mon Sep 17 00:00:00 2001 From: "wanzhang.zxh" Date: Wed, 6 May 2026 09:37:54 +0800 Subject: [PATCH 1/2] feat(qoder): add Qoder rules support (1/4) Add Qoder (https://qoder.com) as a new tool target with rules support. Qoder is an AI coding tool developed by Alibaba Group, designed for enterprise-grade intelligent code generation and development assistance. - Add "qoder" to ALL_TOOL_TARGETS - Add qoder-specific fields to RulesyncRuleFrontmatterSchema - Implement QoderRule adapter for .qoder/rules/*.md with YAML frontmatter - Smart trigger inference maps rulesync canonical fields to Qoder's four native trigger modes: always_on, glob, model_decision, manual - Register QoderRule in rules-processor - Add .qoder/rules/ gitignore entry Qoder documentation: https://docs.qoder.com/user-guide/rules Co-authored-by: Cursor AI-Contributed/Feature: 0/285 AI-Contributed/UT: 0/501 --- src/cli/commands/gitignore-entries.ts | 3 + src/features/rules/qoder-rule.test.ts | 500 ++++++++++++++++++++++++++ src/features/rules/qoder-rule.ts | 260 ++++++++++++++ src/features/rules/rules-processor.ts | 13 + src/features/rules/rulesync-rule.ts | 8 + src/types/tool-targets.test.ts | 1 + src/types/tool-targets.ts | 1 + 7 files changed, 786 insertions(+) create mode 100644 src/features/rules/qoder-rule.test.ts create mode 100644 src/features/rules/qoder-rule.ts diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index e9bd52f8..c4719d4a 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -220,6 +220,9 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ { target: "pi", feature: "commands", entry: "**/.pi/prompts/" }, { target: "pi", feature: "skills", entry: "**/.pi/skills/" }, + // Qoder + { target: "qoder", feature: "rules", entry: "**/.qoder/rules/" }, + // Qwen Code { target: "qwencode", feature: "rules", entry: "**/QWEN.md" }, { target: "qwencode", feature: "general", entry: "**/.qwen/memories/" }, diff --git a/src/features/rules/qoder-rule.test.ts b/src/features/rules/qoder-rule.test.ts new file mode 100644 index 00000000..a4572a8a --- /dev/null +++ b/src/features/rules/qoder-rule.test.ts @@ -0,0 +1,500 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { RULESYNC_RULES_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { QoderRule } from "./qoder-rule.js"; +import { RulesyncRule } from "./rulesync-rule.js"; + +describe("QoderRule", () => { + 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 instance with frontmatter and body", () => { + const rule = new QoderRule({ + relativeDirPath: ".qoder/rules", + relativeFilePath: "test-rule.md", + frontmatter: { trigger: "always_on", alwaysApply: true }, + body: "# Test Rule\n\nThis is a test qoder rule.", + }); + + expect(rule).toBeInstanceOf(QoderRule); + expect(rule.getRelativeDirPath()).toBe(".qoder/rules"); + expect(rule.getRelativeFilePath()).toBe("test-rule.md"); + expect(rule.getFrontmatter()).toEqual({ trigger: "always_on", alwaysApply: true }); + expect(rule.getBody()).toBe("# Test Rule\n\nThis is a test qoder rule."); + }); + + it("should generate file content with YAML frontmatter", () => { + const rule = new QoderRule({ + relativeDirPath: ".qoder/rules", + relativeFilePath: "test-rule.md", + frontmatter: { trigger: "always_on", alwaysApply: true, description: "Test rule" }, + body: "# Test Rule", + }); + + const content = rule.getFileContent(); + expect(content).toContain("---"); + expect(content).toContain("trigger: always_on"); + expect(content).toContain("alwaysApply: true"); + expect(content).toContain("description: Test rule"); + expect(content).toContain("# Test Rule"); + }); + + it("should create instance with empty frontmatter", () => { + const rule = new QoderRule({ + relativeDirPath: ".qoder/rules", + relativeFilePath: "test-rule.md", + frontmatter: {}, + body: "# Test Rule", + }); + + expect(rule).toBeInstanceOf(QoderRule); + expect(rule.getFrontmatter()).toEqual({}); + }); + + it("should create instance with custom outputRoot", () => { + const rule = new QoderRule({ + outputRoot: "/custom/path", + relativeDirPath: ".qoder/rules", + relativeFilePath: "custom-rule.md", + frontmatter: {}, + body: "# Custom Rule", + }); + + expect(rule.getFilePath()).toBe("/custom/path/.qoder/rules/custom-rule.md"); + }); + }); + + describe("getSettablePaths", () => { + it("should return correct paths for nonRoot", () => { + const paths = QoderRule.getSettablePaths(); + expect(paths.nonRoot).toEqual({ + relativeDirPath: ".qoder/rules", + }); + }); + }); + + describe("fromRulesyncRule", () => { + it("should use explicit qoder trigger when set", () => { + const rulesyncRule = new RulesyncRule({ + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "source-rule.md", + frontmatter: { + root: false, + targets: ["*"], + description: "Test description", + globs: [], + qoder: { + trigger: "always_on", + alwaysApply: true, + }, + }, + body: "# Source Rule\n\nThis is a source rule.", + }); + + const qoderRule = QoderRule.fromRulesyncRule({ rulesyncRule }); + + expect(qoderRule).toBeInstanceOf(QoderRule); + expect(qoderRule.getRelativeDirPath()).toBe(".qoder/rules"); + expect(qoderRule.getRelativeFilePath()).toBe("source-rule.md"); + expect(qoderRule.getFrontmatter().trigger).toBe("always_on"); + expect(qoderRule.getFrontmatter().alwaysApply).toBe(true); + expect(qoderRule.getFrontmatter().description).toBeUndefined(); + expect(qoderRule.getBody()).toBe("# Source Rule\n\nThis is a source rule."); + }); + + it("should use explicit qoder trigger: glob with glob field", () => { + const rulesyncRule = new RulesyncRule({ + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "source-rule.md", + frontmatter: { + root: false, + targets: ["*"], + globs: [], + qoder: { + trigger: "glob", + globs: ["*.java", "*.kt"], + }, + }, + body: "# Source Rule", + }); + + const qoderRule = QoderRule.fromRulesyncRule({ rulesyncRule }); + expect(qoderRule.getFrontmatter().trigger).toBe("glob"); + expect(qoderRule.getFrontmatter().glob).toBe("*.java,*.kt"); + }); + + it("should use explicit qoder trigger: model_decision with description", () => { + const rulesyncRule = new RulesyncRule({ + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "source-rule.md", + frontmatter: { + root: false, + targets: ["*"], + globs: [], + qoder: { + trigger: "model_decision", + description: "Qoder specific desc", + }, + }, + body: "# Source Rule", + }); + + const qoderRule = QoderRule.fromRulesyncRule({ rulesyncRule }); + expect(qoderRule.getFrontmatter().trigger).toBe("model_decision"); + expect(qoderRule.getFrontmatter().description).toBe("Qoder specific desc"); + }); + + it("should infer always_on from cursor.alwaysApply", () => { + const rulesyncRule = new RulesyncRule({ + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "source-rule.md", + frontmatter: { + root: false, + targets: ["*"], + globs: ["**/*"], + cursor: { alwaysApply: true, globs: ["**/*"] }, + }, + body: "# Always On Rule", + }); + + const qoderRule = QoderRule.fromRulesyncRule({ rulesyncRule }); + expect(qoderRule.getFrontmatter().trigger).toBe("always_on"); + expect(qoderRule.getFrontmatter().alwaysApply).toBe(true); + expect(qoderRule.getFrontmatter().glob).toBeUndefined(); + expect(qoderRule.getFrontmatter().description).toBeUndefined(); + }); + + it("should infer always_on from catch-all globs without cursor section", () => { + const rulesyncRule = new RulesyncRule({ + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "source-rule.md", + frontmatter: { + root: false, + targets: ["*"], + globs: ["**/*"], + }, + body: "# Always On Rule", + }); + + const qoderRule = QoderRule.fromRulesyncRule({ rulesyncRule }); + expect(qoderRule.getFrontmatter().trigger).toBe("always_on"); + expect(qoderRule.getFrontmatter().alwaysApply).toBe(true); + }); + + it("should infer glob from specific globs", () => { + const rulesyncRule = new RulesyncRule({ + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "source-rule.md", + frontmatter: { + root: false, + targets: ["*"], + globs: ["*.ts", "*.js"], + }, + body: "# Glob Rule", + }); + + const qoderRule = QoderRule.fromRulesyncRule({ rulesyncRule }); + expect(qoderRule.getFrontmatter().trigger).toBe("glob"); + expect(qoderRule.getFrontmatter().glob).toBe("*.ts,*.js"); + expect(qoderRule.getFrontmatter().alwaysApply).toBeUndefined(); + }); + + it("should infer model_decision from description with empty globs", () => { + const rulesyncRule = new RulesyncRule({ + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "source-rule.md", + frontmatter: { + root: false, + targets: ["*"], + description: "开发参考:API 模式、代码示例", + globs: [], + }, + body: "# Dev Reference", + }); + + const qoderRule = QoderRule.fromRulesyncRule({ rulesyncRule }); + expect(qoderRule.getFrontmatter().trigger).toBe("model_decision"); + expect(qoderRule.getFrontmatter().description).toBe("开发参考:API 模式、代码示例"); + expect(qoderRule.getFrontmatter().glob).toBeUndefined(); + }); + + it("should infer manual when no description and no globs", () => { + const rulesyncRule = new RulesyncRule({ + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "source-rule.md", + frontmatter: { + root: false, + targets: ["*"], + globs: [], + }, + body: "# Manual Rule", + }); + + const qoderRule = QoderRule.fromRulesyncRule({ rulesyncRule }); + expect(qoderRule.getFrontmatter().trigger).toBe("manual"); + expect(qoderRule.getFrontmatter().alwaysApply).toBe(false); + expect(qoderRule.getFrontmatter().glob).toBeUndefined(); + expect(qoderRule.getFrontmatter().description).toBeUndefined(); + }); + + it("should infer from qoder.alwaysApply without explicit trigger", () => { + const rulesyncRule = new RulesyncRule({ + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "source-rule.md", + frontmatter: { + root: false, + targets: ["*"], + globs: [], + qoder: { alwaysApply: true }, + }, + body: "# Source Rule", + }); + + const qoderRule = QoderRule.fromRulesyncRule({ rulesyncRule }); + expect(qoderRule.getFrontmatter().trigger).toBe("always_on"); + expect(qoderRule.getFrontmatter().alwaysApply).toBe(true); + }); + + it("should prefer qoder.globs over common globs for inference", () => { + const rulesyncRule = new RulesyncRule({ + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "source-rule.md", + frontmatter: { + root: false, + targets: ["*"], + globs: ["**/*"], + qoder: { globs: ["*.java"] }, + }, + body: "# Source Rule", + }); + + const qoderRule = QoderRule.fromRulesyncRule({ rulesyncRule }); + expect(qoderRule.getFrontmatter().trigger).toBe("glob"); + expect(qoderRule.getFrontmatter().glob).toBe("*.java"); + }); + + it("should create QoderRule with custom outputRoot", () => { + const rulesyncRule = new RulesyncRule({ + outputRoot: "/source/path", + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "source-rule.md", + frontmatter: { + root: false, + targets: ["*"], + description: "", + globs: [], + }, + body: "# Source Rule", + }); + + const qoderRule = QoderRule.fromRulesyncRule({ + outputRoot: "/target/path", + rulesyncRule, + }); + + expect(qoderRule.getFilePath()).toBe("/target/path/.qoder/rules/source-rule.md"); + }); + }); + + describe("fromFile", () => { + it("should create QoderRule from file with frontmatter", async () => { + const rulesDir = join(testDir, ".qoder", "rules"); + await ensureDir(rulesDir); + await writeFileContent( + join(rulesDir, "test-rule.md"), + "---\ntrigger: always_on\nalwaysApply: true\n---\n# Test Rule\n\nThis is a test rule from file.", + ); + + const rule = await QoderRule.fromFile({ + outputRoot: testDir, + relativeFilePath: "test-rule.md", + }); + + expect(rule).toBeInstanceOf(QoderRule); + expect(rule.getFrontmatter().trigger).toBe("always_on"); + expect(rule.getFrontmatter().alwaysApply).toBe(true); + expect(rule.getBody()).toBe("# Test Rule\n\nThis is a test rule from file."); + }); + + it("should handle file content without frontmatter", async () => { + const rulesDir = join(testDir, ".qoder", "rules"); + await ensureDir(rulesDir); + await writeFileContent( + join(rulesDir, "no-frontmatter.md"), + "# Simple Rule\n\nNo frontmatter here.", + ); + + const rule = await QoderRule.fromFile({ + outputRoot: testDir, + relativeFilePath: "no-frontmatter.md", + }); + + expect(rule.getFrontmatter()).toEqual({}); + expect(rule.getBody()).toBe("# Simple Rule\n\nNo frontmatter here."); + }); + + it("should throw error when file does not exist", async () => { + await expect( + QoderRule.fromFile({ + outputRoot: testDir, + relativeFilePath: "nonexistent-rule.md", + }), + ).rejects.toThrow(); + }); + }); + + describe("toRulesyncRule", () => { + it("should convert QoderRule to RulesyncRule preserving frontmatter", () => { + const rule = new QoderRule({ + outputRoot: testDir, + relativeDirPath: ".qoder/rules", + relativeFilePath: "test-rule.md", + frontmatter: { trigger: "always_on", alwaysApply: true, description: "Test" }, + body: "# Test Rule\n\nThis is a test rule.", + }); + + const rulesyncRule = rule.toRulesyncRule(); + + expect(rulesyncRule).toBeInstanceOf(RulesyncRule); + expect(rulesyncRule.getRelativeDirPath()).toBe(RULESYNC_RULES_RELATIVE_DIR_PATH); + expect(rulesyncRule.getRelativeFilePath()).toBe("test-rule.md"); + expect(rulesyncRule.getBody()).toBe("# Test Rule\n\nThis is a test rule."); + expect(rulesyncRule.getFrontmatter().description).toBe("Test"); + expect(rulesyncRule.getFrontmatter().qoder?.trigger).toBe("always_on"); + expect(rulesyncRule.getFrontmatter().qoder?.alwaysApply).toBe(true); + }); + + it("should set globs to **/* when alwaysApply is true", () => { + const rule = new QoderRule({ + outputRoot: testDir, + relativeDirPath: ".qoder/rules", + relativeFilePath: "test-rule.md", + frontmatter: { alwaysApply: true }, + body: "# Test Rule", + }); + + const rulesyncRule = rule.toRulesyncRule(); + expect(rulesyncRule.getFrontmatter().globs).toEqual(["**/*"]); + }); + + it("should parse comma-separated glob", () => { + const rule = new QoderRule({ + outputRoot: testDir, + relativeDirPath: ".qoder/rules", + relativeFilePath: "test-rule.md", + frontmatter: { trigger: "glob", glob: "*.ts, *.js" }, + body: "# Test Rule", + }); + + const rulesyncRule = rule.toRulesyncRule(); + expect(rulesyncRule.getFrontmatter().globs).toEqual(["*.ts", "*.js"]); + }); + }); + + describe("validate", () => { + it("should return successful validation for valid frontmatter", () => { + const rule = new QoderRule({ + relativeDirPath: ".qoder/rules", + relativeFilePath: "test-rule.md", + frontmatter: { trigger: "always_on", alwaysApply: true }, + body: "# Test Rule", + validate: false, + }); + + const result = rule.validate(); + expect(result.success).toBe(true); + expect(result.error).toBe(null); + }); + + it("should return successful validation for empty frontmatter", () => { + const rule = new QoderRule({ + relativeDirPath: ".qoder/rules", + relativeFilePath: "test-rule.md", + frontmatter: {}, + body: "", + validate: false, + }); + + const result = rule.validate(); + expect(result.success).toBe(true); + expect(result.error).toBe(null); + }); + }); + + describe("isTargetedByRulesyncRule", () => { + it("should return true for rules targeting qoder", () => { + const rulesyncRule = new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: ".qoder/rules", + relativeFilePath: "test.md", + frontmatter: { targets: ["qoder"] }, + body: "Test content", + }); + + expect(QoderRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(true); + }); + + it("should return true for rules targeting all tools (*)", () => { + const rulesyncRule = new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: ".qoder/rules", + relativeFilePath: "test.md", + frontmatter: { targets: ["*"] }, + body: "Test content", + }); + + expect(QoderRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(true); + }); + + it("should return false for rules not targeting qoder", () => { + const rulesyncRule = new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: ".qoder/rules", + relativeFilePath: "test.md", + frontmatter: { targets: ["cursor", "copilot"] }, + body: "Test content", + }); + + expect(QoderRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(false); + }); + + it("should return false for empty targets", () => { + const rulesyncRule = new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: ".qoder/rules", + relativeFilePath: "test.md", + frontmatter: { targets: [] }, + body: "Test content", + }); + + expect(QoderRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(false); + }); + }); + + describe("forDeletion", () => { + it("should create a QoderRule instance for deletion", () => { + const rule = QoderRule.forDeletion({ + outputRoot: testDir, + relativeDirPath: ".qoder/rules", + relativeFilePath: "to-delete.md", + }); + + expect(rule).toBeInstanceOf(QoderRule); + }); + }); +}); diff --git a/src/features/rules/qoder-rule.ts b/src/features/rules/qoder-rule.ts new file mode 100644 index 00000000..18717a76 --- /dev/null +++ b/src/features/rules/qoder-rule.ts @@ -0,0 +1,260 @@ +import { join } from "node:path"; + +import { z } from "zod/mini"; + +import { RULESYNC_RULES_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import type { RulesyncTargets } from "../../types/tool-targets.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContent } from "../../utils/file.js"; +import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter.js"; +import { RulesyncRule, RulesyncRuleFrontmatter } from "./rulesync-rule.js"; +import { + ToolRule, + ToolRuleForDeletionParams, + ToolRuleFromFileParams, + ToolRuleFromRulesyncRuleParams, + ToolRuleSettablePaths, + buildToolPath, +} from "./tool-rule.js"; + +export const QoderRuleFrontmatterSchema = z.looseObject({ + trigger: z.optional(z.string()), + alwaysApply: z.optional(z.boolean()), + description: z.optional(z.string()), + glob: z.optional(z.string()), +}); + +export type QoderRuleFrontmatter = z.infer; + +export type QoderRuleParams = { + frontmatter: QoderRuleFrontmatter; + body: string; +} & Omit; + +export type QoderRuleSettablePaths = Omit & { + nonRoot: { + relativeDirPath: string; + }; +}; + +export class QoderRule extends ToolRule { + private readonly frontmatter: QoderRuleFrontmatter; + private readonly body: string; + + static getSettablePaths( + _options: { + global?: boolean; + excludeToolDir?: boolean; + } = {}, + ): QoderRuleSettablePaths { + return { + nonRoot: { + relativeDirPath: buildToolPath(".qoder", "rules", _options.excludeToolDir), + }, + }; + } + + constructor({ frontmatter, body, ...rest }: QoderRuleParams) { + if (rest.validate) { + const result = QoderRuleFrontmatterSchema.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, { avoidBlockScalars: true }), + }); + + this.frontmatter = frontmatter; + this.body = body; + } + + toRulesyncRule(): RulesyncRule { + const targets: RulesyncTargets = ["*"]; + + const isAlways = this.frontmatter.alwaysApply === true; + const hasGlob = this.frontmatter.glob && this.frontmatter.glob.trim() !== ""; + + let globs: string[]; + if (hasGlob && this.frontmatter.glob) { + globs = this.frontmatter.glob + .split(",") + .map((g) => g.trim()) + .filter((g) => g.length > 0); + } else if (isAlways) { + globs = ["**/*"]; + } else { + globs = []; + } + + const rulesyncFrontmatter: RulesyncRuleFrontmatter = { + targets, + root: false, + description: this.frontmatter.description, + globs, + qoder: { + trigger: this.frontmatter.trigger, + alwaysApply: this.frontmatter.alwaysApply, + description: this.frontmatter.description, + globs: globs.length > 0 ? globs : undefined, + }, + }; + + return new RulesyncRule({ + frontmatter: rulesyncFrontmatter, + body: this.body, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: this.relativeFilePath, + validate: true, + }); + } + + /** + * Infer the best Qoder trigger mode from rulesync canonical frontmatter. + * + * Priority: explicit qoder section > cursor hints > common fields. + * + * Mapping: + * alwaysApply / catch-all globs → trigger: always_on + * specific globs → trigger: glob + * description only → trigger: model_decision + * fallback → trigger: manual + */ + static fromRulesyncRule({ + outputRoot = process.cwd(), + rulesyncRule, + validate = true, + }: ToolRuleFromRulesyncRuleParams): QoderRule { + const fm = rulesyncRule.getFrontmatter(); + + // If the qoder section already has an explicit trigger, honour it directly + if (fm.qoder?.trigger) { + const qoderFrontmatter: QoderRuleFrontmatter = { + trigger: fm.qoder.trigger, + ...(fm.qoder.alwaysApply !== undefined && { alwaysApply: fm.qoder.alwaysApply }), + ...(fm.qoder.description !== undefined && { description: fm.qoder.description }), + ...(fm.qoder.globs !== undefined && { glob: fm.qoder.globs.join(",") }), + }; + + return new QoderRule({ + outputRoot, + frontmatter: qoderFrontmatter, + body: rulesyncRule.getBody(), + relativeDirPath: this.getSettablePaths().nonRoot.relativeDirPath, + relativeFilePath: rulesyncRule.getRelativeFilePath(), + validate, + }); + } + + // Infer from common / cursor fields + const alwaysApply = fm.qoder?.alwaysApply ?? fm.cursor?.alwaysApply; + const globs = fm.qoder?.globs ?? fm.globs; + const description = fm.qoder?.description ?? fm.description; + + const isCatchAll = + globs != null && globs.length > 0 && globs.every((g) => g === "**/*" || g === "**"); + const hasSpecificGlobs = globs != null && globs.length > 0 && !isCatchAll; + + let qoderFrontmatter: QoderRuleFrontmatter; + + if (alwaysApply === true || isCatchAll) { + qoderFrontmatter = { trigger: "always_on", alwaysApply: true }; + } else if (hasSpecificGlobs) { + qoderFrontmatter = { trigger: "glob", glob: globs!.join(",") }; + } else if (description) { + qoderFrontmatter = { trigger: "model_decision", description }; + } else { + qoderFrontmatter = { trigger: "manual", alwaysApply: false }; + } + + return new QoderRule({ + outputRoot, + frontmatter: qoderFrontmatter, + body: rulesyncRule.getBody(), + relativeDirPath: this.getSettablePaths().nonRoot.relativeDirPath, + relativeFilePath: rulesyncRule.getRelativeFilePath(), + validate, + }); + } + + static async fromFile({ + outputRoot = process.cwd(), + relativeFilePath, + validate = true, + }: ToolRuleFromFileParams): Promise { + const filePath = join( + outputRoot, + this.getSettablePaths().nonRoot.relativeDirPath, + relativeFilePath, + ); + const fileContent = await readFileContent(filePath); + const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath); + + const result = QoderRuleFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`); + } + + return new QoderRule({ + outputRoot, + relativeDirPath: this.getSettablePaths().nonRoot.relativeDirPath, + relativeFilePath, + frontmatter: result.data, + body: content.trim(), + validate, + }); + } + + validate(): ValidationResult { + if (!this.frontmatter) { + return { success: true, error: null }; + } + + const result = QoderRuleFrontmatterSchema.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)}`, + ), + }; + } + } + + getFrontmatter(): QoderRuleFrontmatter { + return this.frontmatter; + } + + getBody(): string { + return this.body; + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolRuleForDeletionParams): QoderRule { + return new QoderRule({ + outputRoot, + relativeDirPath, + relativeFilePath, + frontmatter: {}, + body: "", + validate: false, + }); + } + + static isTargetedByRulesyncRule(rulesyncRule: RulesyncRule): boolean { + return this.isTargetedByRulesyncRuleDefault({ + rulesyncRule, + toolTarget: "qoder", + }); + } +} diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index 790dfbbc..7983064c 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -51,6 +51,7 @@ import { KiloRule } from "./kilo-rule.js"; import { KiroRule } from "./kiro-rule.js"; import { OpenCodeRule } from "./opencode-rule.js"; import { PiRule } from "./pi-rule.js"; +import { QoderRule } from "./qoder-rule.js"; import { QwencodeRule } from "./qwencode-rule.js"; import { ReplitRule } from "./replit-rule.js"; import { RooRule } from "./roo-rule.js"; @@ -89,6 +90,7 @@ const rulesProcessorToolTargets: ToolTarget[] = [ "kiro", "opencode", "pi", + "qoder", "qwencode", "replit", "roo", @@ -470,6 +472,17 @@ const toolRuleFactories = new Map([ }, }, ], + [ + "qoder", + { + class: QoderRule, + meta: { + extension: "md", + supportsGlobal: false, + ruleDiscoveryMode: "auto", + }, + }, + ], [ "qwencode", { diff --git a/src/features/rules/rulesync-rule.ts b/src/features/rules/rulesync-rule.ts index d4220377..c7c945d8 100644 --- a/src/features/rules/rulesync-rule.ts +++ b/src/features/rules/rulesync-rule.ts @@ -43,6 +43,14 @@ export const RulesyncRuleFrontmatterSchema = z.object({ globs: z.optional(z.array(z.string())), }), ), + qoder: z.optional( + z.looseObject({ + trigger: z.optional(z.string()), + alwaysApply: z.optional(z.boolean()), + description: z.optional(z.string()), + globs: z.optional(z.array(z.string())), + }), + ), copilot: z.optional( z.looseObject({ excludeAgent: z.optional(z.union([z.literal("code-review"), z.literal("coding-agent")])), diff --git a/src/types/tool-targets.test.ts b/src/types/tool-targets.test.ts index 4a98bdf3..f2049983 100644 --- a/src/types/tool-targets.test.ts +++ b/src/types/tool-targets.test.ts @@ -33,6 +33,7 @@ describe("tool targets", () => { "kiro", "opencode", "pi", + "qoder", "qwencode", "replit", "roo", diff --git a/src/types/tool-targets.ts b/src/types/tool-targets.ts index 8f7688ca..a8acffc9 100644 --- a/src/types/tool-targets.ts +++ b/src/types/tool-targets.ts @@ -25,6 +25,7 @@ export const ALL_TOOL_TARGETS = [ "kiro", "opencode", "pi", + "qoder", "qwencode", "replit", "roo", From 24b349e90bfc7383dda1c42e97ffeeda8c962d0c Mon Sep 17 00:00:00 2001 From: "wanzhang.zxh" Date: Wed, 6 May 2026 09:39:08 +0800 Subject: [PATCH 2/2] feat(qoder): add Qoder MCP and commands support (2/4) Add MCP and commands support for the Qoder tool target. - Implement QoderMcp adapter for .qoder/mcp.json - Implement QoderCommand adapter for .qoder/commands/*.md - Register both in their respective processors - Add gitignore entries for .qoder/mcp.json and .qoder/commands/ Co-authored-by: Cursor AI-Contributed/Feature: 0/304 AI-Contributed/UT: 0/457 --- src/cli/commands/gitignore-entries.ts | 2 + .../commands/commands-processor.test.ts | 2 + src/features/commands/commands-processor.ts | 15 ++ src/features/commands/qoder-command.test.ts | 235 ++++++++++++++++++ src/features/commands/qoder-command.ts | 181 ++++++++++++++ src/features/mcp/mcp-processor.ts | 14 ++ src/features/mcp/qoder-mcp.test.ts | 220 ++++++++++++++++ src/features/mcp/qoder-mcp.ts | 92 +++++++ 8 files changed, 761 insertions(+) create mode 100644 src/features/commands/qoder-command.test.ts create mode 100644 src/features/commands/qoder-command.ts create mode 100644 src/features/mcp/qoder-mcp.test.ts create mode 100644 src/features/mcp/qoder-mcp.ts diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index c4719d4a..e409f206 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -222,6 +222,8 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ // Qoder { target: "qoder", feature: "rules", entry: "**/.qoder/rules/" }, + { target: "qoder", feature: "mcp", entry: "**/.qoder/mcp.json" }, + { target: "qoder", feature: "commands", entry: "**/.qoder/commands/" }, // Qwen Code { target: "qwencode", feature: "rules", entry: "**/QWEN.md" }, diff --git a/src/features/commands/commands-processor.test.ts b/src/features/commands/commands-processor.test.ts index 7fbe7941..bef2446a 100644 --- a/src/features/commands/commands-processor.test.ts +++ b/src/features/commands/commands-processor.test.ts @@ -1146,6 +1146,7 @@ describe("CommandsProcessor", () => { "kiro", "opencode", "pi", + "qoder", "roo", "takt", ]), @@ -1170,6 +1171,7 @@ describe("CommandsProcessor", () => { "kiro", "opencode", "pi", + "qoder", "roo", "takt", ]), diff --git a/src/features/commands/commands-processor.ts b/src/features/commands/commands-processor.ts index 1a2cfc47..7b51ed11 100644 --- a/src/features/commands/commands-processor.ts +++ b/src/features/commands/commands-processor.ts @@ -23,6 +23,7 @@ import { KiloCommand } from "./kilo-command.js"; import { KiroCommand } from "./kiro-command.js"; import { OpenCodeCommand } from "./opencode-command.js"; import { PiCommand } from "./pi-command.js"; +import { QoderCommand } from "./qoder-command.js"; import { RooCommand } from "./roo-command.js"; import { RulesyncCommand } from "./rulesync-command.js"; import { TaktCommand } from "./takt-command.js"; @@ -80,6 +81,7 @@ const commandsProcessorToolTargetTuple = [ "kiro", "opencode", "pi", + "qoder", "roo", "takt", ] as const; @@ -289,6 +291,19 @@ const toolCommandFactories = new Map { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + const testSetup = await setupTestDirectory(); + testDir = testSetup.testDir; + cleanup = testSetup.cleanup; + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("should return correct paths for qoder commands", () => { + const paths = QoderCommand.getSettablePaths(); + expect(paths).toEqual({ + relativeDirPath: join(".qoder", "commands"), + }); + }); + }); + + describe("constructor", () => { + it("should create instance with valid content and frontmatter", () => { + const command = new QoderCommand({ + outputRoot: testDir, + relativeDirPath: ".qoder/commands", + relativeFilePath: "test-command.md", + frontmatter: { description: "Test description" }, + body: "This is the body of the qoder command.\nIt can be multiline.", + validate: true, + }); + + expect(command).toBeInstanceOf(QoderCommand); + expect(command.getBody()).toBe( + "This is the body of the qoder command.\nIt can be multiline.", + ); + expect(command.getFrontmatter()).toEqual({ description: "Test description" }); + }); + + it("should create instance with empty frontmatter", () => { + const command = new QoderCommand({ + outputRoot: testDir, + relativeDirPath: ".qoder/commands", + relativeFilePath: "test-command.md", + frontmatter: {}, + body: "Body content", + validate: true, + }); + + expect(command).toBeInstanceOf(QoderCommand); + expect(command.getBody()).toBe("Body content"); + }); + + it("should generate correct file content with frontmatter", () => { + const command = new QoderCommand({ + outputRoot: testDir, + relativeDirPath: ".qoder/commands", + relativeFilePath: "test.md", + frontmatter: { description: "Test qoder command" }, + body: "This is a test command body", + }); + + const fileContent = command.getFileContent(); + expect(fileContent).toContain("---"); + expect(fileContent).toContain("description: Test qoder command"); + expect(fileContent).toContain("This is a test command body"); + }); + }); + + describe("fromFile", () => { + it("should create instance from valid file", async () => { + const commandsDir = join(testDir, ".qoder", "commands"); + await ensureDir(commandsDir); + const content = `--- +description: Test command from file +--- + +This is a test command from file.`; + await writeFileContent(join(commandsDir, "test-command.md"), content); + + const command = await QoderCommand.fromFile({ + outputRoot: testDir, + relativeFilePath: "test-command.md", + }); + + expect(command).toBeInstanceOf(QoderCommand); + expect(command.getBody()).toBe("This is a test command from file."); + expect(command.getFrontmatter()).toEqual({ description: "Test command from file" }); + }); + + it("should throw error when file does not exist", async () => { + await expect( + QoderCommand.fromFile({ + outputRoot: testDir, + relativeFilePath: "nonexistent.md", + }), + ).rejects.toThrow(); + }); + }); + + describe("toRulesyncCommand", () => { + it("should convert to RulesyncCommand", () => { + const command = new QoderCommand({ + outputRoot: testDir, + relativeDirPath: ".qoder/commands", + relativeFilePath: "test-command.md", + frontmatter: { description: "Test description" }, + body: "Test command body", + validate: true, + }); + + const rulesyncCommand = command.toRulesyncCommand(); + + expect(rulesyncCommand).toBeInstanceOf(RulesyncCommand); + expect(rulesyncCommand.getRelativeFilePath()).toBe("test-command.md"); + expect(rulesyncCommand.getBody()).toBe("Test command body"); + expect(rulesyncCommand.getFrontmatter().description).toBe("Test description"); + }); + }); + + describe("fromRulesyncCommand", () => { + it("should create QoderCommand from RulesyncCommand", () => { + const rulesyncCommand = new RulesyncCommand({ + outputRoot: testDir, + frontmatter: { + targets: ["*"], + description: "Rulesync command description", + }, + body: "Rulesync command body", + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test-command.md", + fileContent: "", + validate: false, + }); + + const command = QoderCommand.fromRulesyncCommand({ + outputRoot: testDir, + rulesyncCommand, + }); + + expect(command).toBeInstanceOf(QoderCommand); + expect(command.getBody()).toBe("Rulesync command body"); + expect(command.getFrontmatter()).toEqual({ + description: "Rulesync command description", + }); + }); + }); + + describe("isTargetedByRulesyncCommand", () => { + it("should return true for commands targeting qoder", () => { + const rulesyncCommand = new RulesyncCommand({ + outputRoot: testDir, + frontmatter: { targets: ["qoder"], description: "test" }, + body: "Test", + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + fileContent: "", + validate: false, + }); + + expect(QoderCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true); + }); + + it("should return true for commands targeting all (*)", () => { + const rulesyncCommand = new RulesyncCommand({ + outputRoot: testDir, + frontmatter: { targets: ["*"], description: "test" }, + body: "Test", + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + fileContent: "", + validate: false, + }); + + expect(QoderCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true); + }); + + it("should return false for commands not targeting qoder", () => { + const rulesyncCommand = new RulesyncCommand({ + outputRoot: testDir, + frontmatter: { targets: ["cursor"], description: "test" }, + body: "Test", + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + fileContent: "", + validate: false, + }); + + expect(QoderCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(false); + }); + }); + + describe("validate", () => { + it("should return successful validation for valid frontmatter", () => { + const command = new QoderCommand({ + outputRoot: testDir, + relativeDirPath: ".qoder/commands", + relativeFilePath: "test.md", + frontmatter: { description: "Valid command" }, + body: "Test body", + validate: false, + }); + + const result = command.validate(); + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + }); + }); + + describe("forDeletion", () => { + it("should create a QoderCommand instance for deletion", () => { + const command = QoderCommand.forDeletion({ + outputRoot: testDir, + relativeDirPath: ".qoder/commands", + relativeFilePath: "to-delete.md", + }); + + expect(command).toBeInstanceOf(QoderCommand); + }); + }); +}); diff --git a/src/features/commands/qoder-command.ts b/src/features/commands/qoder-command.ts new file mode 100644 index 00000000..4c41406c --- /dev/null +++ b/src/features/commands/qoder-command.ts @@ -0,0 +1,181 @@ +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"; + +export const QoderCommandFrontmatterSchema = z.looseObject({ + description: z.optional(z.string()), +}); + +export type QoderCommandFrontmatter = z.infer; + +export type QoderCommandParams = { + frontmatter: QoderCommandFrontmatter; + body: string; +} & Omit; + +export class QoderCommand extends ToolCommand { + private readonly frontmatter: QoderCommandFrontmatter; + private readonly body: string; + + constructor({ frontmatter, body, ...rest }: QoderCommandParams) { + if (rest.validate) { + const result = QoderCommandFrontmatterSchema.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, { avoidBlockScalars: true }), + }); + + this.frontmatter = frontmatter; + this.body = body; + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolCommandSettablePaths { + return { + relativeDirPath: join(".qoder", "commands"), + }; + } + + getBody(): string { + return this.body; + } + + getFrontmatter(): Record { + return this.frontmatter; + } + + toRulesyncCommand(): RulesyncCommand { + const { description, ...restFields } = this.frontmatter; + + const rulesyncFrontmatter: RulesyncCommandFrontmatter = { + targets: ["*"], + description, + ...(Object.keys(restFields).length > 0 && { qoder: restFields }), + }; + + const fileContent = stringifyFrontmatter(this.body, rulesyncFrontmatter); + + return new RulesyncCommand({ + outputRoot: ".", + frontmatter: rulesyncFrontmatter, + body: this.body, + relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath, + relativeFilePath: this.relativeFilePath, + fileContent, + validate: true, + }); + } + + static fromRulesyncCommand({ + outputRoot = process.cwd(), + rulesyncCommand, + validate = true, + global = false, + }: ToolCommandFromRulesyncCommandParams): QoderCommand { + const rulesyncFrontmatter = rulesyncCommand.getFrontmatter(); + const qoderFields = rulesyncFrontmatter.qoder ?? {}; + + const qoderFrontmatter: QoderCommandFrontmatter = { + ...(rulesyncFrontmatter.description && { description: rulesyncFrontmatter.description }), + ...qoderFields, + }; + + const body = rulesyncCommand.getBody(); + const paths = this.getSettablePaths({ global }); + + return new QoderCommand({ + outputRoot: outputRoot, + frontmatter: qoderFrontmatter, + body, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: rulesyncCommand.getRelativeFilePath(), + validate, + }); + } + + validate(): ValidationResult { + if (!this.frontmatter) { + return { success: true, error: null }; + } + + const result = QoderCommandFrontmatterSchema.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: "qoder", + }); + } + + 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 fileContent = await readFileContent(filePath); + const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath); + + const result = QoderCommandFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`); + } + + return new QoderCommand({ + outputRoot: outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + frontmatter: result.data, + body: content.trim(), + validate, + }); + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolCommandForDeletionParams): QoderCommand { + return new QoderCommand({ + outputRoot, + relativeDirPath, + relativeFilePath, + frontmatter: {}, + body: "", + validate: false, + }); + } +} diff --git a/src/features/mcp/mcp-processor.ts b/src/features/mcp/mcp-processor.ts index aec200c7..7d86114a 100644 --- a/src/features/mcp/mcp-processor.ts +++ b/src/features/mcp/mcp-processor.ts @@ -20,6 +20,7 @@ import { JunieMcp } from "./junie-mcp.js"; import { KiloMcp } from "./kilo-mcp.js"; import { KiroMcp } from "./kiro-mcp.js"; import { OpencodeMcp } from "./opencode-mcp.js"; +import { QoderMcp } from "./qoder-mcp.js"; import { RooMcp } from "./roo-mcp.js"; import { RovodevMcp } from "./rovodev-mcp.js"; import { RulesyncMcp } from "./rulesync-mcp.js"; @@ -49,6 +50,7 @@ const mcpProcessorToolTargetTuple = [ "kilo", "kiro", "junie", + "qoder", "opencode", "roo", "rovodev", @@ -257,6 +259,18 @@ const toolMcpFactories = new Map([ }, }, ], + [ + "qoder", + { + class: QoderMcp, + meta: { + supportsProject: true, + supportsGlobal: false, + supportsEnabledTools: false, + supportsDisabledTools: false, + }, + }, + ], [ "roo", { diff --git a/src/features/mcp/qoder-mcp.test.ts b/src/features/mcp/qoder-mcp.test.ts new file mode 100644 index 00000000..ecd5a38b --- /dev/null +++ b/src/features/mcp/qoder-mcp.test.ts @@ -0,0 +1,220 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + RULESYNC_MCP_SCHEMA_URL, + RULESYNC_RELATIVE_DIR_PATH, +} from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { QoderMcp } from "./qoder-mcp.js"; +import { RulesyncMcp } from "./rulesync-mcp.js"; + +describe("QoderMcp", () => { + 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 instance with valid JSON content", () => { + const jsonContent = JSON.stringify({ + mcpServers: { + "test-server": { command: "node", args: ["server.js"] }, + }, + }); + + const mcp = new QoderMcp({ + relativeDirPath: ".qoder", + relativeFilePath: "mcp.json", + fileContent: jsonContent, + }); + + expect(mcp).toBeInstanceOf(QoderMcp); + expect(mcp.getRelativeDirPath()).toBe(".qoder"); + expect(mcp.getRelativeFilePath()).toBe("mcp.json"); + expect(mcp.getFileContent()).toBe(jsonContent); + }); + + it("should parse JSON content correctly", () => { + const jsonData = { + mcpServers: { + "test-server": { command: "node", args: ["server.js"], env: { NODE_ENV: "dev" } }, + }, + }; + + const mcp = new QoderMcp({ + relativeDirPath: ".qoder", + relativeFilePath: "mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + expect(mcp.getJson()).toEqual(jsonData); + }); + + it("should handle empty JSON object", () => { + const mcp = new QoderMcp({ + relativeDirPath: ".qoder", + relativeFilePath: "mcp.json", + fileContent: JSON.stringify({}), + }); + + expect(mcp.getJson()).toEqual({}); + }); + + it("should throw error for invalid JSON content", () => { + expect(() => { + const _instance = new QoderMcp({ + relativeDirPath: ".qoder", + relativeFilePath: "mcp.json", + fileContent: "{ invalid json }", + }); + }).toThrow(); + }); + }); + + describe("getSettablePaths", () => { + it("should return correct paths", () => { + const paths = QoderMcp.getSettablePaths(); + expect(paths).toEqual({ + relativeDirPath: ".qoder", + relativeFilePath: "mcp.json", + }); + }); + }); + + describe("fromFile", () => { + it("should create instance from file", async () => { + const qoderDir = join(testDir, ".qoder"); + await ensureDir(qoderDir); + const jsonData = { + mcpServers: { + filesystem: { command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem"] }, + }, + }; + await writeFileContent(join(qoderDir, "mcp.json"), JSON.stringify(jsonData, null, 2)); + + const mcp = await QoderMcp.fromFile({ outputRoot: testDir }); + + expect(mcp).toBeInstanceOf(QoderMcp); + expect(mcp.getJson()).toEqual(jsonData); + expect(mcp.getFilePath()).toBe(join(testDir, ".qoder", "mcp.json")); + }); + + it("should throw error if file does not exist", async () => { + await expect(QoderMcp.fromFile({ outputRoot: testDir })).rejects.toThrow(); + }); + }); + + describe("fromRulesyncMcp", () => { + it("should create instance from RulesyncMcp", () => { + const jsonData = { + mcpServers: { + "test-server": { command: "node", args: ["test-server.js"] }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const mcp = QoderMcp.fromRulesyncMcp({ rulesyncMcp }); + + expect(mcp).toBeInstanceOf(QoderMcp); + expect(mcp.getJson()).toEqual(jsonData); + expect(mcp.getRelativeDirPath()).toBe(".qoder"); + expect(mcp.getRelativeFilePath()).toBe("mcp.json"); + }); + + it("should create instance with custom outputRoot", () => { + const jsonData = { mcpServers: {} }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const mcp = QoderMcp.fromRulesyncMcp({ + outputRoot: "/target/dir", + rulesyncMcp, + }); + + expect(mcp.getFilePath()).toBe("/target/dir/.qoder/mcp.json"); + }); + }); + + describe("toRulesyncMcp", () => { + it("should convert to RulesyncMcp", () => { + const jsonData = { + mcpServers: { + filesystem: { command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem"] }, + }, + }; + const mcp = new QoderMcp({ + relativeDirPath: ".qoder", + relativeFilePath: "mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const rulesyncMcp = mcp.toRulesyncMcp(); + + expect(rulesyncMcp).toBeInstanceOf(RulesyncMcp); + expect(JSON.parse(rulesyncMcp.getFileContent())).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + ...jsonData, + }); + }); + }); + + describe("validate", () => { + it("should always return successful validation", () => { + const mcp = new QoderMcp({ + relativeDirPath: ".qoder", + relativeFilePath: "mcp.json", + fileContent: JSON.stringify({ mcpServers: {} }), + validate: false, + }); + + const result = mcp.validate(); + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + }); + }); + + describe("integration", () => { + it("should handle complete workflow: fromFile -> toRulesyncMcp -> fromRulesyncMcp", async () => { + const qoderDir = join(testDir, ".qoder"); + await ensureDir(qoderDir); + const originalJsonData = { + mcpServers: { + "workflow-server": { + command: "node", + args: ["workflow-server.js"], + env: { NODE_ENV: "test" }, + }, + }, + }; + await writeFileContent(join(qoderDir, "mcp.json"), JSON.stringify(originalJsonData, null, 2)); + + const originalMcp = await QoderMcp.fromFile({ outputRoot: testDir }); + const rulesyncMcp = originalMcp.toRulesyncMcp(); + const newMcp = QoderMcp.fromRulesyncMcp({ outputRoot: testDir, rulesyncMcp }); + + expect(newMcp.getJson()).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + ...originalJsonData, + }); + expect(newMcp.getFilePath()).toBe(join(testDir, ".qoder", "mcp.json")); + }); + }); +}); diff --git a/src/features/mcp/qoder-mcp.ts b/src/features/mcp/qoder-mcp.ts new file mode 100644 index 00000000..3f75ee1d --- /dev/null +++ b/src/features/mcp/qoder-mcp.ts @@ -0,0 +1,92 @@ +import { join } from "node:path"; + +import { ValidationResult } from "../../types/ai-file.js"; +import { readFileContent } from "../../utils/file.js"; +import { RulesyncMcp } from "./rulesync-mcp.js"; +import { + ToolMcp, + ToolMcpForDeletionParams, + ToolMcpFromFileParams, + ToolMcpFromRulesyncMcpParams, + ToolMcpParams, + ToolMcpSettablePaths, +} from "./tool-mcp.js"; + +export type QoderMcpParams = ToolMcpParams; + +export class QoderMcp extends ToolMcp { + private readonly json: Record; + + constructor(params: ToolMcpParams) { + super(params); + this.json = this.fileContent !== undefined ? JSON.parse(this.fileContent) : {}; + } + + getJson(): Record { + return this.json; + } + + static getSettablePaths(): ToolMcpSettablePaths { + return { + relativeDirPath: ".qoder", + relativeFilePath: "mcp.json", + }; + } + + static async fromFile({ + outputRoot = process.cwd(), + validate = true, + }: ToolMcpFromFileParams): Promise { + const fileContent = await readFileContent( + join( + outputRoot, + this.getSettablePaths().relativeDirPath, + this.getSettablePaths().relativeFilePath, + ), + ); + + return new QoderMcp({ + outputRoot, + relativeDirPath: this.getSettablePaths().relativeDirPath, + relativeFilePath: this.getSettablePaths().relativeFilePath, + fileContent, + validate, + }); + } + + static fromRulesyncMcp({ + outputRoot = process.cwd(), + rulesyncMcp, + validate = true, + }: ToolMcpFromRulesyncMcpParams): QoderMcp { + return new QoderMcp({ + outputRoot, + relativeDirPath: this.getSettablePaths().relativeDirPath, + relativeFilePath: this.getSettablePaths().relativeFilePath, + fileContent: rulesyncMcp.getFileContent(), + validate, + }); + } + + toRulesyncMcp(): RulesyncMcp { + return this.toRulesyncMcpDefault(); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolMcpForDeletionParams): QoderMcp { + return new QoderMcp({ + outputRoot, + relativeDirPath, + relativeFilePath, + fileContent: "{}", + validate: false, + }); + } +}