diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index ab4f496ce..6bce1d69c 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -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 diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index ab4f496ce..6bce1d69c 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -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 diff --git a/src/constants/junie-paths.ts b/src/constants/junie-paths.ts index c5c327c30..a683c1747 100644 --- a/src/constants/junie-paths.ts +++ b/src/constants/junie-paths.ts @@ -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"; diff --git a/src/e2e/e2e-subagents.spec.ts b/src/e2e/e2e-subagents.spec.ts index 8f6eaa501..766fce738 100644 --- a/src/e2e/e2e-subagents.spec.ts +++ b/src/e2e/e2e-subagents.spec.ts @@ -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(); diff --git a/src/features/subagents/junie-subagent.test.ts b/src/features/subagents/junie-subagent.test.ts index 26e6565d2..37b1e0baf 100644 --- a/src/features/subagents/junie-subagent.test.ts +++ b/src/features/subagents/junie-subagent.test.ts @@ -93,6 +93,7 @@ describe("JunieSubagent", () => { it("should return settable paths", () => { expect(JunieSubagent.getSettablePaths()).toEqual({ relativeDirPath: join(".junie", "agents"), + importDirPaths: [".agents"], }); }); @@ -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"], }); }); @@ -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"); + }); }); }); diff --git a/src/features/subagents/junie-subagent.ts b/src/features/subagents/junie-subagent.ts index f9620f826..af6c9fe0b 100644 --- a/src/features/subagents/junie-subagent.ts +++ b/src/features/subagents/junie-subagent.ts @@ -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"; @@ -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], }; } @@ -183,12 +188,15 @@ export class JunieSubagent extends ToolSubagent { static async fromFile({ outputRoot = process.cwd(), + relativeDirPath, relativeFilePath, validate = true, global = false, }: ToolSubagentFromFileParams): Promise { - 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); @@ -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(), diff --git a/src/features/subagents/subagents-processor.test.ts b/src/features/subagents/subagents-processor.test.ts index 200b8fcef..cbb5dcf6a 100644 --- a/src/features/subagents/subagents-processor.test.ts +++ b/src/features/subagents/subagents-processor.test.ts @@ -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, @@ -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; @@ -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; diff --git a/src/features/subagents/subagents-processor.ts b/src/features/subagents/subagents-processor.ts index 1e567d764..529d3e5b2 100644 --- a/src/features/subagents/subagents-processor.ts +++ b/src/features/subagents/subagents-processor.ts @@ -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. `.md`) this is identical to `basename(path)`, - // while for directory-per-agent layouts (e.g. deepagents' `/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(); + 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. `.md`) this is identical to `basename(path)`, + // while for directory-per-agent layouts (e.g. deepagents' `/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; } diff --git a/src/features/subagents/tool-subagent.ts b/src/features/subagents/tool-subagent.ts index ae38f825d..5bafaa678 100644 --- a/src/features/subagents/tool-subagent.ts +++ b/src/features/subagents/tool-subagent.ts @@ -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 & {