Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@
"withoptions",
"moonshotai",
"kimi",
"writerside",
"wrappy",
"wscript",
"XVCJ",
Expand Down
9 changes: 9 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "..")
---
Expand Down
9 changes: 9 additions & 0 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "..")
---
Expand Down
79 changes: 79 additions & 0 deletions src/features/subagents/junie-subagent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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"],
Expand Down
20 changes: 20 additions & 0 deletions src/features/subagents/junie-subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof JunieSubagentFrontmatterSchema>;
Expand Down
Loading