diff --git a/cspell.json b/cspell.json index 1afe55b2c..ea4c8b787 100644 --- a/cspell.json +++ b/cspell.json @@ -316,6 +316,7 @@ "withoptions", "moonshotai", "kimi", + "writerside", "wrappy", "wscript", "XVCJ", diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 3fc97f7f8..6a84a298b 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -282,6 +282,15 @@ opencode: # for OpenCode-specific parameters "git diff": allow kilo: # for Kilo-specific parameters mode: all # (optional, defaults to "all") use "subagent" for hidden/subagent-only agents +junie: # for JetBrains Junie CLI specific parameters (.junie/agents/*.md) + tools: ["Read", "Grep", "Edit"] # allowed tools + disallowedTools: ["Bash", "WebSearch"] # disallowed tools + mcpServers: ["github"] # MCP servers the subagent may use + model: sonnet # model id + reasoningLevel: high # low | medium | high + maxTurns: 20 # max agentic turns + skills: ["kotlin", "writerside"] # Agent Skills to utilize + allowPromptArgument: true # whether the subagent accepts a prompt argument takt: # takt specific parameters (optional; emitted under .takt/facets/personas/) name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 3fc97f7f8..6a84a298b 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -282,6 +282,15 @@ opencode: # for OpenCode-specific parameters "git diff": allow kilo: # for Kilo-specific parameters mode: all # (optional, defaults to "all") use "subagent" for hidden/subagent-only agents +junie: # for JetBrains Junie CLI specific parameters (.junie/agents/*.md) + tools: ["Read", "Grep", "Edit"] # allowed tools + disallowedTools: ["Bash", "WebSearch"] # disallowed tools + mcpServers: ["github"] # MCP servers the subagent may use + model: sonnet # model id + reasoningLevel: high # low | medium | high + maxTurns: 20 # max agentic turns + skills: ["kotlin", "writerside"] # Agent Skills to utilize + allowPromptArgument: true # whether the subagent accepts a prompt argument takt: # takt specific parameters (optional; emitted under .takt/facets/personas/) name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- diff --git a/src/features/subagents/junie-subagent.test.ts b/src/features/subagents/junie-subagent.test.ts index 16d0c8af3..26e6565d2 100644 --- a/src/features/subagents/junie-subagent.test.ts +++ b/src/features/subagents/junie-subagent.test.ts @@ -43,6 +43,37 @@ describe("JunieSubagentFrontmatterSchema", () => { const invalidName = { name: 123, description: "A test agent" }; expect(() => JunieSubagentFrontmatterSchema.parse(invalidName)).toThrow(); }); + + it("should accept the full documented field set", () => { + const frontmatter = { + description: "Review a change and propose a safe patch", + name: "code-review-helper", + tools: ["Read", "Grep", "Edit"], + disallowedTools: ["Bash", "WebSearch"], + mcpServers: ["github"], + model: "sonnet", + reasoningLevel: "high", + maxTurns: 20, + skills: ["kotlin", "writerside"], + allowPromptArgument: true, + }; + + expect(() => JunieSubagentFrontmatterSchema.parse(frontmatter)).not.toThrow(); + const result = JunieSubagentFrontmatterSchema.parse(frontmatter); + expect(result.tools).toEqual(["Read", "Grep", "Edit"]); + expect(result.maxTurns).toBe(20); + expect(result.allowPromptArgument).toBe(true); + expect(result.reasoningLevel).toBe("high"); + }); + + it("should reject a non-numeric maxTurns and non-boolean allowPromptArgument", () => { + expect(() => + JunieSubagentFrontmatterSchema.parse({ description: "x", maxTurns: "twenty" }), + ).toThrow(); + expect(() => + JunieSubagentFrontmatterSchema.parse({ description: "x", allowPromptArgument: "yes" }), + ).toThrow(); + }); }); describe("JunieSubagent", () => { @@ -194,6 +225,54 @@ describe("JunieSubagent", () => { expect(junieSubagent.getRelativeFilePath()).toBe("test-agent.md"); }); + it("round-trips the documented fields through the junie section", () => { + const rulesyncFrontmatter: RulesyncSubagentFrontmatter = { + targets: ["junie"], + name: "code-review-helper", + description: "Review a change", + junie: { + tools: ["Read", "Grep"], + disallowedTools: ["Bash"], + mcpServers: ["github"], + model: "sonnet", + reasoningLevel: "high", + maxTurns: 20, + skills: ["kotlin"], + allowPromptArgument: true, + }, + }; + + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "code-review-helper.md", + frontmatter: rulesyncFrontmatter, + body: "body", + }); + + const junieSubagent = JunieSubagent.fromRulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: JunieSubagent.getSettablePaths().relativeDirPath, + rulesyncSubagent, + validate: true, + }) as JunieSubagent; + + const fm = junieSubagent.getFrontmatter(); + expect(fm.tools).toEqual(["Read", "Grep"]); + expect(fm.mcpServers).toEqual(["github"]); + expect(fm.reasoningLevel).toBe("high"); + expect(fm.maxTurns).toBe(20); + expect(fm.allowPromptArgument).toBe(true); + + // Round-trips back into the rulesync junie section. + const back = junieSubagent.toRulesyncSubagent().getFrontmatter().junie as Record< + string, + unknown + >; + expect(back.tools).toEqual(["Read", "Grep"]); + expect(back.maxTurns).toBe(20); + }); + it("should not allow junie section to overwrite top-level name and description", () => { const rulesyncFrontmatter: RulesyncSubagentFrontmatter = { targets: ["junie"], diff --git a/src/features/subagents/junie-subagent.ts b/src/features/subagents/junie-subagent.ts index 67df58455..75449c6bd 100644 --- a/src/features/subagents/junie-subagent.ts +++ b/src/features/subagents/junie-subagent.ts @@ -16,9 +16,29 @@ import { ToolSubagentSettablePaths, } from "./tool-subagent.js"; +/** + * Frontmatter for Junie CLI custom subagents (`.junie/agents/*.md`). Junie + * documents these fields; the schema stays loose (and the list fields accept a + * single string or an array) for forward compatibility. + * + * @see https://junie.jetbrains.com/docs/junie-cli-subagents.html + */ export const JunieSubagentFrontmatterSchema = z.looseObject({ name: z.optional(z.string()), description: z.string(), + // Tool access controls. + tools: z.optional(z.union([z.string(), z.array(z.string())])), + disallowedTools: z.optional(z.union([z.string(), z.array(z.string())])), + // MCP servers the subagent may use. + mcpServers: z.optional(z.union([z.string(), z.array(z.string())])), + // Model and reasoning controls (`reasoningLevel`: low | medium | high). + model: z.optional(z.string()), + reasoningLevel: z.optional(z.string()), + maxTurns: z.optional(z.number()), + // Agent Skills the subagent should utilize. + skills: z.optional(z.union([z.string(), z.array(z.string())])), + // Whether the subagent accepts a prompt argument. + allowPromptArgument: z.optional(z.boolean()), }); export type JunieSubagentFrontmatter = z.infer;