Files
Marius Wichtner 8a9510603c feat(kilocode): add modes migration from Kilocode to Opencode agents
Phase 1 of Kilocode config migration:

- Add ModesMigrator to read custom modes from:
  - VSCode extension global storage (platform-specific paths)
  - Project .kilocodemodes files
  - Home directory .kilocodemodes

- Convert Kilocode mode groups to Opencode permissions:
  - read → read: allow
  - edit → edit: allow (with fileRegex support)
  - command/browser → bash: allow
  - mcp → mcp: allow
  - Missing groups are explicitly denied (not ask)

- Skip default modes (code, architect, ask, debug, orchestrator)
  as they map to native Opencode agents

- Add KilocodeConfigInjector for building OPENCODE_CONFIG_CONTENT

- Integration in config.ts to auto-load Kilocode modes

- 27 unit tests with 100% function coverage

- Documentation in kilocode/docs/modes-migration.md
2026-01-26 16:26:49 +01:00

321 lines
9.8 KiB
TypeScript

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")
})
})
})