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
2 changes: 1 addition & 1 deletion docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ 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)
junie: # for JetBrains Junie CLI specific parameters (generated to .junie/agents/*.md; also imported from .agents/*.md)
tools: ["Read", "Grep", "Edit"] # allowed tools
disallowedTools: ["Bash", "WebSearch"] # disallowed tools
mcpServers: ["github"] # MCP servers the subagent may use
Expand Down
2 changes: 1 addition & 1 deletion skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ 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)
junie: # for JetBrains Junie CLI specific parameters (generated to .junie/agents/*.md; also imported from .agents/*.md)
tools: ["Read", "Grep", "Edit"] # allowed tools
disallowedTools: ["Bash", "WebSearch"] # disallowed tools
mcpServers: ["github"] # MCP servers the subagent may use
Expand Down
6 changes: 6 additions & 0 deletions src/constants/junie-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ export const JUNIE_DIR = ".junie";
export const JUNIE_COMMANDS_DIR_PATH = join(JUNIE_DIR, "commands");
export const JUNIE_SKILLS_DIR_PATH = join(JUNIE_DIR, "skills");
export const JUNIE_AGENTS_DIR_PATH = join(JUNIE_DIR, "agents");
// Junie also discovers subagents from the cross-tool `.agents/` directory
// (project `.agents/` and user `~/.agents/`) in addition to `.junie/agents/`.
// This is an import-only discovery root; generation still targets
// `.junie/agents/`.
// @see https://junie.jetbrains.com/docs/junie-cli-subagents.html
export const JUNIE_ALT_AGENTS_DIR_PATH = ".agents";
export const JUNIE_MCP_DIR_PATH = join(JUNIE_DIR, "mcp");
export const JUNIE_MCP_FILE_NAME = "mcp.json";
export const JUNIE_HOOKS_FILE_NAME = "config.json";
Expand Down
22 changes: 22 additions & 0 deletions src/e2e/e2e-subagents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,28 @@ Break down tasks into steps.
expect(importedContent).toContain("planner");
});

it("should import junie subagents from the shared .agents directory", async () => {
const testDir = getTestDir();

const subagentContent = `---
name: planner
description: "Plans implementation tasks"
---
# Instructions
Break down tasks into steps.
`;
// Junie also discovers subagents from the cross-tool `.agents/` directory,
// not just `.junie/agents/`.
await writeFileContent(join(testDir, ".agents", "planner.md"), subagentContent);

await runImport({ target: "junie", features: "subagents" });

const importedContent = await readFileContent(
join(testDir, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, "planner.md"),
);
expect(importedContent).toContain("planner");
});

it("should import kiro subagents (JSON format)", async () => {
const testDir = getTestDir();

Expand Down
28 changes: 28 additions & 0 deletions src/features/subagents/junie-subagent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ describe("JunieSubagent", () => {
it("should return settable paths", () => {
expect(JunieSubagent.getSettablePaths()).toEqual({
relativeDirPath: join(".junie", "agents"),
importDirPaths: [".agents"],
});
});

Expand All @@ -101,6 +102,7 @@ describe("JunieSubagent", () => {
// identical to project mode, only the resolved outputRoot differs.
expect(JunieSubagent.getSettablePaths({ global: true })).toEqual({
relativeDirPath: join(".junie", "agents"),
importDirPaths: [".agents"],
});
});

Expand Down Expand Up @@ -435,5 +437,31 @@ describe("JunieSubagent", () => {

expect(subagent.getBody()).toBe("body content");
});

it("should load subagent from the .agents import root when relativeDirPath is given", async () => {
// Junie also discovers subagents from the cross-tool `.agents/` directory.
const frontmatter: JunieSubagentFrontmatter = {
name: "shared-agent",
description: "An agent loaded from the shared .agents directory",
};

const body = "Shared agent body";
const fileContent = stringifyFrontmatter(body, frontmatter);

const filePath = join(testDir, ".agents", "shared-agent.md");
await writeFileContent(filePath, fileContent);

const subagent = await JunieSubagent.fromFile({
outputRoot: testDir,
relativeDirPath: ".agents",
relativeFilePath: "shared-agent.md",
validate: true,
});

expect(subagent).toBeInstanceOf(JunieSubagent);
expect(subagent.getFrontmatter()).toEqual(frontmatter);
expect(subagent.getBody()).toBe(body);
expect(subagent.getRelativeDirPath()).toBe(".agents");
});
});
});
16 changes: 12 additions & 4 deletions src/features/subagents/junie-subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { join } from "node:path";

import { z } from "zod/mini";

import { JUNIE_AGENTS_DIR_PATH } from "../../constants/junie-paths.js";
import { JUNIE_AGENTS_DIR_PATH, JUNIE_ALT_AGENTS_DIR_PATH } from "../../constants/junie-paths.js";
import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js";
import { AiFileParams, ValidationResult } from "../../types/ai-file.js";
import { formatError } from "../../utils/error.js";
Expand Down Expand Up @@ -76,8 +76,13 @@ export class JunieSubagent extends ToolSubagent {
// The actual location differs based on outputRoot:
// - Project mode: {process.cwd()}/.junie/agents/
// - Global mode: {getHomeDirectory()}/.junie/agents/
//
// Junie additionally discovers subagents from the cross-tool `.agents/`
// directory (project `.agents/` and user `~/.agents/`). That is an
// import-only root: generation still writes to `.junie/agents/`.
return {
relativeDirPath: JUNIE_AGENTS_DIR_PATH,
importDirPaths: [JUNIE_ALT_AGENTS_DIR_PATH],
};
}

Expand Down Expand Up @@ -183,12 +188,15 @@ export class JunieSubagent extends ToolSubagent {

static async fromFile({
outputRoot = process.cwd(),
relativeDirPath,
relativeFilePath,
validate = true,
global = false,
}: ToolSubagentFromFileParams): Promise<JunieSubagent> {
const paths = this.getSettablePaths({ global });
const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
// Honor an explicit discovery root (e.g. the `.agents/` import root) when
// provided; otherwise fall back to the canonical `.junie/agents/` location.
const dirPath = relativeDirPath ?? this.getSettablePaths({ global }).relativeDirPath;
const filePath = join(outputRoot, dirPath, relativeFilePath);
const fileContent = await readFileContent(filePath);
const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath);

Expand All @@ -199,7 +207,7 @@ export class JunieSubagent extends ToolSubagent {

return new JunieSubagent({
outputRoot,
relativeDirPath: paths.relativeDirPath,
relativeDirPath: dirPath,
relativeFilePath,
frontmatter: result.data,
body: content.trim(),
Expand Down
68 changes: 68 additions & 0 deletions src/features/subagents/subagents-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ClaudecodeSubagent } from "./claudecode-subagent.js";
import { CodexCliSubagent } from "./codexcli-subagent.js";
import { CopilotSubagent } from "./copilot-subagent.js";
import { CursorSubagent } from "./cursor-subagent.js";
import { JunieSubagent } from "./junie-subagent.js";
import { RulesyncSubagent } from "./rulesync-subagent.js";
import {
SubagentsProcessor,
Expand All @@ -27,6 +28,13 @@ const createMockGetFactoryThatThrowsUnsupported = () => {
throw new Error("Unsupported tool target: unsupported");
};

/** Builds a minimal Junie subagent Markdown file with valid frontmatter. */
const junieSubagentMd = (name: string): string => `---
name: ${name}
description: ${name} description
---
${name} content`;

describe("SubagentsProcessor", () => {
let testDir: string;
let cleanup: () => Promise<void>;
Expand Down Expand Up @@ -653,6 +661,66 @@ Body from inputRoot`;
});
});

describe("loadJunieSubagents", () => {
let processor: SubagentsProcessor;

beforeEach(() => {
processor = new SubagentsProcessor({
logger: createMockLogger(),
outputRoot: testDir,
toolTarget: "junie",
});
});

it("should import from both .junie/agents and the shared .agents root", async () => {
const junieDir = join(testDir, ".junie", "agents");
const sharedDir = join(testDir, ".agents");
await ensureDir(junieDir);
await ensureDir(sharedDir);

await writeFileContent(join(junieDir, "native-agent.md"), junieSubagentMd("native-agent"));
await writeFileContent(join(sharedDir, "shared-agent.md"), junieSubagentMd("shared-agent"));

const toolFiles = await processor.loadToolFiles();

expect(toolFiles).toHaveLength(2);
expect(toolFiles.every((file) => file instanceof JunieSubagent)).toBe(true);
const dirPaths = toolFiles.map((file) => file.getRelativeDirPath()).toSorted();
expect(dirPaths).toEqual([".agents", join(".junie", "agents")].toSorted());
});

it("should keep the higher-precedence copy when the same name exists in both roots", async () => {
const junieDir = join(testDir, ".junie", "agents");
const sharedDir = join(testDir, ".agents");
await ensureDir(junieDir);
await ensureDir(sharedDir);

// Same relative path in both roots; `.junie/agents/` is scanned first and wins.
await writeFileContent(join(junieDir, "planner.md"), junieSubagentMd("planner"));
await writeFileContent(join(sharedDir, "planner.md"), junieSubagentMd("planner"));

const toolFiles = await processor.loadToolFiles();

expect(toolFiles).toHaveLength(1);
expect(toolFiles[0]?.getRelativeDirPath()).toBe(join(".junie", "agents"));
});

it("should not delete files in the .agents import root (forDeletion targets .junie/agents only)", async () => {
const junieDir = join(testDir, ".junie", "agents");
const sharedDir = join(testDir, ".agents");
await ensureDir(junieDir);
await ensureDir(sharedDir);

await writeFileContent(join(junieDir, "native-agent.md"), junieSubagentMd("native-agent"));
await writeFileContent(join(sharedDir, "shared-agent.md"), junieSubagentMd("shared-agent"));

const filesToDelete = await processor.loadToolFiles({ forDeletion: true });

expect(filesToDelete).toHaveLength(1);
expect(filesToDelete[0]?.getRelativeDirPath()).toBe(join(".junie", "agents"));
});
});

describe("loadCopilotSubagents", () => {
let processor: SubagentsProcessor;

Expand Down
94 changes: 63 additions & 31 deletions src/features/subagents/subagents-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,45 +442,77 @@ export class SubagentsProcessor extends FeatureProcessor {
const factory = this.getFactory(this.toolTarget);
const paths = factory.class.getSettablePaths({ global: this.global });

const baseDir = join(this.outputRoot, paths.relativeDirPath);
const subagentFilePaths = await findFilesByGlobs(join(baseDir, factory.meta.filePattern));

// Compute the per-subagent file path relative to the tool's base directory.
// For flat layouts (e.g. `<name>.md`) this is identical to `basename(path)`,
// while for directory-per-agent layouts (e.g. deepagents' `<name>/AGENTS.md`)
// it preserves the subdirectory so the subagent name is not lost.
const toRelativeFilePath = (path: string): string => relative(baseDir, path);

if (forDeletion) {
const toolSubagents = subagentFilePaths
.map((path) =>
factory.class.forDeletion({
// Orphan deletion must only ever target the canonical generation directory,
// so that import-only discovery roots (e.g. Junie's `.agents/`) are never
// removed. Importing, on the other hand, scans every discovery root.
const dirPaths = forDeletion
? [paths.relativeDirPath]
: [paths.relativeDirPath, ...(paths.importDirPaths ?? [])];

const toolSubagents: ToolFile[] = [];
// Tracks subagent relative paths already loaded so that a duplicate in a
// lower-precedence import root does not silently shadow an earlier one.
const seenRelativeFilePaths = new Set<string>();
for (const dirPath of dirPaths) {
const baseDir = join(this.outputRoot, dirPath);
const subagentFilePaths = await findFilesByGlobs(join(baseDir, factory.meta.filePattern));

// Compute the per-subagent file path relative to the tool's base directory.
// For flat layouts (e.g. `<name>.md`) this is identical to `basename(path)`,
// while for directory-per-agent layouts (e.g. deepagents' `<name>/AGENTS.md`)
// it preserves the subdirectory so the subagent name is not lost.
const toRelativeFilePath = (path: string): string => relative(baseDir, path);

if (forDeletion) {
toolSubagents.push(
...subagentFilePaths
.map((path) =>
factory.class.forDeletion({
outputRoot: this.outputRoot,
relativeDirPath: dirPath,
relativeFilePath: toRelativeFilePath(path),
global: this.global,
}),
)
.filter((subagent) => subagent.isDeletable()),
);
continue;
}

const loaded = await Promise.all(
subagentFilePaths.map((path) =>
factory.class.fromFile({
outputRoot: this.outputRoot,
relativeDirPath: paths.relativeDirPath,
relativeDirPath: dirPath,
relativeFilePath: toRelativeFilePath(path),
global: this.global,
}),
)
.filter((subagent) => subagent.isDeletable());

this.logger.debug(
`Successfully loaded ${toolSubagents.length} ${paths.relativeDirPath} subagents`,
),
);
return toolSubagents;
}

const toolSubagents = await Promise.all(
subagentFilePaths.map((path) =>
factory.class.fromFile({
outputRoot: this.outputRoot,
relativeFilePath: toRelativeFilePath(path),
global: this.global,
}),
),
);
// When more than one discovery root is scanned (e.g. Junie's
// `.junie/agents/` plus `.agents/`), two roots can hold a subagent with
// the same relative path. Downstream conversion keys by that path, so a
// later one would silently overwrite an earlier one. Warn instead of
// failing, keeping the earlier (higher-precedence) root's file.
const deduped: ToolFile[] = [];
for (const subagent of loaded) {
const key = subagent.getRelativeFilePath();
if (seenRelativeFilePaths.has(key)) {
this.logger.warn(
`Duplicate ${this.toolTarget} subagent "${key}" found in ${dirPath}; ` +
`keeping the one from a higher-precedence directory and ignoring this copy.`,
);
continue;
}
seenRelativeFilePaths.add(key);
deduped.push(subagent);
}
toolSubagents.push(...deduped);
}

this.logger.debug(
`Successfully loaded ${toolSubagents.length} ${paths.relativeDirPath} subagents`,
`Successfully loaded ${toolSubagents.length} ${this.toolTarget} subagents from ${dirPaths.join(", ")}`,
);
return toolSubagents;
}
Expand Down
8 changes: 8 additions & 0 deletions src/features/subagents/tool-subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export type ToolSubagentFromRulesyncSubagentParams = Omit<

export type ToolSubagentSettablePaths = {
relativeDirPath: string;
/**
* Additional directories to scan when *importing* subagents, beyond
* `relativeDirPath`. Generation and orphan deletion always target only
* `relativeDirPath`; these extra roots are read-only discovery locations
* (e.g. Junie also reads the cross-tool `.agents/` directory). Omitted by
* tools that have a single subagent directory.
*/
importDirPaths?: string[];
};

export type ToolSubagentFromFileParams = AiFileFromFileParams & {
Expand Down