diff --git a/src/features/commands/agentsmd-command.test.ts b/src/features/commands/agentsmd-command.test.ts index 5c8a2f57a..7c6ca3fac 100644 --- a/src/features/commands/agentsmd-command.test.ts +++ b/src/features/commands/agentsmd-command.test.ts @@ -21,8 +21,7 @@ This is the body of the agentsmd command. It can be multiline.`; const invalidMarkdownContent = `--- -# Missing required description field -invalid: true +description: 123 --- Body content`; @@ -105,20 +104,18 @@ Body content`; expect(command).toBeInstanceOf(AgentsmdCommand); }); - it("should throw error for invalid frontmatter when validation is enabled", () => { - expect( - () => - new AgentsmdCommand({ - baseDir: testDir, - relativeDirPath: ".agents/commands", - relativeFilePath: "invalid-command.md", - frontmatter: { - // Missing required description field - } as SimulatedCommandFrontmatter, - body: "Body content", - validate: true, - }), - ).toThrow(); + it("should accept frontmatter without description (description is optional)", () => { + const command = new AgentsmdCommand({ + baseDir: testDir, + relativeDirPath: ".agents/commands", + relativeFilePath: "no-desc-command.md", + frontmatter: {} as SimulatedCommandFrontmatter, + body: "Body content", + validate: true, + }); + + expect(command).toBeInstanceOf(AgentsmdCommand); + expect(command.getFrontmatter().description).toBeUndefined(); }); }); @@ -320,19 +317,20 @@ Body content`; ).rejects.toThrow(); }); - it("should handle file without frontmatter", async () => { + it("should handle file without frontmatter (description is optional)", async () => { const commandsDir = join(testDir, ".agents", "commands"); const filePath = join(commandsDir, "no-frontmatter.md"); await writeFileContent(filePath, markdownWithoutFrontmatter); - await expect( - AgentsmdCommand.fromFile({ - baseDir: testDir, - relativeFilePath: "no-frontmatter.md", - validate: true, - }), - ).rejects.toThrow(); + const command = await AgentsmdCommand.fromFile({ + baseDir: testDir, + relativeFilePath: "no-frontmatter.md", + validate: true, + }); + + expect(command).toBeInstanceOf(AgentsmdCommand); + expect(command.getFrontmatter().description).toBeUndefined(); }); }); diff --git a/src/features/commands/antigravity-command.ts b/src/features/commands/antigravity-command.ts index bf7b87705..5a2be0fc1 100644 --- a/src/features/commands/antigravity-command.ts +++ b/src/features/commands/antigravity-command.ts @@ -23,7 +23,7 @@ const AntigravityWorkflowFrontmatterSchema = z.looseObject({ // looseObject preserves unknown keys during parsing (like passthrough in Zod 3) export const AntigravityCommandFrontmatterSchema = z.looseObject({ - description: z.string(), + description: z.optional(z.string()), // Support for workflow-specific configuration ...AntigravityWorkflowFrontmatterSchema.shape, }); diff --git a/src/features/commands/claudecode-command.test.ts b/src/features/commands/claudecode-command.test.ts index a2ff6e398..ce344ee85 100644 --- a/src/features/commands/claudecode-command.test.ts +++ b/src/features/commands/claudecode-command.test.ts @@ -580,11 +580,14 @@ Roundtrip body`; } }); - it("should reject frontmatter without description", () => { - const invalidFrontmatter = {}; - const result = ClaudecodeCommandFrontmatterSchema.safeParse(invalidFrontmatter); + it("should accept frontmatter without description (description is optional)", () => { + const frontmatter = {}; + const result = ClaudecodeCommandFrontmatterSchema.safeParse(frontmatter); - expect(result.success).toBe(false); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.description).toBeUndefined(); + } }); it("should reject frontmatter with non-string description", () => { diff --git a/src/features/commands/claudecode-command.ts b/src/features/commands/claudecode-command.ts index 442370960..c42723760 100644 --- a/src/features/commands/claudecode-command.ts +++ b/src/features/commands/claudecode-command.ts @@ -17,7 +17,7 @@ import { // looseObject preserves unknown keys during parsing (like passthrough in Zod 3) export const ClaudecodeCommandFrontmatterSchema = z.looseObject({ - description: z.string(), + description: z.optional(z.string()), "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])), "argument-hint": z.optional(z.string()), model: z.optional(z.string()), diff --git a/src/features/commands/cline-command.ts b/src/features/commands/cline-command.ts index 5c0ddccb0..72c7f4e23 100644 --- a/src/features/commands/cline-command.ts +++ b/src/features/commands/cline-command.ts @@ -30,7 +30,6 @@ export class ClineCommand extends ToolCommand { toRulesyncCommand(): RulesyncCommand { const rulesyncFrontmatter: RulesyncCommandFrontmatter = { targets: ["*"], - description: "", }; return new RulesyncCommand({ diff --git a/src/features/commands/codexcli-command.ts b/src/features/commands/codexcli-command.ts index 18f902b76..2814e55d5 100644 --- a/src/features/commands/codexcli-command.ts +++ b/src/features/commands/codexcli-command.ts @@ -27,7 +27,6 @@ export class CodexcliCommand extends ToolCommand { toRulesyncCommand(): RulesyncCommand { const rulesyncFrontmatter: RulesyncCommandFrontmatter = { targets: ["*"], - description: "", }; return new RulesyncCommand({ diff --git a/src/features/commands/copilot-command.test.ts b/src/features/commands/copilot-command.test.ts index cdab13608..40b73e936 100644 --- a/src/features/commands/copilot-command.test.ts +++ b/src/features/commands/copilot-command.test.ts @@ -25,8 +25,8 @@ This is the body of the copilot command. It can be multiline.`; const invalidMarkdownContent = `--- -# Missing required description field -mode: agent +mode: 123 +description: 456 --- Body content`; @@ -114,20 +114,18 @@ Body content`; expect(command).toBeInstanceOf(CopilotCommand); }); - it("should throw error for invalid frontmatter when validation is enabled", () => { - expect( - () => - new CopilotCommand({ - baseDir: testDir, - relativeDirPath: join(".github", "prompts"), - relativeFilePath: "invalid-command.prompt.md", - frontmatter: { - // Missing required mode and description field - } as CopilotCommandFrontmatter, - body: "Body content", - validate: true, - }), - ).toThrow(); + it("should accept frontmatter without description (description is optional)", () => { + const command = new CopilotCommand({ + baseDir: testDir, + relativeDirPath: join(".github", "prompts"), + relativeFilePath: "no-desc-command.prompt.md", + frontmatter: {} as CopilotCommandFrontmatter, + body: "Body content", + validate: true, + }); + + expect(command).toBeInstanceOf(CopilotCommand); + expect(command.getFrontmatter().description).toBeUndefined(); }); }); @@ -351,18 +349,19 @@ Body content`; ).rejects.toThrow(); }); - it("should handle file without frontmatter", async () => { + it("should handle file without frontmatter (description is optional)", async () => { const commandsDir = join(testDir, ".github", "prompts"); const filePath = join(commandsDir, "no-frontmatter.prompt.md"); await writeFileContent(filePath, markdownWithoutFrontmatter); - await expect( - CopilotCommand.fromFile({ - relativeFilePath: "no-frontmatter.prompt.md", - validate: true, - }), - ).rejects.toThrow(); + const command = await CopilotCommand.fromFile({ + relativeFilePath: "no-frontmatter.prompt.md", + validate: true, + }); + + expect(command).toBeInstanceOf(CopilotCommand); + expect(command.getFrontmatter().description).toBeUndefined(); }); }); @@ -426,12 +425,14 @@ Body content`; expect(result).toEqual(validFrontmatter); }); - it("should throw error for frontmatter without description", () => { - const invalidFrontmatter = { + it("should accept frontmatter without description (description is optional)", () => { + const frontmatter = { mode: "agent", }; - expect(() => CopilotCommandFrontmatterSchema.parse(invalidFrontmatter)).toThrow(); + const result = CopilotCommandFrontmatterSchema.parse(frontmatter); + expect(result.mode).toBe("agent"); + expect(result.description).toBeUndefined(); }); it("should validate frontmatter with any string mode (mode is optional string)", () => { diff --git a/src/features/commands/copilot-command.ts b/src/features/commands/copilot-command.ts index 596d60274..17f39bf74 100644 --- a/src/features/commands/copilot-command.ts +++ b/src/features/commands/copilot-command.ts @@ -18,7 +18,7 @@ import { // looseObject preserves unknown keys during parsing (like passthrough in Zod 3) export const CopilotCommandFrontmatterSchema = z.looseObject({ mode: z.optional(z.string()), - description: z.string(), + description: z.optional(z.string()), }); export type CopilotCommandFrontmatter = z.infer; diff --git a/src/features/commands/cursor-command.test.ts b/src/features/commands/cursor-command.test.ts index 0b7123293..e6a47c55a 100644 --- a/src/features/commands/cursor-command.test.ts +++ b/src/features/commands/cursor-command.test.ts @@ -152,7 +152,7 @@ describe("CursorCommand", () => { expect(rulesyncCommand.getFileContent()).toContain("Test body content"); }); - it("should default description to empty string when not set", () => { + it("should propagate undefined description when not set", () => { const command = new CursorCommand({ baseDir: testDir, relativeDirPath: ".cursor/commands", @@ -163,7 +163,7 @@ describe("CursorCommand", () => { }); const rulesyncCommand = command.toRulesyncCommand(); - expect(rulesyncCommand.getFrontmatter().description).toBe(""); + expect(rulesyncCommand.getFrontmatter().description).toBeUndefined(); }); it("should preserve handoffs in cursor section", () => { diff --git a/src/features/commands/cursor-command.ts b/src/features/commands/cursor-command.ts index ee38cf87a..fcb866ad0 100644 --- a/src/features/commands/cursor-command.ts +++ b/src/features/commands/cursor-command.ts @@ -76,7 +76,7 @@ export class CursorCommand extends ToolCommand { } toRulesyncCommand(): RulesyncCommand { - const { description = "", ...restFields } = this.frontmatter; + const { description, ...restFields } = this.frontmatter; const rulesyncFrontmatter: RulesyncCommandFrontmatter = { targets: ["*"], diff --git a/src/features/commands/factorydroid-command.test.ts b/src/features/commands/factorydroid-command.test.ts index e6fa3b3c1..85464d721 100644 --- a/src/features/commands/factorydroid-command.test.ts +++ b/src/features/commands/factorydroid-command.test.ts @@ -20,8 +20,7 @@ This is the body of the factorydroid command. It can be multiline.`; const invalidMarkdownContent = `--- -# Missing required fields -invalid: true +description: 123 --- Body content`; diff --git a/src/features/commands/geminicli-command.test.ts b/src/features/commands/geminicli-command.test.ts index a7b9e17c9..7f1717b25 100644 --- a/src/features/commands/geminicli-command.test.ts +++ b/src/features/commands/geminicli-command.test.ts @@ -75,7 +75,7 @@ prompt = "Unclosed string`; expect(command.getBody()).toBe("This is a test prompt without description.\n"); expect(command.getFrontmatter()).toEqual({ - description: "", + description: undefined, prompt: "This is a test prompt without description.\n", }); }); @@ -134,7 +134,7 @@ prompt = "Unclosed string`; }); const frontmatter = command.getFrontmatter() as GeminiCliCommandFrontmatter; - expect(frontmatter.description).toBe(""); + expect(frontmatter.description).toBeUndefined(); expect(frontmatter.prompt).toBe("This is a test prompt without description.\n"); }); @@ -223,7 +223,7 @@ prompt = "Unclosed string`; expect(rulesyncCommand.getFrontmatter()).toEqual({ targets: ["geminicli"], - description: "", + description: undefined, }); }); }); diff --git a/src/features/commands/geminicli-command.ts b/src/features/commands/geminicli-command.ts index 3c61df25f..a928da325 100644 --- a/src/features/commands/geminicli-command.ts +++ b/src/features/commands/geminicli-command.ts @@ -58,7 +58,7 @@ export class GeminiCliCommand extends ToolCommand { // Preserve all fields including unknown ones (looseObject passthrough) return { ...result.data, - description: result.data.description || "", + description: result.data.description, }; } catch (error) { throw new Error( @@ -84,7 +84,7 @@ export class GeminiCliCommand extends ToolCommand { const rulesyncFrontmatter: RulesyncCommandFrontmatter = { targets: ["geminicli"], - description: description ?? "", + description: description, // Preserve extra fields in geminicli section (excluding prompt which is the body) ...(Object.keys(restFields).length > 0 && { geminicli: restFields }), }; @@ -123,8 +123,11 @@ export class GeminiCliCommand extends ToolCommand { // Generate proper file content with TOML format // Note: TOML format only supports description and prompt fields // Extra fields from geminicli section are stored in the object but not serialized to TOML - const tomlContent = `description = "${geminiFrontmatter.description}" -prompt = """ + const descriptionLine = + geminiFrontmatter.description !== undefined + ? `description = "${geminiFrontmatter.description}"\n` + : ""; + const tomlContent = `${descriptionLine}prompt = """ ${geminiFrontmatter.prompt} """`; diff --git a/src/features/commands/kilo-command.test.ts b/src/features/commands/kilo-command.test.ts index 2d1340bd8..c6f24685b 100644 --- a/src/features/commands/kilo-command.test.ts +++ b/src/features/commands/kilo-command.test.ts @@ -58,7 +58,7 @@ Step 1`; const rulesyncCommand = kiloCommand.toRulesyncCommand(); expect(rulesyncCommand).toBeInstanceOf(RulesyncCommand); - expect(rulesyncCommand.getFrontmatter()).toEqual({ targets: ["*"], description: "" }); + expect(rulesyncCommand.getFrontmatter()).toEqual({ targets: ["*"] }); expect(rulesyncCommand.getBody()).toBe(validContent); expect(rulesyncCommand.getRelativeDirPath()).toBe(RULESYNC_COMMANDS_RELATIVE_DIR_PATH); }); diff --git a/src/features/commands/kilo-command.ts b/src/features/commands/kilo-command.ts index 90a093eb8..c5da8c6cf 100644 --- a/src/features/commands/kilo-command.ts +++ b/src/features/commands/kilo-command.ts @@ -24,7 +24,6 @@ export class KiloCommand extends ToolCommand { toRulesyncCommand(): RulesyncCommand { const rulesyncFrontmatter: RulesyncCommandFrontmatter = { targets: ["*"], - description: "", }; return new RulesyncCommand({ diff --git a/src/features/commands/kiro-command.test.ts b/src/features/commands/kiro-command.test.ts index 25c7c19bf..4f5fe8e95 100644 --- a/src/features/commands/kiro-command.test.ts +++ b/src/features/commands/kiro-command.test.ts @@ -58,7 +58,7 @@ Step 1`; const rulesyncCommand = kiroCommand.toRulesyncCommand(); expect(rulesyncCommand).toBeInstanceOf(RulesyncCommand); - expect(rulesyncCommand.getFrontmatter()).toEqual({ targets: ["*"], description: "" }); + expect(rulesyncCommand.getFrontmatter()).toEqual({ targets: ["*"] }); expect(rulesyncCommand.getBody()).toBe(validContent); expect(rulesyncCommand.getRelativeDirPath()).toBe(RULESYNC_COMMANDS_RELATIVE_DIR_PATH); }); diff --git a/src/features/commands/kiro-command.ts b/src/features/commands/kiro-command.ts index f72bd18c0..f41f15cd3 100644 --- a/src/features/commands/kiro-command.ts +++ b/src/features/commands/kiro-command.ts @@ -22,7 +22,6 @@ export class KiroCommand extends ToolCommand { toRulesyncCommand(): RulesyncCommand { const rulesyncFrontmatter: RulesyncCommandFrontmatter = { targets: ["*"], - description: "", }; return new RulesyncCommand({ diff --git a/src/features/commands/opencode-command.ts b/src/features/commands/opencode-command.ts index 1188825a4..e6e229ba1 100644 --- a/src/features/commands/opencode-command.ts +++ b/src/features/commands/opencode-command.ts @@ -16,7 +16,7 @@ import { } from "./tool-command.js"; export const OpenCodeCommandFrontmatterSchema = z.looseObject({ - description: z.string(), + description: z.optional(z.string()), agent: optional(z.string()), subtask: optional(z.boolean()), model: optional(z.string()), diff --git a/src/features/commands/roo-command.test.ts b/src/features/commands/roo-command.test.ts index 6d2be9f5d..5a05e02b2 100644 --- a/src/features/commands/roo-command.test.ts +++ b/src/features/commands/roo-command.test.ts @@ -422,13 +422,17 @@ This file has invalid frontmatter`; } }); - it("should reject frontmatter without description", () => { - const invalidFrontmatter = { - "argument-hint": "Missing description", + it("should accept frontmatter without description (description is optional)", () => { + const frontmatter = { + "argument-hint": "Has hint but no description", }; - const result = RooCommandFrontmatterSchema.safeParse(invalidFrontmatter); - expect(result.success).toBe(false); + const result = RooCommandFrontmatterSchema.safeParse(frontmatter); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.description).toBeUndefined(); + expect(result.data["argument-hint"]).toBe("Has hint but no description"); + } }); it("should reject frontmatter with invalid description type", () => { diff --git a/src/features/commands/roo-command.ts b/src/features/commands/roo-command.ts index 8a9cb28df..338dd553f 100644 --- a/src/features/commands/roo-command.ts +++ b/src/features/commands/roo-command.ts @@ -16,7 +16,7 @@ import { // looseObject preserves unknown keys during parsing (like passthrough in Zod 3) export const RooCommandFrontmatterSchema = z.looseObject({ - description: z.string(), + description: z.optional(z.string()), "argument-hint": optional(z.string()), }); diff --git a/src/features/commands/rulesync-command.test.ts b/src/features/commands/rulesync-command.test.ts index fe60bd41c..8e172502d 100644 --- a/src/features/commands/rulesync-command.test.ts +++ b/src/features/commands/rulesync-command.test.ts @@ -84,19 +84,18 @@ describe("RulesyncCommand", () => { expect(() => new RulesyncCommand(paramsWithInvalidData)).toThrow(); }); - it("should throw error for missing required frontmatter fields", () => { - const incompleteFrontmatter = { + it("should not throw for frontmatter without description (description is optional)", () => { + const frontmatterWithoutDescription = { targets: ["cursor"], - // Missing description }; - const paramsWithIncompleteData: RulesyncCommandParams = { + const paramsWithoutDescription: RulesyncCommandParams = { ...validParams, - frontmatter: incompleteFrontmatter as any, - validate: true, // Enable validation + frontmatter: frontmatterWithoutDescription as any, + validate: true, }; - expect(() => new RulesyncCommand(paramsWithIncompleteData)).toThrow(); + expect(() => new RulesyncCommand(paramsWithoutDescription)).not.toThrow(); }); it("should generate correct file content from frontmatter and body", () => { @@ -181,22 +180,20 @@ describe("RulesyncCommand", () => { expect(result.error).toBeDefined(); }); - it("should return failure for missing required fields", () => { - const incompleteFrontmatter = { + it("should return success for frontmatter without description (description is optional)", () => { + const frontmatterWithoutDescription = { targets: ["cursor"], - // Missing description }; const command = new RulesyncCommand({ ...validParams, - frontmatter: incompleteFrontmatter as any, + frontmatter: frontmatterWithoutDescription as any, validate: false, }); const result = command.validate(); - expect(result.success).toBe(false); - expect(result.error).toBeDefined(); + expect(result.success).toBe(true); }); }); @@ -227,14 +224,15 @@ describe("RulesyncCommand", () => { expect(result.success).toBe(false); }); - it("should reject missing description", () => { - const invalidData = { + it("should accept missing description (description is optional)", () => { + const data = { targets: ["cursor"], }; - const result = RulesyncCommandFrontmatterSchema.safeParse(invalidData); + const result = RulesyncCommandFrontmatterSchema.safeParse(data); - expect(result.success).toBe(false); + expect(result.success).toBe(true); + expect(result.data?.description).toBeUndefined(); }); it("should reject non-string description", () => { diff --git a/src/features/commands/rulesync-command.ts b/src/features/commands/rulesync-command.ts index 4662aaa0d..53a35ca95 100644 --- a/src/features/commands/rulesync-command.ts +++ b/src/features/commands/rulesync-command.ts @@ -18,7 +18,7 @@ import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter. // Tool-specific sections (e.g., claudecode:, copilot:) are preserved as additional keys export const RulesyncCommandFrontmatterSchema = z.looseObject({ targets: z._default(RulesyncTargetsSchema, ["*"]), - description: z.string(), + description: z.optional(z.string()), }); // Input type allows targets to be omitted (will use default value) diff --git a/src/features/commands/simulated-command.ts b/src/features/commands/simulated-command.ts index 96cdef639..d5fa5693c 100644 --- a/src/features/commands/simulated-command.ts +++ b/src/features/commands/simulated-command.ts @@ -15,7 +15,7 @@ import { } from "./tool-command.js"; export const SimulatedCommandFrontmatterSchema = z.object({ - description: z.string(), + description: z.optional(z.string()), }); export type SimulatedCommandFrontmatter = z.infer; diff --git a/src/features/rules/antigravity-rule.ts b/src/features/rules/antigravity-rule.ts index ef346c0d5..bbd67ab71 100644 --- a/src/features/rules/antigravity-rule.ts +++ b/src/features/rules/antigravity-rule.ts @@ -145,7 +145,7 @@ type TriggerStrategy = { ): AntigravityRuleFrontmatter; exportRulesyncData(frontmatter: AntigravityRuleFrontmatter): { globs: string[]; - description: string; + description?: string; antigravity: Record; }; }; @@ -164,7 +164,7 @@ const globStrategy: TriggerStrategy = { }, exportRulesyncData: ({ description, ...frontmatter }) => ({ globs: parseGlobsString(frontmatter.globs), - description: description || "", + description, antigravity: frontmatter, }), }; @@ -177,7 +177,7 @@ const manualStrategy: TriggerStrategy = { }), exportRulesyncData: ({ description, ...frontmatter }) => ({ globs: [], - description: description || "", + description, antigravity: frontmatter, }), }; @@ -190,7 +190,7 @@ const alwaysOnStrategy: TriggerStrategy = { }), exportRulesyncData: ({ description, ...frontmatter }) => ({ globs: ["**/*"], - description: description || "", + description, antigravity: frontmatter, }), }; @@ -204,7 +204,7 @@ const modelDecisionStrategy: TriggerStrategy = { }), exportRulesyncData: ({ description, ...frontmatter }) => ({ globs: [], - description: description || "", + description, antigravity: frontmatter, }), }; @@ -225,7 +225,7 @@ const unknownStrategy: TriggerStrategy = { }, exportRulesyncData: ({ description, ...frontmatter }) => ({ globs: frontmatter.globs ? parseGlobsString(frontmatter.globs) : ["**/*"], - description: description || "", + description, antigravity: frontmatter, }), }; @@ -261,7 +261,7 @@ const inferenceStrategy: TriggerStrategy = { }, exportRulesyncData: ({ description, ...frontmatter }) => ({ globs: frontmatter.globs ? parseGlobsString(frontmatter.globs) : ["**/*"], - description: description || "", + description, antigravity: frontmatter, }), }; @@ -435,11 +435,10 @@ export class AntigravityRule extends ToolRule { // So we should find one. If not, fallback to empty array? let rulesyncData: { globs: string[]; - description: string; + description?: string; antigravity: Record; } = { globs: [], - description: "", antigravity: this.frontmatter, }; diff --git a/src/features/rules/augmentcode-legacy-rule.test.ts b/src/features/rules/augmentcode-legacy-rule.test.ts index 7140d36bb..3690a6aa3 100644 --- a/src/features/rules/augmentcode-legacy-rule.test.ts +++ b/src/features/rules/augmentcode-legacy-rule.test.ts @@ -122,7 +122,7 @@ describe("AugmentcodeLegacyRule", () => { const frontmatter = rulesyncRule.getFrontmatter(); expect(frontmatter.root).toBe(false); expect(frontmatter.targets).toEqual(["*"]); - expect(frontmatter.description).toBe(""); + expect(frontmatter.description).toBeUndefined(); expect(frontmatter.globs).toEqual([]); }); @@ -146,7 +146,7 @@ describe("AugmentcodeLegacyRule", () => { const frontmatter = rulesyncRule.getFrontmatter(); expect(frontmatter.root).toBe(true); expect(frontmatter.targets).toEqual(["*"]); - expect(frontmatter.description).toBe(""); + expect(frontmatter.description).toBeUndefined(); expect(frontmatter.globs).toEqual(["**/*"]); }); diff --git a/src/features/rules/augmentcode-legacy-rule.ts b/src/features/rules/augmentcode-legacy-rule.ts index c6d494fb2..ecc6813e9 100644 --- a/src/features/rules/augmentcode-legacy-rule.ts +++ b/src/features/rules/augmentcode-legacy-rule.ts @@ -31,7 +31,6 @@ export class AugmentcodeLegacyRule extends ToolRule { const rulesyncFrontmatter: RulesyncRuleFrontmatter = { root: this.isRoot(), targets: ["*"], - description: "", globs: this.isRoot() ? ["**/*"] : [], }; diff --git a/src/features/subagents/claudecode-subagent.test.ts b/src/features/subagents/claudecode-subagent.test.ts index d5dd8f714..ea740d9d4 100644 --- a/src/features/subagents/claudecode-subagent.test.ts +++ b/src/features/subagents/claudecode-subagent.test.ts @@ -53,11 +53,18 @@ describe("ClaudecodeSubagentFrontmatterSchema", () => { }; expect(() => ClaudecodeSubagentFrontmatterSchema.parse(missingName)).toThrow(); - // Missing description - const missingDescription = { + // Missing name (description is optional, so only name is required) + const missingBoth = {}; + expect(() => ClaudecodeSubagentFrontmatterSchema.parse(missingBoth)).toThrow(); + }); + + it("should accept frontmatter without description (description is optional)", () => { + const frontmatter = { name: "test-agent", }; - expect(() => ClaudecodeSubagentFrontmatterSchema.parse(missingDescription)).toThrow(); + expect(() => ClaudecodeSubagentFrontmatterSchema.parse(frontmatter)).not.toThrow(); + const result = ClaudecodeSubagentFrontmatterSchema.parse(frontmatter); + expect(result.description).toBeUndefined(); }); it("should reject invalid model values", () => { @@ -836,10 +843,10 @@ describe("ClaudecodeSubagent", () => { ).rejects.toThrow("Invalid frontmatter"); }); - it("should throw error for missing required frontmatter fields", async () => { + it("should accept frontmatter without description (description is optional)", async () => { const incompleteFrontmatter = { name: "incomplete-agent", - // Missing description + // Missing description - now optional }; const body = "Agent content"; @@ -850,13 +857,15 @@ describe("ClaudecodeSubagent", () => { await writeFileContent(filePath, fileContent); - await expect( - ClaudecodeSubagent.fromFile({ - baseDir: testDir, - relativeFilePath: "incomplete-agent.md", - validate: true, - }), - ).rejects.toThrow("Invalid frontmatter"); + const subagent = await ClaudecodeSubagent.fromFile({ + baseDir: testDir, + relativeFilePath: "incomplete-agent.md", + validate: true, + }); + + expect(subagent).toBeInstanceOf(ClaudecodeSubagent); + expect(subagent.getFrontmatter().name).toBe("incomplete-agent"); + expect(subagent.getFrontmatter().description).toBeUndefined(); }); it("should trim body content", async () => { diff --git a/src/features/subagents/claudecode-subagent.ts b/src/features/subagents/claudecode-subagent.ts index b813e31e5..f81f92cf1 100644 --- a/src/features/subagents/claudecode-subagent.ts +++ b/src/features/subagents/claudecode-subagent.ts @@ -19,7 +19,7 @@ import { // looseObject preserves unknown keys during parsing (like passthrough in Zod 3) export const ClaudecodeSubagentFrontmatterSchema = z.looseObject({ name: z.string(), - description: z.string(), + description: z.optional(z.string()), model: z.optional(z.string()), tools: z.optional(z.union([z.string(), z.array(z.string())])), permissionMode: z.optional(z.string()), diff --git a/src/features/subagents/codexcli-subagent.ts b/src/features/subagents/codexcli-subagent.ts index 18244169e..dca013438 100644 --- a/src/features/subagents/codexcli-subagent.ts +++ b/src/features/subagents/codexcli-subagent.ts @@ -87,7 +87,7 @@ export class CodexCliSubagent extends ToolSubagent { const rulesyncFrontmatter: RulesyncSubagentFrontmatter = { targets: ["codexcli"], name, - description: description ?? "", + description: description, // Only include codexcli section if there are fields ...(Object.keys(codexcliSection).length > 0 && { codexcli: codexcliSection }), }; diff --git a/src/features/subagents/copilot-subagent.ts b/src/features/subagents/copilot-subagent.ts index 31e097ef9..5b4b62a28 100644 --- a/src/features/subagents/copilot-subagent.ts +++ b/src/features/subagents/copilot-subagent.ts @@ -20,7 +20,7 @@ const REQUIRED_TOOL = "agent/runSubagent"; const CopilotSubagentFrontmatterSchema = z.looseObject({ name: z.string(), - description: z.string(), + description: z.optional(z.string()), tools: z.optional(z.union([z.string(), z.array(z.string())])), }); diff --git a/src/features/subagents/cursor-subagent.test.ts b/src/features/subagents/cursor-subagent.test.ts index 6c47d0880..59a25579c 100644 --- a/src/features/subagents/cursor-subagent.test.ts +++ b/src/features/subagents/cursor-subagent.test.ts @@ -449,12 +449,14 @@ Body content`; expect(() => SimulatedSubagentFrontmatterSchema.parse(invalidFrontmatter)).toThrow(); }); - it("should throw error for frontmatter without description", () => { - const invalidFrontmatter = { + it("should accept frontmatter without description (description is optional)", () => { + const frontmatter = { name: "Test Agent", }; - expect(() => SimulatedSubagentFrontmatterSchema.parse(invalidFrontmatter)).toThrow(); + const result = SimulatedSubagentFrontmatterSchema.parse(frontmatter); + expect(result.name).toBe("Test Agent"); + expect(result.description).toBeUndefined(); }); it("should throw error for frontmatter with invalid types", () => { diff --git a/src/features/subagents/cursor-subagent.ts b/src/features/subagents/cursor-subagent.ts index 585363327..95fc7c37c 100644 --- a/src/features/subagents/cursor-subagent.ts +++ b/src/features/subagents/cursor-subagent.ts @@ -18,7 +18,7 @@ import { const CursorSubagentFrontmatterSchema = z.looseObject({ name: z.string(), - description: z.string(), + description: z.optional(z.string()), }); type CursorSubagentFrontmatter = z.infer; diff --git a/src/features/subagents/geminicli-subagent.test.ts b/src/features/subagents/geminicli-subagent.test.ts index b14c9fe81..2654c644e 100644 --- a/src/features/subagents/geminicli-subagent.test.ts +++ b/src/features/subagents/geminicli-subagent.test.ts @@ -414,12 +414,14 @@ Body content`; expect(() => SimulatedSubagentFrontmatterSchema.parse(invalidFrontmatter)).toThrow(); }); - it("should throw error for frontmatter without description", () => { - const invalidFrontmatter = { + it("should accept frontmatter without description (description is optional)", () => { + const frontmatter = { name: "Test Agent", }; - expect(() => SimulatedSubagentFrontmatterSchema.parse(invalidFrontmatter)).toThrow(); + const result = SimulatedSubagentFrontmatterSchema.parse(frontmatter); + expect(result.name).toBe("Test Agent"); + expect(result.description).toBeUndefined(); }); it("should throw error for frontmatter with invalid types", () => { diff --git a/src/features/subagents/kiro-subagent.ts b/src/features/subagents/kiro-subagent.ts index 54104ac4f..03cab49a4 100644 --- a/src/features/subagents/kiro-subagent.ts +++ b/src/features/subagents/kiro-subagent.ts @@ -94,7 +94,7 @@ export class KiroSubagent extends ToolSubagent { const rulesyncFrontmatter: RulesyncSubagentFrontmatter = { targets: ["kiro"], name, - description: description ?? "", + description: description ?? undefined, // Only include kiro section if there are fields ...(Object.keys(kiroSection).length > 0 && { kiro: kiroSection }), }; diff --git a/src/features/subagents/opencode-subagent.ts b/src/features/subagents/opencode-subagent.ts index e2eec6275..9a3009102 100644 --- a/src/features/subagents/opencode-subagent.ts +++ b/src/features/subagents/opencode-subagent.ts @@ -17,7 +17,7 @@ import { } from "./tool-subagent.js"; export const OpenCodeSubagentFrontmatterSchema = z.looseObject({ - description: z.string(), + description: z.optional(z.string()), mode: z._default(z.string(), "subagent"), name: z.optional(z.string()), }); diff --git a/src/features/subagents/roo-subagent.test.ts b/src/features/subagents/roo-subagent.test.ts index bcc8aac32..66b00df01 100644 --- a/src/features/subagents/roo-subagent.test.ts +++ b/src/features/subagents/roo-subagent.test.ts @@ -409,12 +409,14 @@ Body content`; expect(() => SimulatedSubagentFrontmatterSchema.parse(invalidFrontmatter)).toThrow(); }); - it("should throw error for frontmatter without description", () => { - const invalidFrontmatter = { + it("should accept frontmatter without description (description is optional)", () => { + const frontmatter = { name: "Test Agent", }; - expect(() => SimulatedSubagentFrontmatterSchema.parse(invalidFrontmatter)).toThrow(); + const result = SimulatedSubagentFrontmatterSchema.parse(frontmatter); + expect(result.name).toBe("Test Agent"); + expect(result.description).toBeUndefined(); }); it("should throw error for frontmatter with invalid types", () => { diff --git a/src/features/subagents/rulesync-subagent.test.ts b/src/features/subagents/rulesync-subagent.test.ts index dff894eff..55d0ecc61 100644 --- a/src/features/subagents/rulesync-subagent.test.ts +++ b/src/features/subagents/rulesync-subagent.test.ts @@ -50,13 +50,18 @@ describe("RulesyncSubagentFrontmatterSchema", () => { description: "A test subagent", }; + expect(() => RulesyncSubagentFrontmatterSchema.parse(missingName)).toThrow(); + }); + + it("should accept frontmatter without description (description is optional)", () => { const missingDescription = { targets: ["*"], name: "test-subagent", }; - expect(() => RulesyncSubagentFrontmatterSchema.parse(missingName)).toThrow(); - expect(() => RulesyncSubagentFrontmatterSchema.parse(missingDescription)).toThrow(); + const result = RulesyncSubagentFrontmatterSchema.safeParse(missingDescription); + expect(result.success).toBe(true); + expect(result.data?.description).toBeUndefined(); }); it("should use default targets when omitted", () => { @@ -149,22 +154,22 @@ describe("RulesyncSubagent", () => { expect(subagent.getFrontmatter().claudecode?.model).toBe("opus"); }); - it("should throw error with invalid frontmatter", () => { - const invalidFrontmatter = { + it("should not throw for frontmatter without description (description is optional)", () => { + const frontmatterWithoutDescription = { targets: ["*"], name: "test-subagent", - // missing description + // no description }; expect(() => { const _instance = new RulesyncSubagent({ baseDir: ".", relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, - relativeFilePath: "invalid.md", - frontmatter: invalidFrontmatter as any, + relativeFilePath: "no-desc.md", + frontmatter: frontmatterWithoutDescription as any, body: "Test body", }); - }).toThrow(); + }).not.toThrow(); }); it("should skip validation when validate=false", () => { @@ -298,23 +303,22 @@ describe("RulesyncSubagent", () => { expect(result.error).toBe(null); }); - it("should return error for invalid frontmatter", () => { + it("should return success for frontmatter without description (description is optional)", () => { const subagent = new RulesyncSubagent({ baseDir: ".", relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, - relativeFilePath: "invalid-validate.md", + relativeFilePath: "no-desc-validate.md", frontmatter: { targets: ["*"], - name: "invalid-subagent", - // missing description + name: "no-desc-subagent", + // no description } as any, - body: "Invalid body", - validate: false, // Skip validation in constructor for testing + body: "Body without description", + validate: false, }); const result = subagent.validate(); - expect(result.success).toBe(false); - expect(result.error).toBeInstanceOf(Error); + expect(result.success).toBe(true); }); }); @@ -407,23 +411,23 @@ Nested content.`; expect(basename("test-fromfile-nested.md")).toBe("test-fromfile-nested.md"); }); - it("should throw error for invalid frontmatter in file", async () => { + it("should succeed for file without description (description is optional)", async () => { const subagentsDir = join(testDir, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH); - const filePath = join(subagentsDir, "test-fromfile-invalid.md"); + const filePath = join(subagentsDir, "test-fromfile-no-desc.md"); const fileContent = `--- targets: ["*"] -name: invalid-subagent -# missing description +name: no-desc-subagent --- -Invalid content.`; +Content without description.`; await writeFileContent(filePath, fileContent); - await expect( - RulesyncSubagent.fromFile({ - relativeFilePath: "test-fromfile-invalid.md", - }), - ).rejects.toThrow("Invalid frontmatter in test-fromfile-invalid.md:"); + const subagent = await RulesyncSubagent.fromFile({ + relativeFilePath: "test-fromfile-no-desc.md", + }); + + expect(subagent.getFrontmatter().name).toBe("no-desc-subagent"); + expect(subagent.getFrontmatter().description).toBeUndefined(); }); it("should throw error for non-existent file", async () => { diff --git a/src/features/subagents/rulesync-subagent.ts b/src/features/subagents/rulesync-subagent.ts index a1bdc661f..d0ed08141 100644 --- a/src/features/subagents/rulesync-subagent.ts +++ b/src/features/subagents/rulesync-subagent.ts @@ -19,7 +19,7 @@ import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter. export const RulesyncSubagentFrontmatterSchema = z.looseObject({ targets: z._default(RulesyncTargetsSchema, ["*"]), name: z.string(), - description: z.string(), + description: z.optional(z.string()), }); // Input type allows targets to be omitted (will use default value) diff --git a/src/features/subagents/simulated-subagent.test.ts b/src/features/subagents/simulated-subagent.test.ts index 38c9d1b1c..2639a0d23 100644 --- a/src/features/subagents/simulated-subagent.test.ts +++ b/src/features/subagents/simulated-subagent.test.ts @@ -301,13 +301,17 @@ Body content`; expect(result.success).toBe(false); }); - it("should reject frontmatter without description", () => { - const invalidFrontmatter = { + it("should accept frontmatter without description (description is optional)", () => { + const frontmatter = { name: "Test Agent", }; - const result = SimulatedSubagentFrontmatterSchema.safeParse(invalidFrontmatter); - expect(result.success).toBe(false); + const result = SimulatedSubagentFrontmatterSchema.safeParse(frontmatter); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe("Test Agent"); + expect(result.data.description).toBeUndefined(); + } }); }); }); diff --git a/src/features/subagents/simulated-subagent.ts b/src/features/subagents/simulated-subagent.ts index ac304266a..156e040ce 100644 --- a/src/features/subagents/simulated-subagent.ts +++ b/src/features/subagents/simulated-subagent.ts @@ -16,7 +16,7 @@ import { export const SimulatedSubagentFrontmatterSchema = z.object({ name: z.string(), - description: z.string(), + description: z.optional(z.string()), }); export type SimulatedSubagentFrontmatter = z.infer; diff --git a/src/utils/frontmatter.test.ts b/src/utils/frontmatter.test.ts index d1db154be..4e9328763 100644 --- a/src/utils/frontmatter.test.ts +++ b/src/utils/frontmatter.test.ts @@ -251,6 +251,22 @@ Body content.`; }); }); + it("should leave empty object when all nested values are null", () => { + const content = `--- +cursor: + description: + globs: +--- +Body content.`; + + const result = parseFrontmatter(content); + + // When all nested values are null, the parent key remains as empty object + expect(result.frontmatter).toEqual({ + cursor: {}, + }); + }); + it("should handle malformed YAML gracefully", () => { const content = `--- title: "Valid quote"