Merge branch 'dev' into schaltwerk/kilocode-mcp-migration

Merged dev branch which includes:
- Workflows migration (Phase 3)
- Rules migration (Phase 2)

Combined with MCP migration (Phase 4) from this branch.

Resolved merge conflicts in:
- packages/opencode/src/config/config.ts - Combined all four migrators
- packages/opencode/src/kilocode/index.ts - Export all migrators
This commit is contained in:
Marius Wichtner
2026-01-26 18:44:39 +01:00
9 changed files with 1043 additions and 28 deletions
+68 -24
View File
@@ -29,6 +29,8 @@ import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { ModesMigrator } from "../kilocode/modes-migrator" // kilocode_change
import { RulesMigrator } from "../kilocode/rules-migrator" // kilocode_change
import { WorkflowsMigrator } from "../kilocode/workflows-migrator" // kilocode_change
import { McpMigrator } from "../kilocode/mcp-migrator" // kilocode_change
export namespace Config {
@@ -49,11 +51,73 @@ export namespace Config {
export const state = Instance.state(async () => {
const auth = await Auth.all()
// Load remote/well-known config first as the base layer (lowest precedence)
// This allows organizations to provide default configs that users can override
const kilocodeMcp = await McpMigrator.loadMcpConfig(Instance.directory) // kilocode_change
let result: Info = Object.keys(kilocodeMcp).length > 0 ? { mcp: kilocodeMcp } : {}
// kilocode_change start - Load Kilocode configs first (lowest precedence)
// This ensures Opencode native configs always take precedence over legacy Kilocode configs
let result: Info = {}
// Load Kilocode custom modes (legacy fallback)
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 })
}
// Load Kilocode workflows as commands (legacy fallback)
try {
const workflowsMigration = await WorkflowsMigrator.migrate({
projectDir: Instance.directory,
})
if (Object.keys(workflowsMigration.commands).length > 0) {
result = mergeConfigConcatArrays(result, { command: workflowsMigration.commands })
log.debug("loaded kilocode workflows as commands", {
count: Object.keys(workflowsMigration.commands).length,
commands: Object.keys(workflowsMigration.commands),
})
}
} catch (err) {
log.warn("failed to load kilocode workflows", { error: err })
}
// Load Kilocode rules (legacy fallback)
try {
const kilocodeRules = await RulesMigrator.migrate({
projectDir: Instance.directory,
})
if (kilocodeRules.instructions.length > 0) {
result = mergeConfigConcatArrays(result, { instructions: kilocodeRules.instructions })
log.debug("loaded kilocode rules", {
count: kilocodeRules.instructions.length,
files: kilocodeRules.instructions,
})
}
for (const warning of kilocodeRules.warnings) {
log.debug("kilocode rules warning", { warning })
}
} catch (err) {
log.warn("failed to load kilocode rules", { error: err })
}
// Load Kilocode MCP servers (legacy fallback)
const kilocodeMcp = await McpMigrator.loadMcpConfig(Instance.directory)
if (Object.keys(kilocodeMcp).length > 0) {
result = mergeConfigConcatArrays(result, { mcp: kilocodeMcp })
}
// kilocode_change end
// Load remote/well-known config (overrides Kilocode legacy configs)
// This allows organizations to provide default configs that users can override
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
process.env[value.key] = value.token
@@ -99,26 +163,6 @@ 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 || []
@@ -1,5 +1,7 @@
import { Config } from "../config/config"
import { ModesMigrator } from "./modes-migrator"
import { RulesMigrator } from "./rules-migrator" // kilocode_change
import { WorkflowsMigrator } from "./workflows-migrator"
export namespace KilocodeConfigInjector {
export interface InjectionResult {
@@ -12,10 +14,15 @@ export namespace KilocodeConfigInjector {
globalSettingsDir?: string
/** Skip reading from global paths (VSCode storage, home dir). Used for testing. */
skipGlobalPaths?: boolean
/** Include rules migration. Defaults to true. */
includeRules?: boolean
}): Promise<InjectionResult> {
const warnings: string[] = []
// Migrate custom modes only
// Build config object
const config: Partial<Config.Info> = {}
// Migrate custom modes
const modesMigration = await ModesMigrator.migrate(options)
// Log skipped default modes (for debugging)
@@ -23,13 +30,35 @@ export namespace KilocodeConfigInjector {
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
}
// Migrate workflows to commands
const workflowsMigration = await WorkflowsMigrator.migrate(options)
warnings.push(...workflowsMigration.warnings)
if (Object.keys(workflowsMigration.commands).length > 0) {
config.command = workflowsMigration.commands
}
// kilocode_change start - Rules migration
if (options.includeRules !== false) {
const rulesMigration = await RulesMigrator.migrate({
projectDir: options.projectDir,
includeGlobal: !options.skipGlobalPaths,
includeModeSpecific: true,
})
warnings.push(...rulesMigration.warnings)
if (rulesMigration.instructions.length > 0) {
config.instructions = rulesMigration.instructions
}
}
// kilocode_change end
return {
configJson: JSON.stringify(config),
warnings,
@@ -0,0 +1,130 @@
# Kilocode Rules Migration
This document explains how Kilocode rules are automatically migrated to Opencode's `instructions` config array.
## Overview
Kilocode stores rules in various file locations. When Opencode starts, it reads these files and injects their paths into the `instructions` config array, which Opencode then loads as part of the system prompt.
## Key Guarantees
### 1. Read-Only Migration
The migration **never modifies project files**. We only:
- Read existing rule files from disk
- Inject file paths into the config's `instructions` array
- Never write to the project or modify any files
### 2. Combines with Existing Config (Never Overwrites)
If you have existing opencode config with `instructions`, the Kilocode rules are **combined**, not replaced:
```typescript
// Example: User has opencode.json with:
{ "instructions": ["AGENTS.md", "custom-rules.md"] }
// Kilocode rules add:
{ "instructions": [".kilocoderules", ".kilocode/rules/coding.md"] }
// Result (combined, deduplicated):
{ "instructions": ["AGENTS.md", "custom-rules.md", ".kilocoderules", ".kilocode/rules/coding.md"] }
```
### 3. Restart to Pick Up Changes
If you change your Kilocode configuration (e.g., edit `.kilocoderules`), simply restart kilo-cli to pick up the new config. No manual migration or conversion needed.
## Source Locations
The migrator reads rules from these locations:
### Project Rules
| Location | Description |
|----------|-------------|
| `.kilocoderules` | Legacy single-file rules in project root |
| `.kilocode/rules/*.md` | Directory-based rules (multiple markdown files) |
| `.kilocoderules-{mode}` | Mode-specific legacy rules (e.g., `.kilocoderules-code`) |
| `.kilocode/rules-{mode}/*.md` | Mode-specific rule directories |
### Global Rules
| Location | Description |
|----------|-------------|
| `~/.kilocode/rules/*.md` | Global rules directory |
## File Mapping
| Kilocode Location | Opencode Equivalent |
|-------------------|---------------------|
| `.kilocoderules` | `instructions: [".kilocoderules"]` |
| `.kilocoderules-{mode}` | `instructions: [".kilocoderules-{mode}"]` |
| `.kilocode/rules/*.md` | `instructions: [".kilocode/rules/file.md", ...]` |
| `.kilocode/rules-{mode}/*.md` | `instructions: [".kilocode/rules-{mode}/file.md", ...]` |
| `~/.kilocode/rules/*.md` | `instructions: ["~/.kilocode/rules/file.md", ...]` |
## AGENTS.md Compatibility
`AGENTS.md` is loaded **natively** by Opencode - no migration needed. Opencode automatically loads:
- `AGENTS.md` in project root
- `CLAUDE.md` in project root
- `~/.opencode/AGENTS.md` (global)
## Not Migrated
The following are **not** migrated:
- `.roorules` - Roo-specific rules
- `.clinerules` - Cline-specific rules
Only Kilocode-specific files (`.kilocoderules`, `.kilocode/rules/`) are migrated.
## Mode-Specific Rules
Mode-specific rules (e.g., `.kilocoderules-code`, `.kilocode/rules-architect/`) are included by default. All mode-specific rules are loaded regardless of the current mode.
## Warnings
The migrator generates warnings for:
- **Legacy files**: When `.kilocoderules` is found, a warning suggests migrating to `.kilocode/rules/` directory structure
## Example
### Before (Kilocode)
```
project/
├── .kilocoderules # Legacy rules
├── .kilocoderules-code # Code-mode specific
└── .kilocode/
└── rules/
├── coding.md # Coding standards
└── testing.md # Testing guidelines
```
### After (Opencode Config)
```json
{
"instructions": [
"/path/to/project/.kilocode/rules/coding.md",
"/path/to/project/.kilocode/rules/testing.md",
"/path/to/project/.kilocoderules",
"/path/to/project/.kilocoderules-code"
]
}
```
## Troubleshooting
### Rules not appearing
1. Check the file exists at the expected location
2. Ensure markdown files have `.md` extension
3. Restart kilo-cli to pick up changes
### Duplicate rules
The `mergeConfigConcatArrays` function automatically deduplicates the `instructions` array using `Array.from(new Set([...]))`.
## Related Files
- [`rules-migrator.ts`](../rules-migrator.ts) - Core migration logic
- [`config-injector.ts`](../config-injector.ts) - Config building and injection
- [`modes-migration.md`](./modes-migration.md) - Modes migration documentation
+2
View File
@@ -1,3 +1,5 @@
export { ModesMigrator } from "./modes-migrator"
export { RulesMigrator } from "./rules-migrator"
export { WorkflowsMigrator } from "./workflows-migrator"
export { McpMigrator } from "./mcp-migrator"
export { KilocodeConfigInjector } from "./config-injector"
@@ -0,0 +1,136 @@
// kilocode_change - new file
import * as fs from "fs/promises"
import * as path from "path"
import os from "os"
export namespace RulesMigrator {
// Only support .kilocoderules (no migration for .roorules or .clinerules)
const LEGACY_RULE_FILE = ".kilocoderules"
// Directory-based rules
const KILOCODE_RULES_DIR = ".kilocode/rules"
const GLOBAL_RULES_DIR = path.join(os.homedir(), ".kilocode", "rules")
// Known modes for mode-specific rule discovery
const KNOWN_MODES = ["code", "architect", "ask", "debug", "orchestrator"]
export interface RuleFile {
path: string
source: "global" | "project" | "legacy"
mode?: string // e.g., "code", "architect" - undefined means applies to all modes
}
export interface MigrationResult {
instructions: string[]
warnings: string[]
}
async function exists(filepath: string): Promise<boolean> {
return Bun.file(filepath).exists()
}
async function isDirectory(filepath: string): Promise<boolean> {
try {
const stat = await fs.stat(filepath)
return stat.isDirectory()
} catch {
return false
}
}
async function findMarkdownFiles(dir: string): Promise<string[]> {
try {
const entries = await fs.readdir(dir, { withFileTypes: true })
return entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => path.join(dir, e.name))
} catch {
return []
}
}
export async function discoverRules(projectDir: string): Promise<RuleFile[]> {
const rules: RuleFile[] = []
// 1. Global rules directory (~/.kilocode/rules/*.md)
if (await isDirectory(GLOBAL_RULES_DIR)) {
const files = await findMarkdownFiles(GLOBAL_RULES_DIR)
for (const file of files) {
rules.push({ path: file, source: "global" })
}
}
// 2. Project .kilocode/rules/ directory
const projectRulesDir = path.join(projectDir, KILOCODE_RULES_DIR)
if (await isDirectory(projectRulesDir)) {
const files = await findMarkdownFiles(projectRulesDir)
for (const file of files) {
rules.push({ path: file, source: "project" })
}
}
// 3. Legacy .kilocoderules file (only kilocode, not roo/cline)
const legacyFile = path.join(projectDir, LEGACY_RULE_FILE)
if (await exists(legacyFile)) {
rules.push({ path: legacyFile, source: "legacy" })
}
// 4. Mode-specific rules
for (const mode of KNOWN_MODES) {
// Mode-specific directory (.kilocode/rules-{mode}/*.md)
const modeDir = path.join(projectDir, `.kilocode/rules-${mode}`)
if (await isDirectory(modeDir)) {
const files = await findMarkdownFiles(modeDir)
for (const file of files) {
rules.push({ path: file, source: "project", mode })
}
}
// Legacy mode-specific file (.kilocoderules-{mode})
const legacyModeFile = path.join(projectDir, `.kilocoderules-${mode}`)
if (await exists(legacyModeFile)) {
rules.push({ path: legacyModeFile, source: "legacy", mode })
}
}
return rules
}
export async function migrate(options: {
projectDir: string
includeGlobal?: boolean
includeModeSpecific?: boolean
}): Promise<MigrationResult> {
const warnings: string[] = []
const instructions: string[] = []
const includeGlobal = options.includeGlobal ?? true
const includeModeSpecific = options.includeModeSpecific ?? true
const rules = await discoverRules(options.projectDir)
for (const rule of rules) {
// Skip global if not requested
if (rule.source === "global" && !includeGlobal) {
continue
}
// Skip mode-specific if not requested
if (rule.mode && !includeModeSpecific) {
warnings.push(`Mode-specific rule '${path.basename(rule.path)}' skipped (mode: ${rule.mode})`)
continue
}
// Add to instructions array
instructions.push(rule.path)
// Warn about legacy files
if (rule.source === "legacy") {
warnings.push(
`Legacy rule file '${path.basename(rule.path)}' found. ` +
`Consider migrating to .kilocode/rules/ directory.`,
)
}
}
return { instructions, warnings }
}
}
@@ -0,0 +1,143 @@
// kilocode_change - new file
import * as fs from "fs/promises"
import * as path from "path"
import os from "os"
import type { Config } from "../config/config"
export namespace WorkflowsMigrator {
const KILOCODE_WORKFLOWS_DIR = ".kilocode/workflows"
const GLOBAL_WORKFLOWS_DIR = path.join(os.homedir(), ".kilocode", "workflows")
// Get platform-specific VSCode global storage path (same as modes-migrator)
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 interface KilocodeWorkflow {
name: string
path: string
content: string
source: "global" | "project"
}
export interface MigrationResult {
commands: Record<string, Config.Command>
warnings: string[]
}
async function directoryExists(dirPath: string): Promise<boolean> {
const stat = await fs.stat(dirPath).catch(() => null)
return stat?.isDirectory() ?? false
}
async function findWorkflowFiles(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => [])
return entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => path.join(dir, e.name))
}
export function extractNameFromFilename(filename: string): string {
return path.basename(filename, ".md")
}
export function extractDescription(content: string): string | undefined {
const lines = content.split("\n")
let foundTitle = false
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith("#")) {
foundTitle = true
continue
}
if (foundTitle && trimmed.length > 0) {
return trimmed.slice(0, 200)
}
}
return undefined
}
async function loadWorkflowsFromDir(dir: string, source: "global" | "project"): Promise<KilocodeWorkflow[]> {
if (!(await directoryExists(dir))) return []
const files = await findWorkflowFiles(dir)
const workflows: KilocodeWorkflow[] = []
for (const file of files) {
const content = await fs.readFile(file, "utf-8")
workflows.push({
name: extractNameFromFilename(file),
path: file,
content: content.trim(),
source,
})
}
return workflows
}
export async function discoverWorkflows(projectDir: string, skipGlobalPaths?: boolean): Promise<KilocodeWorkflow[]> {
const workflows: KilocodeWorkflow[] = []
if (!skipGlobalPaths) {
// 1. VSCode extension global storage (primary location for global workflows)
const vscodeWorkflowsDir = path.join(getVSCodeGlobalStoragePath(), "workflows")
workflows.push(...(await loadWorkflowsFromDir(vscodeWorkflowsDir, "global")))
// 2. Home directory ~/.kilocode/workflows (fallback/alternative location)
workflows.push(...(await loadWorkflowsFromDir(GLOBAL_WORKFLOWS_DIR, "global")))
}
// 3. Project workflows (.kilocode/workflows/)
const projectWorkflowsDir = path.join(projectDir, KILOCODE_WORKFLOWS_DIR)
workflows.push(...(await loadWorkflowsFromDir(projectWorkflowsDir, "project")))
return workflows
}
export function convertToCommand(workflow: KilocodeWorkflow): Config.Command {
return {
template: workflow.content,
description: extractDescription(workflow.content) ?? `Workflow: ${workflow.name}`,
}
}
export async function migrate(options: {
projectDir: string
/** Skip reading from global paths. Used for testing. */
skipGlobalPaths?: boolean
}): Promise<MigrationResult> {
const warnings: string[] = []
const commands: Record<string, Config.Command> = {}
const workflows = await discoverWorkflows(options.projectDir, options.skipGlobalPaths)
// Deduplicate by name (project takes precedence over global)
const workflowsByName = new Map<string, KilocodeWorkflow>()
// Add global first
for (const workflow of workflows.filter((w) => w.source === "global")) {
workflowsByName.set(workflow.name, workflow)
}
// Project overwrites global
for (const workflow of workflows.filter((w) => w.source === "project")) {
if (workflowsByName.has(workflow.name)) {
warnings.push(`Project workflow '${workflow.name}' overrides global workflow`)
}
workflowsByName.set(workflow.name, workflow)
}
// Convert to commands
for (const [name, workflow] of workflowsByName) {
commands[name] = convertToCommand(workflow)
}
return { commands, warnings }
}
}