Merge pull request #11 from Kilo-Org/schaltwerk/kilocode_config_migration_to

feat(kilocode): add modes migration from Kilocode to Opencode agents
This commit is contained in:
Marius
2026-01-26 16:39:55 +01:00
committed by GitHub
7 changed files with 828 additions and 0 deletions
+21
View File
@@ -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 || []
@@ -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<InjectionResult> {
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<Config.Info> = {}
if (Object.keys(modesMigration.agents).length > 0) {
config.agent = modesMigration.agents
}
return {
configJson: JSON.stringify(config),
warnings,
}
}
export function getEnvVars(configJson: string): Record<string, string> {
if (!configJson || configJson === "{}") {
return {}
}
return {
OPENCODE_CONFIG_CONTENT: configJson,
}
}
}
@@ -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
+2
View File
@@ -0,0 +1,2 @@
export { ModesMigrator } from "./modes-migrator"
export { KilocodeConfigInjector } from "./config-injector"
@@ -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<string | [string, { fileRegex?: string; description?: string }]>
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<string, string> = {
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<string, any> = {}
const allowedPermissions = new Set<string>()
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<KilocodeMode[]> {
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<string, Config.Agent>
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<MigrationResult> {
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<string, KilocodeMode>()
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
}
}