diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 020e626cb..ba26ff4f2 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -28,6 +28,7 @@ import { existsSync } from "fs" import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" +import { ModesMigrator } from "../kilocode/modes-migrator" // kilocode_change export namespace Config { const log = Log.create({ service: "config" }) @@ -95,6 +96,26 @@ export namespace Config { log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } + // kilocode_change start - Load Kilocode custom modes + try { + const kilocodeMigration = await ModesMigrator.migrate({ + projectDir: Instance.directory, + }) + if (Object.keys(kilocodeMigration.agents).length > 0) { + result = mergeConfigConcatArrays(result, { agent: kilocodeMigration.agents }) + log.debug("loaded kilocode custom modes", { + count: Object.keys(kilocodeMigration.agents).length, + modes: Object.keys(kilocodeMigration.agents), + }) + } + for (const skipped of kilocodeMigration.skipped) { + log.debug("skipped kilocode mode", { slug: skipped.slug, reason: skipped.reason }) + } + } catch (err) { + log.warn("failed to load kilocode modes", { error: err }) + } + // kilocode_change end + result.agent = result.agent || {} result.mode = result.mode || {} result.plugin = result.plugin || [] diff --git a/packages/opencode/src/kilocode/config-injector.ts b/packages/opencode/src/kilocode/config-injector.ts new file mode 100644 index 000000000..fa8ee7990 --- /dev/null +++ b/packages/opencode/src/kilocode/config-injector.ts @@ -0,0 +1,47 @@ +import { Config } from "../config/config" +import { ModesMigrator } from "./modes-migrator" + +export namespace KilocodeConfigInjector { + export interface InjectionResult { + configJson: string + warnings: string[] + } + + export async function buildConfig(options: { + projectDir: string + globalSettingsDir?: string + /** Skip reading from global paths (VSCode storage, home dir). Used for testing. */ + skipGlobalPaths?: boolean + }): Promise { + const warnings: string[] = [] + + // Migrate custom modes only + const modesMigration = await ModesMigrator.migrate(options) + + // Log skipped default modes (for debugging) + for (const skipped of modesMigration.skipped) { + warnings.push(`Mode '${skipped.slug}' skipped: ${skipped.reason}`) + } + + // Build config object + const config: Partial = {} + + if (Object.keys(modesMigration.agents).length > 0) { + config.agent = modesMigration.agents + } + + return { + configJson: JSON.stringify(config), + warnings, + } + } + + export function getEnvVars(configJson: string): Record { + if (!configJson || configJson === "{}") { + return {} + } + return { + OPENCODE_CONFIG_CONTENT: configJson, + } + } +} diff --git a/packages/opencode/src/kilocode/docs/modes-migration.md b/packages/opencode/src/kilocode/docs/modes-migration.md new file mode 100644 index 000000000..0dfec0937 --- /dev/null +++ b/packages/opencode/src/kilocode/docs/modes-migration.md @@ -0,0 +1,170 @@ +# Kilocode Modes Migration + +This document explains how Kilocode custom modes are automatically migrated to Opencode agents. + +## Overview + +Kilocode stores custom modes in YAML files. When Opencode starts, it reads these files and converts them to Opencode's agent format, injecting them via the `OPENCODE_CONFIG_CONTENT` mechanism. + +## Source Locations + +The migrator reads custom modes from these locations (in order, later entries override earlier ones): + +### Global Modes (VSCode Extension Storage) + +| Platform | Path | +|----------|------| +| macOS | `~/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/settings/custom_modes.yaml` | +| Windows | `%APPDATA%/Code/User/globalStorage/kilocode.kilo-code/settings/custom_modes.yaml` | +| Linux | `~/.config/Code/User/globalStorage/kilocode.kilo-code/settings/custom_modes.yaml` | + +### Project Modes + +| Location | Description | +|----------|-------------| +| `.kilocodemodes` | Project-specific modes in the workspace root | + +## Field Mapping + +### Migrated Fields + +| Kilocode Field | Opencode Field | Notes | +|----------------|----------------|-------| +| `slug` | Agent key | Used as the agent identifier | +| `roleDefinition` | `prompt` | Combined with `customInstructions` | +| `customInstructions` | `prompt` | Appended after `roleDefinition` with `\n\n` separator | +| `groups` | `permission` | See permission mapping below | +| `description` | `description` | Primary source for description | +| `whenToUse` | `description` | Fallback if no `description` | +| `name` | `description` | Final fallback | + +### Permission Mapping + +Kilocode uses "groups" to define what tools a mode can access. These are converted to Opencode's permission system: + +| Kilocode Group | Opencode Permission | Notes | +|----------------|---------------------|-------| +| `read` | `read: "allow"` | File reading | +| `edit` | `edit: "allow"` | File editing | +| `command` | `bash: "allow"` | Shell commands | +| `browser` | `bash: "allow"` | Browser actions (via bash) | +| `mcp` | `mcp: "allow"` | MCP server access | + +**Important:** Permissions that are NOT in the groups list are explicitly set to `"deny"`. This ensures that a mode with only `read` and `edit` groups cannot run shell commands or access MCP servers. + +### File Restrictions + +Kilocode supports restricting edit access to specific file patterns: + +```yaml +groups: + - read + - - edit + - fileRegex: "\\.md$" + description: "Markdown files only" +``` + +This converts to: + +```json +{ + "permission": { + "read": "allow", + "edit": { + "\\.md$": "allow", + "*": "deny" + }, + "bash": "deny", + "mcp": "deny" + } +} +``` + +Note: `bash` and `mcp` are explicitly denied because they weren't in the original groups list. + +## Default Modes + +The following Kilocode default modes are **skipped** during migration because Opencode has native equivalents: + +| Kilocode Mode | Reason | +|---------------|--------| +| `code` | Maps to Opencode's `build` agent | +| `architect` | Maps to Opencode's `plan` agent | +| `ask` | Read-only exploration (use `explore` subagent) | +| `debug` | Debugging workflow (use `build` with debug instructions) | +| `orchestrator` | Redundant - all Opencode agents can spawn subagents | + +## Example Conversion + +### Kilocode Mode (YAML) + +```yaml +customModes: + - slug: translate + name: Translate + roleDefinition: You are a linguistic specialist focused on translation. + customInstructions: | + When translating: + - Maintain consistent terminology + - Preserve formatting + groups: + - read + - - edit + - fileRegex: "src/i18n/.*\\.json$" + description: "Translation files only" + description: Translate content between languages +``` + +### Opencode Agent (JSON) + +```json +{ + "agent": { + "translate": { + "mode": "primary", + "description": "Translate content between languages", + "prompt": "You are a linguistic specialist focused on translation.\n\nWhen translating:\n- Maintain consistent terminology\n- Preserve formatting", + "permission": { + "read": "allow", + "edit": { + "src/i18n/.*\\.json$": "allow", + "*": "deny" + } + } + } + } +} +``` + +## Not Migrated (Future Phases) + +The following Kilocode features are not yet migrated: + +| Feature | Status | Notes | +|---------|--------|-------| +| Rules (`.kilocode/rules/`) | Phase 2 | Will map to `instructions` array | +| Workflows (`.kilocode/workflows/`) | Phase 2 | Will map to custom commands | +| MCP Servers (`mcp_settings.json`) | Phase 2 | Will map to `mcp` config | +| Provider Settings | Phase 2 | Will map to `provider` config | +| Mode-specific API configs | Phase 2 | Different models per mode | +| Organization modes | Not planned | `source: organization` not preserved | + +## Troubleshooting + +### Mode not appearing + +1. Check the file exists at the expected location +2. Verify YAML syntax is valid +3. Ensure the mode has a unique `slug` +4. Check it's not a default mode (which are skipped) + +### Permissions not working + +1. Verify the `groups` array is correctly formatted +2. For file restrictions, ensure `fileRegex` is a valid regex +3. Check the permission mapping table above + +## Related Files + +- [`modes-migrator.ts`](../modes-migrator.ts) - Core migration logic +- [`config-injector.ts`](../config-injector.ts) - Config building and injection diff --git a/packages/opencode/src/kilocode/index.ts b/packages/opencode/src/kilocode/index.ts new file mode 100644 index 000000000..6995757e8 --- /dev/null +++ b/packages/opencode/src/kilocode/index.ts @@ -0,0 +1,2 @@ +export { ModesMigrator } from "./modes-migrator" +export { KilocodeConfigInjector } from "./config-injector" diff --git a/packages/opencode/src/kilocode/modes-migrator.ts b/packages/opencode/src/kilocode/modes-migrator.ts new file mode 100644 index 000000000..02ab03985 --- /dev/null +++ b/packages/opencode/src/kilocode/modes-migrator.ts @@ -0,0 +1,184 @@ +import matter from "gray-matter" +import * as fs from "fs/promises" +import * as path from "path" +import os from "os" +import { Config } from "../config/config" + +export namespace ModesMigrator { + // Kilocode mode structure + export interface KilocodeMode { + slug: string + name: string + roleDefinition: string + groups: Array + customInstructions?: string + whenToUse?: string + description?: string + source?: "global" | "project" | "organization" + } + + export interface KilocodeModesFile { + customModes: KilocodeMode[] + } + + // Default modes to skip - these have native Opencode equivalents + const DEFAULT_MODE_SLUGS = new Set(["code", "architect", "ask", "debug", "orchestrator"]) + + // Group to permission mapping + const GROUP_TO_PERMISSION: Record = { + read: "read", + edit: "edit", + browser: "bash", + command: "bash", + mcp: "mcp", + } + + // All permissions that should be explicitly set (deny if not in groups) + const ALL_PERMISSIONS = ["read", "edit", "bash", "mcp"] + + export function isDefaultMode(slug: string): boolean { + return DEFAULT_MODE_SLUGS.has(slug) + } + + export function convertPermissions(groups: KilocodeMode["groups"]): Config.Permission { + const permission: Record = {} + const allowedPermissions = new Set() + + for (const group of groups) { + if (typeof group === "string") { + const permKey = GROUP_TO_PERMISSION[group] ?? group + allowedPermissions.add(permKey) + permission[permKey] = "allow" + } else if (Array.isArray(group)) { + const [groupName, config] = group + const permKey = GROUP_TO_PERMISSION[groupName] ?? groupName + allowedPermissions.add(permKey) + + if (config?.fileRegex) { + permission[permKey] = { + [config.fileRegex]: "allow", + "*": "deny", + } + } else { + permission[permKey] = "allow" + } + } + } + + // Explicitly deny permissions that aren't in the groups + // This is critical because Opencode defaults to "ask" for missing permissions + for (const perm of ALL_PERMISSIONS) { + if (!allowedPermissions.has(perm)) { + permission[perm] = "deny" + } + } + + return permission + } + + export function convertMode(mode: KilocodeMode): Config.Agent { + const prompt = [mode.roleDefinition, mode.customInstructions].filter(Boolean).join("\n\n") + + return { + mode: "primary", + description: mode.description ?? mode.whenToUse ?? mode.name, + prompt, + permission: convertPermissions(mode.groups), + } + } + + export async function readModesFile(filepath: string): Promise { + try { + const content = await fs.readFile(filepath, "utf-8") + // Wrap YAML content in frontmatter delimiters so gray-matter can parse it + const wrapped = `---\n${content}\n---` + const parsed = matter(wrapped).data as KilocodeModesFile + return parsed?.customModes ?? [] + } catch (err: any) { + if (err.code === "ENOENT") return [] + throw err + } + } + + export interface MigrationResult { + agents: Record + skipped: Array<{ slug: string; reason: string }> + } + + // Get platform-specific VSCode global storage path + function getVSCodeGlobalStoragePath(): string { + const home = os.homedir() + switch (process.platform) { + case "darwin": + return path.join(home, "Library", "Application Support", "Code", "User", "globalStorage", "kilocode.kilo-code") + case "win32": + return path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), "Code", "User", "globalStorage", "kilocode.kilo-code") + default: // linux + return path.join(home, ".config", "Code", "User", "globalStorage", "kilocode.kilo-code") + } + } + + export async function migrate(options: { + projectDir: string + globalSettingsDir?: string + /** Skip reading from global paths (VSCode storage, home dir). Used for testing. */ + skipGlobalPaths?: boolean + }): Promise { + const result: MigrationResult = { + agents: {}, + skipped: [], + } + + // Collect modes from all sources + const allModes: KilocodeMode[] = [] + + if (!options.skipGlobalPaths) { + // 1. VSCode extension global storage (primary location for global modes) + const vscodeGlobalPath = path.join(getVSCodeGlobalStoragePath(), "settings", "custom_modes.yaml") + allModes.push(...(await readModesFile(vscodeGlobalPath))) + + // 2. CLI global settings (fallback/alternative location) + const cliGlobalPath = path.join(os.homedir(), ".kilocode", "cli", "global", "settings", "custom_modes.yaml") + allModes.push(...(await readModesFile(cliGlobalPath))) + + // 3. Home directory .kilocodemodes + const homeModesPath = path.join(os.homedir(), ".kilocodemodes") + if (homeModesPath !== options.projectDir) { + allModes.push(...(await readModesFile(homeModesPath))) + } + } + + // 4. Legacy/explicit global settings dir (for backwards compatibility and testing) + if (options.globalSettingsDir) { + const legacyPath = path.join(options.globalSettingsDir, "custom_modes.yaml") + allModes.push(...(await readModesFile(legacyPath))) + } + + // 5. Project .kilocodemodes + const projectModesPath = path.join(options.projectDir, ".kilocodemodes") + allModes.push(...(await readModesFile(projectModesPath))) + + // Deduplicate by slug (later entries win) + const modesBySlug = new Map() + for (const mode of allModes) { + modesBySlug.set(mode.slug, mode) + } + + // Process each mode + for (const [slug, mode] of modesBySlug) { + // Skip default modes - let Opencode's native agents handle these + if (isDefaultMode(slug)) { + result.skipped.push({ + slug, + reason: "Default mode - using Opencode native agent instead", + }) + continue + } + + // Migrate custom mode + result.agents[slug] = convertMode(mode) + } + + return result + } +} diff --git a/packages/opencode/test/kilocode/config-injector.test.ts b/packages/opencode/test/kilocode/config-injector.test.ts new file mode 100644 index 000000000..45d98be06 --- /dev/null +++ b/packages/opencode/test/kilocode/config-injector.test.ts @@ -0,0 +1,84 @@ +import { test, expect, describe } from "bun:test" +import { KilocodeConfigInjector } from "../../src/kilocode/config-injector" +import { tmpdir } from "../fixture/fixture" +import path from "path" + +describe("KilocodeConfigInjector", () => { + describe("buildConfig", () => { + test("returns empty config when no modes exist", async () => { + await using tmp = await tmpdir() + + const result = await KilocodeConfigInjector.buildConfig({ projectDir: tmp.path, skipGlobalPaths: true }) + + expect(result.configJson).toBe("{}") + expect(result.warnings).toHaveLength(0) + }) + + test("includes custom modes in config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, ".kilocodemodes"), + `customModes: + - slug: translate + name: Translate + roleDefinition: You are a translator + groups: + - read + - edit`, + ) + }, + }) + + const result = await KilocodeConfigInjector.buildConfig({ projectDir: tmp.path, skipGlobalPaths: true }) + const config = JSON.parse(result.configJson) + + expect(config.agent).toBeDefined() + expect(config.agent.translate).toBeDefined() + expect(config.agent.translate.mode).toBe("primary") + }) + + test("adds warnings for skipped default modes", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, ".kilocodemodes"), + `customModes: + - slug: code + name: Code + roleDefinition: Default code + groups: + - read`, + ) + }, + }) + + const result = await KilocodeConfigInjector.buildConfig({ projectDir: tmp.path, skipGlobalPaths: true }) + + expect(result.warnings).toHaveLength(1) + expect(result.warnings[0]).toContain("code") + expect(result.warnings[0]).toContain("skipped") + }) + }) + + describe("getEnvVars", () => { + test("returns empty object for empty config", () => { + const envVars = KilocodeConfigInjector.getEnvVars("{}") + expect(envVars).toEqual({}) + }) + + test("returns empty object for empty string", () => { + const envVars = KilocodeConfigInjector.getEnvVars("") + expect(envVars).toEqual({}) + }) + + test("returns OPENCODE_CONFIG_CONTENT for non-empty config", () => { + const config = JSON.stringify({ agent: { test: {} } }) + const envVars = KilocodeConfigInjector.getEnvVars(config) + + expect(envVars).toEqual({ + OPENCODE_CONFIG_CONTENT: config, + }) + }) + }) +}) diff --git a/packages/opencode/test/kilocode/modes-migrator.test.ts b/packages/opencode/test/kilocode/modes-migrator.test.ts new file mode 100644 index 000000000..750b205b3 --- /dev/null +++ b/packages/opencode/test/kilocode/modes-migrator.test.ts @@ -0,0 +1,320 @@ +import { test, expect, describe } from "bun:test" +import { ModesMigrator } from "../../src/kilocode/modes-migrator" +import { tmpdir } from "../fixture/fixture" +import path from "path" + +describe("ModesMigrator", () => { + describe("isDefaultMode", () => { + test("returns true for default modes", () => { + expect(ModesMigrator.isDefaultMode("code")).toBe(true) + expect(ModesMigrator.isDefaultMode("architect")).toBe(true) + expect(ModesMigrator.isDefaultMode("ask")).toBe(true) + expect(ModesMigrator.isDefaultMode("debug")).toBe(true) + expect(ModesMigrator.isDefaultMode("orchestrator")).toBe(true) + }) + + test("returns false for custom modes", () => { + expect(ModesMigrator.isDefaultMode("translate")).toBe(false) + expect(ModesMigrator.isDefaultMode("my-custom-mode")).toBe(false) + expect(ModesMigrator.isDefaultMode("reviewer")).toBe(false) + }) + }) + + describe("convertPermissions", () => { + test("converts simple groups to permissions and denies missing", () => { + const groups = ["read", "edit", "command"] + const permissions = ModesMigrator.convertPermissions(groups) + + expect(permissions.read).toBe("allow") + expect(permissions.edit).toBe("allow") + expect(permissions.bash).toBe("allow") + expect(permissions.mcp).toBe("deny") // Not in groups, should be denied + }) + + test("maps browser group to bash permission", () => { + const groups = ["browser"] + const permissions = ModesMigrator.convertPermissions(groups) + + expect(permissions.bash).toBe("allow") + expect(permissions.read).toBe("deny") + expect(permissions.edit).toBe("deny") + expect(permissions.mcp).toBe("deny") + }) + + test("maps mcp group to mcp permission", () => { + const groups = ["mcp"] + const permissions = ModesMigrator.convertPermissions(groups) + + expect(permissions.mcp).toBe("allow") + expect(permissions.read).toBe("deny") + expect(permissions.edit).toBe("deny") + expect(permissions.bash).toBe("deny") + }) + + test("converts fileRegex groups to restricted permissions", () => { + const groups: ModesMigrator.KilocodeMode["groups"] = [ + "read", + ["edit", { fileRegex: "\\.md$", description: "Markdown only" }], + ] + const permissions = ModesMigrator.convertPermissions(groups) + + expect(permissions.read).toBe("allow") + expect(permissions.edit).toEqual({ + "\\.md$": "allow", + "*": "deny", + }) + expect(permissions.bash).toBe("deny") + expect(permissions.mcp).toBe("deny") + }) + + test("handles tuple without fileRegex", () => { + const groups: ModesMigrator.KilocodeMode["groups"] = [["edit", {}]] + const permissions = ModesMigrator.convertPermissions(groups) + + expect(permissions.edit).toBe("allow") + expect(permissions.read).toBe("deny") + expect(permissions.bash).toBe("deny") + expect(permissions.mcp).toBe("deny") + }) + + test("passes through unknown groups but still denies standard permissions", () => { + const groups = ["custom-group"] + const permissions = ModesMigrator.convertPermissions(groups) + + expect(permissions["custom-group"]).toBe("allow") + expect(permissions.read).toBe("deny") + expect(permissions.edit).toBe("deny") + expect(permissions.bash).toBe("deny") + expect(permissions.mcp).toBe("deny") + }) + + test("denies bash and mcp when only read and edit are allowed", () => { + const groups = ["read", "edit"] + const permissions = ModesMigrator.convertPermissions(groups) + + expect(permissions.read).toBe("allow") + expect(permissions.edit).toBe("allow") + expect(permissions.bash).toBe("deny") + expect(permissions.mcp).toBe("deny") + }) + }) + + describe("convertMode", () => { + test("converts full mode to agent config", () => { + const mode: ModesMigrator.KilocodeMode = { + slug: "translate", + name: "Translate", + roleDefinition: "You are a translator...", + customInstructions: "Translate accurately.", + groups: ["read", ["edit", { fileRegex: "\\.json$" }]], + } + + const agent = ModesMigrator.convertMode(mode) + + expect(agent.mode).toBe("primary") + expect(agent.prompt).toBe("You are a translator...\n\nTranslate accurately.") + expect(agent.permission?.read).toBe("allow") + expect(agent.permission?.edit).toEqual({ + "\\.json$": "allow", + "*": "deny", + }) + }) + + test("uses description when available", () => { + const mode: ModesMigrator.KilocodeMode = { + slug: "test", + name: "Test", + roleDefinition: "Role", + description: "Custom description", + groups: [], + } + + const agent = ModesMigrator.convertMode(mode) + expect(agent.description).toBe("Custom description") + }) + + test("falls back to whenToUse for description", () => { + const mode: ModesMigrator.KilocodeMode = { + slug: "test", + name: "Test", + roleDefinition: "Role", + whenToUse: "When to use this mode", + groups: [], + } + + const agent = ModesMigrator.convertMode(mode) + expect(agent.description).toBe("When to use this mode") + }) + + test("falls back to name for description", () => { + const mode: ModesMigrator.KilocodeMode = { + slug: "test", + name: "Test Mode", + roleDefinition: "Role", + groups: [], + } + + const agent = ModesMigrator.convertMode(mode) + expect(agent.description).toBe("Test Mode") + }) + + test("handles mode without customInstructions", () => { + const mode: ModesMigrator.KilocodeMode = { + slug: "test", + name: "Test", + roleDefinition: "You are a test agent.", + groups: ["read"], + } + + const agent = ModesMigrator.convertMode(mode) + expect(agent.prompt).toBe("You are a test agent.") + }) + }) + + describe("readModesFile", () => { + test("returns empty array for non-existent file", async () => { + const modes = await ModesMigrator.readModesFile("/non/existent/path.yaml") + expect(modes).toEqual([]) + }) + + test("reads and parses yaml file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "modes.yaml"), + `customModes: + - slug: translate + name: Translate + roleDefinition: You are a translator + groups: + - read + - edit`, + ) + }, + }) + + const modes = await ModesMigrator.readModesFile(path.join(tmp.path, "modes.yaml")) + expect(modes).toHaveLength(1) + expect(modes[0].slug).toBe("translate") + expect(modes[0].name).toBe("Translate") + }) + + test("returns empty array for file without customModes", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "modes.yaml"), "someOtherKey: value") + }, + }) + + const modes = await ModesMigrator.readModesFile(path.join(tmp.path, "modes.yaml")) + expect(modes).toEqual([]) + }) + }) + + describe("migrate", () => { + test("skips default modes", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, ".kilocodemodes"), + `customModes: + - slug: code + name: Code + roleDefinition: Default code mode + groups: + - read + - edit + - slug: translate + name: Translate + roleDefinition: Custom translator + groups: + - read`, + ) + }, + }) + + const result = await ModesMigrator.migrate({ projectDir: tmp.path, skipGlobalPaths: true }) + + expect(result.skipped).toHaveLength(1) + expect(result.skipped[0].slug).toBe("code") + expect(result.agents).toHaveProperty("translate") + expect(result.agents).not.toHaveProperty("code") + }) + + test("deduplicates modes by slug with later entries winning", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Create global settings dir + const globalDir = path.join(dir, "global-settings") + await Bun.write( + path.join(globalDir, "custom_modes.yaml"), + `customModes: + - slug: translate + name: Translate Global + roleDefinition: Global translator + groups: + - read`, + ) + + // Create project .kilocodemodes (should win) + await Bun.write( + path.join(dir, ".kilocodemodes"), + `customModes: + - slug: translate + name: Translate Project + roleDefinition: Project translator + groups: + - read + - edit`, + ) + + return globalDir + }, + }) + + const result = await ModesMigrator.migrate({ + projectDir: tmp.path, + globalSettingsDir: tmp.extra, + }) + + expect(result.agents.translate.prompt).toBe("Project translator") + }) + + test("returns empty agents when no custom modes exist", async () => { + await using tmp = await tmpdir() + + const result = await ModesMigrator.migrate({ projectDir: tmp.path, skipGlobalPaths: true }) + + expect(Object.keys(result.agents)).toHaveLength(0) + expect(result.skipped).toHaveLength(0) + }) + + test("migrates multiple custom modes", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, ".kilocodemodes"), + `customModes: + - slug: translate + name: Translate + roleDefinition: Translator + groups: + - read + - slug: reviewer + name: Reviewer + roleDefinition: Code reviewer + groups: + - read + - edit`, + ) + }, + }) + + const result = await ModesMigrator.migrate({ projectDir: tmp.path, skipGlobalPaths: true }) + + expect(Object.keys(result.agents)).toHaveLength(2) + expect(result.agents).toHaveProperty("translate") + expect(result.agents).toHaveProperty("reviewer") + }) + }) +})