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:
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user