diff --git a/.gitignore b/.gitignore index ffb5bc269..634363039 100644 --- a/.gitignore +++ b/.gitignore @@ -351,6 +351,7 @@ rulesync.local.jsonc **/.windsurf/hooks.json **/.devin/skills/ **/.codeium/windsurf/skills/ +**/.devin/agents/ **/.vibeignore **/.vibe/skills/ **/.vibe/agents/ diff --git a/README.md b/README.md index 7768e9bc7..8944870d6 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte | Google Antigravity ⚠️ | ✅ | | | ✅ | | ✅ | | | | JetBrains Junie | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | AugmentCode | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Devin Desktop | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | | +| Devin Desktop | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | Warp | ✅ | | ✅ | | | ✅ | | ✅ | | Replit | ✅ | | | | | ✅ | | | | Pi Coding Agent | ✅ | | | ✅ | | ✅ | | | diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 918b01d4a..477240e29 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -330,6 +330,8 @@ Attention, again, you are just the planner, so though you can read any files and > **Cline note:** Cline file-based agents are emitted as YAML files (`.yaml`) into `.cline/agents/` (project) and `~/.cline/agents/` (global, via `--global`). The file is a YAML frontmatter block (`name` required, `description`) followed by the system prompt body, matching Cline's agent config loader. +> **Devin note:** Devin Local custom subagent profiles are emitted as `AGENT.md` files in a **directory-per-agent** layout: `.devin/agents//AGENT.md` (project) and `~/.config/devin/agents//AGENT.md` (global, via `--global`). The directory name `` is the profile id (derived from the rulesync subagent file name). The `AGENT.md` is a YAML frontmatter block followed by the subagent's system prompt. Besides the shared `name`/`description`, the `devin` subagent block accepts these optional fields (all preserved on round-trip): `model` (string, override the subagent LLM), `allowed-tools` (list of strings, restrict available tools), `permissions` (object with `allow`/`deny`/`ask` string lists, override tool permissions), and `max-nesting` (integer, enable nested subagent spawning up to the given depth). See the [Devin subagents docs](https://docs.devin.ai/cli/subagents). + > **Kilo note (as of 2026-05-13):** Kilo's documented default for user-defined agents is `mode: all`, which makes the agent available both as a top-level pick and as a subagent. Set `kilo.mode: subagent` to opt into hidden/subagent-only behavior. Besides `mode`, the `kilo` subagent block accepts these optional fields (all preserved on round-trip): diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 2cecb9588..ce9b6c59c 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -32,7 +32,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Google Antigravity ⚠️ | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | | | JetBrains Junie | junie | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | 🌏 | | | AugmentCode | augmentcode | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Devin Desktop | devin | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | +| Devin Desktop | devin | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | Warp | warp | ✅ | | ✅ 🌏 | | | ✅ 🌏 | | 🌏 | | Replit | replit | ✅ | | | | | ✅ 🌏 | | | | Pi Coding Agent | pi | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | | diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 918b01d4a..477240e29 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -330,6 +330,8 @@ Attention, again, you are just the planner, so though you can read any files and > **Cline note:** Cline file-based agents are emitted as YAML files (`.yaml`) into `.cline/agents/` (project) and `~/.cline/agents/` (global, via `--global`). The file is a YAML frontmatter block (`name` required, `description`) followed by the system prompt body, matching Cline's agent config loader. +> **Devin note:** Devin Local custom subagent profiles are emitted as `AGENT.md` files in a **directory-per-agent** layout: `.devin/agents//AGENT.md` (project) and `~/.config/devin/agents//AGENT.md` (global, via `--global`). The directory name `` is the profile id (derived from the rulesync subagent file name). The `AGENT.md` is a YAML frontmatter block followed by the subagent's system prompt. Besides the shared `name`/`description`, the `devin` subagent block accepts these optional fields (all preserved on round-trip): `model` (string, override the subagent LLM), `allowed-tools` (list of strings, restrict available tools), `permissions` (object with `allow`/`deny`/`ask` string lists, override tool permissions), and `max-nesting` (integer, enable nested subagent spawning up to the given depth). See the [Devin subagents docs](https://docs.devin.ai/cli/subagents). + > **Kilo note (as of 2026-05-13):** Kilo's documented default for user-defined agents is `mode: all`, which makes the agent available both as a top-level pick and as a subagent. Set `kilo.mode: subagent` to opt into hidden/subagent-only behavior. Besides `mode`, the `kilo` subagent block accepts these optional fields (all preserved on round-trip): diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 2cecb9588..ce9b6c59c 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -32,7 +32,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Google Antigravity ⚠️ | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | | | JetBrains Junie | junie | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | 🌏 | | | AugmentCode | augmentcode | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Devin Desktop | devin | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | +| Devin Desktop | devin | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | Warp | warp | ✅ | | ✅ 🌏 | | | ✅ 🌏 | | 🌏 | | Replit | replit | ✅ | | | | | ✅ 🌏 | | | | Pi Coding Agent | pi | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | | diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index 2f09b8caf..52a6d93a1 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -388,6 +388,10 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ { target: "devin", feature: "hooks", entry: "**/.windsurf/hooks.json" }, { target: "devin", feature: "skills", entry: "**/.devin/skills/" }, { target: "devin", feature: "skills", entry: "**/.codeium/windsurf/skills/" }, + // Devin Local custom subagent profiles: `.devin/agents//AGENT.md` + // (project). The global path `~/.config/devin/agents/` lives under the home + // directory and is not gitignored at the project level. + { target: "devin", feature: "subagents", entry: "**/.devin/agents/" }, // Vibe { target: "vibe", feature: "ignore", entry: "**/.vibeignore" }, diff --git a/src/constants/devin-paths.ts b/src/constants/devin-paths.ts index ad5b4c03e..718a85426 100644 --- a/src/constants/devin-paths.ts +++ b/src/constants/devin-paths.ts @@ -8,6 +8,12 @@ export const CODEIUM_WINDSURF_DIR = join(CODEIUM_DIR, WINDSURF_SUBDIR); export const WINDSURF_MEMORIES_SUBDIR = join(WINDSURF_SUBDIR, "memories"); export const DEVIN_WORKFLOWS_DIR_PATH = join(DEVIN_DIR, "workflows"); export const DEVIN_SKILLS_DIR_PATH = join(DEVIN_DIR, "skills"); +export const DEVIN_AGENTS_DIR_PATH = join(DEVIN_DIR, "agents"); +// Devin Local custom subagent profiles in global mode live under +// `~/.config/devin/agents/` (NOT `.devin/agents/` under the home directory). +// The home directory is resolved by the processor through outputRoot in global mode. +// https://docs.devin.ai/cli/subagents +export const DEVIN_GLOBAL_AGENTS_DIR_PATH = join(".config", "devin", "agents"); export const CODEIUM_WINDSURF_GLOBAL_WORKFLOWS_DIR_PATH = join( CODEIUM_WINDSURF_DIR, "global_workflows", diff --git a/src/e2e/e2e-subagents.spec.ts b/src/e2e/e2e-subagents.spec.ts index 1f35a116d..8b294ffbd 100644 --- a/src/e2e/e2e-subagents.spec.ts +++ b/src/e2e/e2e-subagents.spec.ts @@ -47,6 +47,10 @@ describe("E2E: subagents", () => { target: "deepagents", outputPath: join(".deepagents", "agents", "planner", "AGENTS.md"), }, + { + target: "devin", + outputPath: join(".devin", "agents", "planner", "AGENT.md"), + }, { target: "kiro", outputPath: join(".kiro", "agents", "planner.json"), @@ -229,6 +233,7 @@ You are a subagent-only helper. { target: "codexcli", orphanPath: join(".codex", "agents", "orphan.toml") }, { target: "copilot", orphanPath: join(".github", "agents", "orphan.md") }, { target: "deepagents", orphanPath: join(".deepagents", "agents", "orphan", "AGENTS.md") }, + { target: "devin", orphanPath: join(".devin", "agents", "orphan", "AGENT.md") }, { target: "kiro", orphanPath: join(".kiro", "agents", "orphan.json") }, { target: "kiro-cli", orphanPath: join(".kiro", "agents", "orphan.json") }, { target: "kiro-ide", orphanPath: join(".kiro", "agents", "orphan.md") }, @@ -278,6 +283,7 @@ describe("E2E: subagents (import)", () => { { target: "junie", sourcePath: join(".junie", "agents", "planner.md") }, { target: "factorydroid", sourcePath: join(".factory", "droids", "planner.md") }, { target: "cline", sourcePath: join(".cline", "agents", "planner.yaml") }, + { target: "devin", sourcePath: join(".devin", "agents", "planner", "AGENT.md") }, ])("should import $target subagents", async ({ target, sourcePath }) => { const testDir = getTestDir(); @@ -439,6 +445,10 @@ describe("E2E: subagents (global mode)", () => { target: "deepagents", outputPath: join(".deepagents", "deepagents", "agents", "planner", "AGENTS.md"), }, + { + target: "devin", + outputPath: join(".config", "devin", "agents", "planner", "AGENT.md"), + }, { target: "vibe", outputPath: join(".vibe", "agents", "planner.toml") }, { target: "goose", diff --git a/src/features/subagents/devin-subagent.test.ts b/src/features/subagents/devin-subagent.test.ts new file mode 100644 index 000000000..2dd373bf1 --- /dev/null +++ b/src/features/subagents/devin-subagent.test.ts @@ -0,0 +1,265 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { DevinSubagent } from "./devin-subagent.js"; +import { RulesyncSubagent } from "./rulesync-subagent.js"; + +describe("DevinSubagent", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("should return .devin/agents for project mode", () => { + const paths = DevinSubagent.getSettablePaths(); + expect(paths.relativeDirPath).toBe(join(".devin", "agents")); + }); + + it("should return ~/.config/devin/agents for global mode", () => { + const paths = DevinSubagent.getSettablePaths({ global: true }); + expect(paths.relativeDirPath).toBe(join(".config", "devin", "agents")); + }); + }); + + describe("constructor", () => { + it("should create with name and description", () => { + const subagent = new DevinSubagent({ + outputRoot: testDir, + relativeDirPath: join(".devin", "agents"), + relativeFilePath: join("my-agent", "AGENT.md"), + frontmatter: { name: "My Agent", description: "Does useful things." }, + body: "You are a helpful agent.", + }); + + expect(subagent.getFrontmatter().name).toBe("My Agent"); + expect(subagent.getBody()).toBe("You are a helpful agent."); + }); + + it("should create with Devin-specific optional fields", () => { + const subagent = new DevinSubagent({ + outputRoot: testDir, + relativeDirPath: join(".devin", "agents"), + relativeFilePath: join("my-agent", "AGENT.md"), + frontmatter: { + name: "Agent", + description: "Desc.", + model: "claude-sonnet-4-6", + "allowed-tools": ["read", "search"], + permissions: { allow: ["read"], deny: ["write"] }, + "max-nesting": 2, + }, + body: "System prompt.", + }); + + expect(subagent.getFrontmatter().model).toBe("claude-sonnet-4-6"); + expect(subagent.getFrontmatter()["allowed-tools"]).toEqual(["read", "search"]); + expect(subagent.getFrontmatter().permissions).toEqual({ + allow: ["read"], + deny: ["write"], + }); + expect(subagent.getFrontmatter()["max-nesting"]).toBe(2); + }); + }); + + describe("fromFile", () => { + it("should read subagent from .devin/agents//AGENT.md", async () => { + const agentDir = join(testDir, ".devin", "agents", "test-agent"); + await ensureDir(agentDir); + const content = `--- +name: Test Agent +description: A test agent. +model: claude-haiku-4-5-20251001 +--- + +You are a test agent.`; + await writeFileContent(join(agentDir, "AGENT.md"), content); + + const subagent = await DevinSubagent.fromFile({ + outputRoot: testDir, + relativeFilePath: join("test-agent", "AGENT.md"), + }); + + expect(subagent.getFrontmatter().name).toBe("Test Agent"); + expect(subagent.getFrontmatter().model).toBe("claude-haiku-4-5-20251001"); + expect(subagent.getBody()).toBe("You are a test agent."); + }); + }); + + describe("fromRulesyncSubagent", () => { + it("should map name and description and emit /AGENT.md", () => { + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: ".rulesync/subagents", + relativeFilePath: "my-agent.md", + frontmatter: { name: "My Agent", description: "Does things.", targets: ["devin"] }, + body: "You are an agent.", + }); + + const subagent = DevinSubagent.fromRulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: join(".devin", "agents"), + rulesyncSubagent, + }) as DevinSubagent; + + expect(subagent.getFrontmatter().name).toBe("My Agent"); + expect(subagent.getFrontmatter().description).toBe("Does things."); + expect(subagent.getRelativeDirPath()).toBe(join(".devin", "agents")); + expect(subagent.getRelativeFilePath()).toBe(join("my-agent", "AGENT.md")); + }); + + it("should pull Devin-specific fields from the devin tool-specific section", () => { + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: ".rulesync/subagents", + relativeFilePath: "my-agent.md", + frontmatter: { + name: "Agent", + description: "Desc.", + targets: ["devin"], + devin: { + model: "claude-sonnet-4-6", + "allowed-tools": ["read"], + "max-nesting": 1, + }, + }, + body: "System prompt.", + }); + + const subagent = DevinSubagent.fromRulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: join(".devin", "agents"), + rulesyncSubagent, + }) as DevinSubagent; + + expect(subagent.getFrontmatter().model).toBe("claude-sonnet-4-6"); + expect(subagent.getFrontmatter()["allowed-tools"]).toEqual(["read"]); + expect(subagent.getFrontmatter()["max-nesting"]).toBe(1); + }); + + it("should emit /AGENT.md in global mode", () => { + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: ".rulesync/subagents", + relativeFilePath: "my-agent.md", + frontmatter: { name: "My Agent", description: "Does things.", targets: ["devin"] }, + body: "You are an agent.", + }); + + const subagent = DevinSubagent.fromRulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: join(".devin", "agents"), + rulesyncSubagent, + global: true, + }) as DevinSubagent; + + expect(subagent.getRelativeDirPath()).toBe(join(".config", "devin", "agents")); + expect(subagent.getRelativeFilePath()).toBe(join("my-agent", "AGENT.md")); + }); + }); + + describe("toRulesyncSubagent", () => { + it("should convert back to rulesync subagent preserving name and body", () => { + const subagent = new DevinSubagent({ + outputRoot: testDir, + relativeDirPath: join(".devin", "agents"), + relativeFilePath: join("my-agent", "AGENT.md"), + frontmatter: { name: "My Agent", description: "Does things.", model: "claude-sonnet-4-6" }, + body: "You are an agent.", + }); + + const rulesyncSubagent = subagent.toRulesyncSubagent(); + const frontmatter = rulesyncSubagent.getFrontmatter(); + + expect(frontmatter.name).toBe("My Agent"); + expect(frontmatter.description).toBe("Does things."); + expect(rulesyncSubagent.getBody()).toBe("You are an agent."); + // The directory name (not the AGENT.md filename) becomes the flat rulesync file. + expect(rulesyncSubagent.getRelativeFilePath()).toBe("my-agent.md"); + }); + + it("should store Devin-specific fields in the devin tool-specific section", () => { + const subagent = new DevinSubagent({ + outputRoot: testDir, + relativeDirPath: join(".devin", "agents"), + relativeFilePath: join("my-agent", "AGENT.md"), + frontmatter: { + name: "Agent", + description: "Desc.", + model: "claude-haiku-4-5-20251001", + "max-nesting": 3, + }, + body: "System prompt.", + }); + + const rulesyncSubagent = subagent.toRulesyncSubagent(); + const devinSection = rulesyncSubagent.getFrontmatter().devin as Record; + + expect(devinSection?.model).toBe("claude-haiku-4-5-20251001"); + expect(devinSection?.["max-nesting"]).toBe(3); + }); + }); + + describe("forDeletion", () => { + it("should create a deletable placeholder for /AGENT.md", () => { + const subagent = DevinSubagent.forDeletion({ + outputRoot: testDir, + relativeDirPath: join(".devin", "agents"), + relativeFilePath: join("orphan", "AGENT.md"), + }); + + expect(subagent.getRelativeDirPath()).toBe(join(".devin", "agents")); + expect(subagent.getRelativeFilePath()).toBe(join("orphan", "AGENT.md")); + expect(subagent.getBody()).toBe(""); + }); + }); + + describe("isTargetedByRulesyncSubagent", () => { + it("should return true for devin target", () => { + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: ".rulesync/subagents", + relativeFilePath: "agent.md", + frontmatter: { name: "Agent", targets: ["devin"] }, + body: "", + }); + + expect(DevinSubagent.isTargetedByRulesyncSubagent(rulesyncSubagent)).toBe(true); + }); + + it("should return true for wildcard target", () => { + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: ".rulesync/subagents", + relativeFilePath: "agent.md", + frontmatter: { name: "Agent", targets: ["*"] }, + body: "", + }); + + expect(DevinSubagent.isTargetedByRulesyncSubagent(rulesyncSubagent)).toBe(true); + }); + + it("should return false for different tool", () => { + const rulesyncSubagent = new RulesyncSubagent({ + outputRoot: testDir, + relativeDirPath: ".rulesync/subagents", + relativeFilePath: "agent.md", + frontmatter: { name: "Agent", targets: ["claudecode"] }, + body: "", + }); + + expect(DevinSubagent.isTargetedByRulesyncSubagent(rulesyncSubagent)).toBe(false); + }); + }); +}); diff --git a/src/features/subagents/devin-subagent.ts b/src/features/subagents/devin-subagent.ts new file mode 100644 index 000000000..60e984945 --- /dev/null +++ b/src/features/subagents/devin-subagent.ts @@ -0,0 +1,268 @@ +import { basename, dirname, extname, join } from "node:path"; + +import { z } from "zod/mini"; + +import { + DEVIN_AGENTS_DIR_PATH, + DEVIN_GLOBAL_AGENTS_DIR_PATH, +} from "../../constants/devin-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"; +import { readFileContent } from "../../utils/file.js"; +import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter.js"; +import { RulesyncSubagent, RulesyncSubagentFrontmatter } from "./rulesync-subagent.js"; +import { + ToolSubagent, + ToolSubagentForDeletionParams, + ToolSubagentFromFileParams, + ToolSubagentFromRulesyncSubagentParams, + ToolSubagentSettablePaths, +} from "./tool-subagent.js"; + +// looseObject preserves unknown keys during parsing (like passthrough in Zod 3). +// Devin Local custom subagent profiles are native AGENT.md files with YAML +// frontmatter, stored in a directory-per-agent layout where the directory name +// is the profile id. See https://docs.devin.ai/cli/subagents +// - `name`, `description`: identity fields. +// - `model`, `allowed-tools`, `permissions`, `max-nesting`: optional +// configuration fields, passed through verbatim when present. +export const DevinSubagentFrontmatterSchema = z.looseObject({ + name: z.string(), + description: z.optional(z.string()), + model: z.optional(z.string()), + "allowed-tools": z.optional(z.array(z.string())), + permissions: z.optional( + z.looseObject({ + allow: z.optional(z.array(z.string())), + deny: z.optional(z.array(z.string())), + ask: z.optional(z.array(z.string())), + }), + ), + "max-nesting": z.optional(z.number()), +}); + +export type DevinSubagentFrontmatter = z.infer; + +export type DevinSubagentParams = { + frontmatter: DevinSubagentFrontmatter; + body: string; +} & Omit & { fileContent?: string }; + +/** + * File name for each Devin custom subagent profile. + * + * Devin Local discovers a subagent as a named directory containing an + * `AGENT.md` file (the directory name becomes the profile id), so each + * subagent must live at `.devin/agents//AGENT.md` (project) or + * `~/.config/devin/agents//AGENT.md` (global). + * + * @see https://docs.devin.ai/cli/subagents + */ +const DEVIN_SUBAGENT_FILE_NAME = "AGENT.md"; + +export class DevinSubagent extends ToolSubagent { + private readonly frontmatter: DevinSubagentFrontmatter; + private readonly body: string; + + constructor({ frontmatter, body, fileContent, ...rest }: DevinSubagentParams) { + if (rest.validate !== false) { + const result = DevinSubagentFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error( + `Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`, + ); + } + } + + super({ + ...rest, + fileContent: fileContent ?? stringifyFrontmatter(body, frontmatter), + }); + + this.frontmatter = frontmatter; + this.body = body; + } + + static getSettablePaths({ + global = false, + }: { global?: boolean } = {}): ToolSubagentSettablePaths { + // Devin custom subagent profiles use different paths for project and global + // modes. The home directory is resolved by the processor through outputRoot + // in global mode: + // - Project mode: {process.cwd()}/.devin/agents/ + // - Global mode: {getHomeDirectory()}/.config/devin/agents/ + return { + relativeDirPath: global ? DEVIN_GLOBAL_AGENTS_DIR_PATH : DEVIN_AGENTS_DIR_PATH, + }; + } + + getFrontmatter(): DevinSubagentFrontmatter { + return this.frontmatter; + } + + getBody(): string { + return this.body; + } + + toRulesyncSubagent(): RulesyncSubagent { + const { name, description, ...restFields } = this.frontmatter; + + const devinSection: Record = { + ...restFields, + }; + + const rulesyncFrontmatter: RulesyncSubagentFrontmatter = { + targets: ["*"] as const, + name, + description, + ...(Object.keys(devinSection).length > 0 && { devin: devinSection }), + }; + + return new RulesyncSubagent({ + outputRoot: this.getOutputRoot(), + frontmatter: rulesyncFrontmatter, + body: this.body, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + // The tool-side path is `/AGENT.md`; the rulesync file is a flat + // `.md`, so the subagent name comes from the parent directory. + relativeFilePath: `${this.getSubagentName()}.md`, + validate: true, + }); + } + + /** + * Derive the subagent name from this instance's relative file path. + * + * The tool-side layout is `/AGENT.md`, so the name is the parent + * directory of the file. If the path is unexpectedly flat (e.g. a legacy + * `.md`), fall back to the basename without extension. + */ + private getSubagentName(): string { + const relativeFilePath = this.getRelativeFilePath(); + const dir = dirname(relativeFilePath); + if (dir && dir !== ".") { + return basename(dir); + } + return basename(relativeFilePath, extname(relativeFilePath)); + } + + static fromRulesyncSubagent({ + outputRoot = process.cwd(), + rulesyncSubagent, + validate = true, + global = false, + }: ToolSubagentFromRulesyncSubagentParams): ToolSubagent { + const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter(); + const devinSection = this.filterToolSpecificSection(rulesyncFrontmatter.devin ?? {}, [ + "name", + "description", + ]); + + const rawDevinFrontmatter = { + name: rulesyncFrontmatter.name, + description: rulesyncFrontmatter.description, + ...devinSection, + }; + + const result = DevinSubagentFrontmatterSchema.safeParse(rawDevinFrontmatter); + if (!result.success) { + throw new Error( + `Invalid devin subagent frontmatter in ${rulesyncSubagent.getRelativeFilePath()}: ${formatError(result.error)}`, + ); + } + + const devinFrontmatter = result.data; + const body = rulesyncSubagent.getBody(); + const fileContent = stringifyFrontmatter(body, devinFrontmatter); + + const paths = this.getSettablePaths({ global }); + + // The rulesync subagent is a flat `.md`; Devin requires a + // directory-per-agent layout, so emit `/AGENT.md`. + const subagentName = basename( + rulesyncSubagent.getRelativeFilePath(), + extname(rulesyncSubagent.getRelativeFilePath()), + ); + + return new DevinSubagent({ + outputRoot, + frontmatter: devinFrontmatter, + body, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: join(subagentName, DEVIN_SUBAGENT_FILE_NAME), + fileContent, + validate, + global, + }); + } + + validate(): ValidationResult { + if (!this.frontmatter) { + return { success: true, error: null }; + } + + const result = DevinSubagentFrontmatterSchema.safeParse(this.frontmatter); + if (result.success) { + return { success: true, error: null }; + } else { + return { + success: false, + error: new Error( + `Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`, + ), + }; + } + } + + static isTargetedByRulesyncSubagent(rulesyncSubagent: RulesyncSubagent): boolean { + return this.isTargetedByRulesyncSubagentDefault({ + rulesyncSubagent, + toolTarget: "devin", + }); + } + + static async fromFile({ + outputRoot = process.cwd(), + relativeFilePath, + validate = true, + global = false, + }: ToolSubagentFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath); + const fileContent = await readFileContent(filePath); + const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath); + + const result = DevinSubagentFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`); + } + + return new DevinSubagent({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + frontmatter: result.data, + body: content.trim(), + fileContent, + validate, + global, + }); + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolSubagentForDeletionParams): DevinSubagent { + return new DevinSubagent({ + outputRoot, + relativeDirPath, + relativeFilePath, + frontmatter: { name: "", description: "" }, + body: "", + fileContent: "", + validate: false, + }); + } +} diff --git a/src/features/subagents/subagents-processor.test.ts b/src/features/subagents/subagents-processor.test.ts index 6b3e025fc..4a60fdabc 100644 --- a/src/features/subagents/subagents-processor.test.ts +++ b/src/features/subagents/subagents-processor.test.ts @@ -1032,6 +1032,7 @@ Second global content`; "copilotcli", "cursor", "deepagents", + "devin", "factorydroid", "geminicli", "goose", @@ -1080,6 +1081,7 @@ Second global content`; "copilotcli", "cursor", "deepagents", + "devin", "factorydroid", "geminicli", "goose", diff --git a/src/features/subagents/subagents-processor.ts b/src/features/subagents/subagents-processor.ts index 39af890d7..cb101c5db 100644 --- a/src/features/subagents/subagents-processor.ts +++ b/src/features/subagents/subagents-processor.ts @@ -18,6 +18,7 @@ import { CopilotSubagent } from "./copilot-subagent.js"; import { CopilotcliSubagent } from "./copilotcli-subagent.js"; import { CursorSubagent } from "./cursor-subagent.js"; import { DeepagentsSubagent } from "./deepagents-subagent.js"; +import { DevinSubagent } from "./devin-subagent.js"; import { FactorydroidSubagent } from "./factorydroid-subagent.js"; import { GeminiCliSubagent } from "./geminicli-subagent.js"; import { GooseSubagent } from "./goose-subagent.js"; @@ -79,6 +80,7 @@ const subagentsProcessorToolTargetTuple = [ "copilotcli", "cursor", "deepagents", + "devin", "factorydroid", "geminicli", "goose", @@ -193,6 +195,22 @@ export const toolSubagentFactories = new Map/AGENT.md` (project) + // and `~/.config/devin/agents//AGENT.md` (global). The flat agents + // root is not scanned, so the glob descends one level to the AGENT.md file. + // https://docs.devin.ai/cli/subagents + class: DevinSubagent, + meta: { + supportsSimulated: false, + supportsGlobal: true, + filePattern: join("*", "AGENT.md"), + }, + }, + ], [ "factorydroid", {