From ab4d1a68799d7966833c4d49628d46f0f93f3e94 Mon Sep 17 00:00:00 2001 From: Marius Wichtner Date: Mon, 26 Jan 2026 17:17:13 +0100 Subject: [PATCH 1/2] core: migrate Kilocode workflows to Opencode commands - Add WorkflowsMigrator to discover and convert Kilocode workflows - Load workflows from VSCode global storage, ~/.kilocode/workflows/, and .kilocode/workflows/ - Convert workflows to Opencode Command format with template and description - Load Kilocode configs first (lowest precedence) so Opencode configs always win - Update config-injector for VSCode extension env var injection - Add 16 unit tests for workflows-migrator - Add 2 integration tests for config-injector Workflows are discovered from: 1. VSCode extension storage (platform-specific) 2. ~/.kilocode/workflows/ (global) 3. .kilocode/workflows/ (project) Config loading order (lowest to highest precedence): 1. Kilocode legacy configs (modes + workflows) 2. Remote/well-known config 3. Global user config 4. Custom config path 5. Project config (opencode.json) 6. OPENCODE_CONFIG_CONTENT env var --- packages/opencode/src/config/config.ts | 64 ++++-- .../opencode/src/kilocode/config-injector.ts | 18 +- packages/opencode/src/kilocode/index.ts | 1 + .../src/kilocode/workflows-migrator.ts | 143 +++++++++++++ .../test/kilocode/config-injector.test.ts | 48 +++++ .../test/kilocode/workflows-migrator.test.ts | 197 ++++++++++++++++++ 6 files changed, 445 insertions(+), 26 deletions(-) create mode 100644 packages/opencode/src/kilocode/workflows-migrator.ts create mode 100644 packages/opencode/test/kilocode/workflows-migrator.test.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ba26ff4f2..ba805b1e9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -29,6 +29,7 @@ import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" import { ModesMigrator } from "../kilocode/modes-migrator" // kilocode_change +import { WorkflowsMigrator } from "../kilocode/workflows-migrator" // kilocode_change export namespace Config { const log = Log.create({ service: "config" }) @@ -48,9 +49,48 @@ 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 + // 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 }) + } + // 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 @@ -96,26 +136,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 || [] diff --git a/packages/opencode/src/kilocode/config-injector.ts b/packages/opencode/src/kilocode/config-injector.ts index fa8ee7990..8b089520c 100644 --- a/packages/opencode/src/kilocode/config-injector.ts +++ b/packages/opencode/src/kilocode/config-injector.ts @@ -1,5 +1,6 @@ import { Config } from "../config/config" import { ModesMigrator } from "./modes-migrator" +import { WorkflowsMigrator } from "./workflows-migrator" export namespace KilocodeConfigInjector { export interface InjectionResult { @@ -15,7 +16,10 @@ export namespace KilocodeConfigInjector { }): Promise { const warnings: string[] = [] - // Migrate custom modes only + // Build config object + const config: Partial = {} + + // Migrate custom modes const modesMigration = await ModesMigrator.migrate(options) // Log skipped default modes (for debugging) @@ -23,13 +27,19 @@ export namespace KilocodeConfigInjector { 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 } + // 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 + } + return { configJson: JSON.stringify(config), warnings, diff --git a/packages/opencode/src/kilocode/index.ts b/packages/opencode/src/kilocode/index.ts index 6995757e8..88a0ec245 100644 --- a/packages/opencode/src/kilocode/index.ts +++ b/packages/opencode/src/kilocode/index.ts @@ -1,2 +1,3 @@ export { ModesMigrator } from "./modes-migrator" +export { WorkflowsMigrator } from "./workflows-migrator" export { KilocodeConfigInjector } from "./config-injector" diff --git a/packages/opencode/src/kilocode/workflows-migrator.ts b/packages/opencode/src/kilocode/workflows-migrator.ts new file mode 100644 index 000000000..99993c38b --- /dev/null +++ b/packages/opencode/src/kilocode/workflows-migrator.ts @@ -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 + warnings: string[] + } + + async function directoryExists(dirPath: string): Promise { + const stat = await fs.stat(dirPath).catch(() => null) + return stat?.isDirectory() ?? false + } + + async function findWorkflowFiles(dir: string): Promise { + 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 { + 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 { + 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 { + const warnings: string[] = [] + const commands: Record = {} + + const workflows = await discoverWorkflows(options.projectDir, options.skipGlobalPaths) + + // Deduplicate by name (project takes precedence over global) + const workflowsByName = new Map() + + // 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 } + } +} diff --git a/packages/opencode/test/kilocode/config-injector.test.ts b/packages/opencode/test/kilocode/config-injector.test.ts index 45d98be06..db0725328 100644 --- a/packages/opencode/test/kilocode/config-injector.test.ts +++ b/packages/opencode/test/kilocode/config-injector.test.ts @@ -59,6 +59,54 @@ describe("KilocodeConfigInjector", () => { expect(result.warnings[0]).toContain("code") expect(result.warnings[0]).toContain("skipped") }) + + test("includes workflows as commands in config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const workflowsDir = path.join(dir, ".kilocode", "workflows") + await Bun.write( + path.join(workflowsDir, "code-review.md"), + "# Code Review\n\nPerform a code review.\n\n## Steps\n\n1. Review", + ) + }, + }) + + const result = await KilocodeConfigInjector.buildConfig({ projectDir: tmp.path, skipGlobalPaths: true }) + const config = JSON.parse(result.configJson) + + expect(config.command).toBeDefined() + expect(config.command["code-review"]).toBeDefined() + expect(config.command["code-review"].template).toContain("# Code Review") + expect(config.command["code-review"].description).toBe("Perform a code review.") + }) + + test("includes both modes and workflows in config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Add a custom mode + await Bun.write( + path.join(dir, ".kilocodemodes"), + `customModes: + - slug: translate + name: Translate + roleDefinition: You are a translator + groups: + - read`, + ) + // Add a workflow + const workflowsDir = path.join(dir, ".kilocode", "workflows") + await Bun.write(path.join(workflowsDir, "deploy.md"), "# Deploy\n\nDeploy the app.") + }, + }) + + 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.command).toBeDefined() + expect(config.command["deploy"]).toBeDefined() + }) }) describe("getEnvVars", () => { diff --git a/packages/opencode/test/kilocode/workflows-migrator.test.ts b/packages/opencode/test/kilocode/workflows-migrator.test.ts new file mode 100644 index 000000000..f301889fe --- /dev/null +++ b/packages/opencode/test/kilocode/workflows-migrator.test.ts @@ -0,0 +1,197 @@ +import { test, expect, describe } from "bun:test" +import { WorkflowsMigrator } from "../../src/kilocode/workflows-migrator" +import { tmpdir } from "../fixture/fixture" +import path from "path" + +describe("WorkflowsMigrator", () => { + describe("extractNameFromFilename", () => { + test("extracts name from simple filename", () => { + expect(WorkflowsMigrator.extractNameFromFilename("code-review.md")).toBe("code-review") + }) + + test("extracts name from path", () => { + expect(WorkflowsMigrator.extractNameFromFilename("/path/to/my-workflow.md")).toBe("my-workflow") + }) + + test("handles filename without extension", () => { + expect(WorkflowsMigrator.extractNameFromFilename("workflow")).toBe("workflow") + }) + }) + + describe("extractDescription", () => { + test("extracts description from first paragraph after title", () => { + const content = `# My Workflow + +This is the description of the workflow. + +## Steps + +1. Do something` + + expect(WorkflowsMigrator.extractDescription(content)).toBe("This is the description of the workflow.") + }) + + test("returns undefined when no description found", () => { + const content = `# My Workflow` + expect(WorkflowsMigrator.extractDescription(content)).toBeUndefined() + }) + + test("limits description to 200 characters", () => { + const longDescription = "A".repeat(300) + const content = `# Title + +${longDescription}` + + const result = WorkflowsMigrator.extractDescription(content) + expect(result?.length).toBe(200) + }) + + test("skips empty lines after title", () => { + const content = `# Title + + +Actual description here.` + + expect(WorkflowsMigrator.extractDescription(content)).toBe("Actual description here.") + }) + }) + + describe("discoverWorkflows", () => { + test("discovers project workflows", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const workflowsDir = path.join(dir, ".kilocode", "workflows") + await Bun.write(path.join(workflowsDir, "test-workflow.md"), "# Test\n\nDescription") + }, + }) + + const workflows = await WorkflowsMigrator.discoverWorkflows(tmp.path, true) + + expect(workflows).toHaveLength(1) + expect(workflows[0].name).toBe("test-workflow") + expect(workflows[0].source).toBe("project") + }) + + test("returns empty array when no workflows directory exists", async () => { + await using tmp = await tmpdir() + + const workflows = await WorkflowsMigrator.discoverWorkflows(tmp.path, true) + + expect(workflows).toHaveLength(0) + }) + + test("only discovers .md files", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const workflowsDir = path.join(dir, ".kilocode", "workflows") + await Bun.write(path.join(workflowsDir, "workflow.md"), "# Workflow") + await Bun.write(path.join(workflowsDir, "readme.txt"), "Not a workflow") + await Bun.write(path.join(workflowsDir, "config.json"), "{}") + }, + }) + + const workflows = await WorkflowsMigrator.discoverWorkflows(tmp.path, true) + + expect(workflows).toHaveLength(1) + expect(workflows[0].name).toBe("workflow") + }) + }) + + describe("convertToCommand", () => { + test("converts workflow to command format", () => { + const workflow: WorkflowsMigrator.KilocodeWorkflow = { + name: "code-review", + path: "/path/to/code-review.md", + content: "# Code Review\n\nReview the code changes.\n\n## Steps\n\n1. Check", + source: "project", + } + + const command = WorkflowsMigrator.convertToCommand(workflow) + + expect(command.template).toBe(workflow.content) + expect(command.description).toBe("Review the code changes.") + }) + + test("uses fallback description when none found", () => { + const workflow: WorkflowsMigrator.KilocodeWorkflow = { + name: "simple", + path: "/path/to/simple.md", + content: "# Simple", + source: "project", + } + + const command = WorkflowsMigrator.convertToCommand(workflow) + + expect(command.description).toBe("Workflow: simple") + }) + }) + + describe("migrate", () => { + test("migrates project workflows to commands", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const workflowsDir = path.join(dir, ".kilocode", "workflows") + await Bun.write( + path.join(workflowsDir, "code-review.md"), + "# Code Review\n\nPerform a code review.\n\n## Steps\n\n1. Review", + ) + }, + }) + + const result = await WorkflowsMigrator.migrate({ projectDir: tmp.path, skipGlobalPaths: true }) + + expect(Object.keys(result.commands)).toHaveLength(1) + expect(result.commands["code-review"]).toBeDefined() + expect(result.commands["code-review"].template).toContain("# Code Review") + expect(result.commands["code-review"].description).toBe("Perform a code review.") + }) + + test("returns empty commands when no workflows exist", async () => { + await using tmp = await tmpdir() + + const result = await WorkflowsMigrator.migrate({ projectDir: tmp.path, skipGlobalPaths: true }) + + expect(Object.keys(result.commands)).toHaveLength(0) + expect(result.warnings).toHaveLength(0) + }) + + test("migrates multiple workflows", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const workflowsDir = path.join(dir, ".kilocode", "workflows") + await Bun.write(path.join(workflowsDir, "review.md"), "# Review\n\nReview code") + await Bun.write(path.join(workflowsDir, "deploy.md"), "# Deploy\n\nDeploy app") + }, + }) + + const result = await WorkflowsMigrator.migrate({ projectDir: tmp.path, skipGlobalPaths: true }) + + expect(Object.keys(result.commands)).toHaveLength(2) + expect(result.commands["review"]).toBeDefined() + expect(result.commands["deploy"]).toBeDefined() + }) + + test("project workflows override global workflows with same name", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Create a "global" directory to simulate global workflows + const globalDir = path.join(dir, "global-workflows") + await Bun.write(path.join(globalDir, "shared.md"), "# Shared\n\nGlobal version") + + // Create project workflows + const projectDir = path.join(dir, ".kilocode", "workflows") + await Bun.write(path.join(projectDir, "shared.md"), "# Shared\n\nProject version") + + return globalDir + }, + }) + + // Note: We can't easily test global workflow override without mocking the home directory + // This test verifies the deduplication logic works for project workflows + const result = await WorkflowsMigrator.migrate({ projectDir: tmp.path, skipGlobalPaths: true }) + + expect(Object.keys(result.commands)).toHaveLength(1) + expect(result.commands["shared"].template).toContain("Project version") + }) + }) +}) From 7471fcfe7398da8eb2d60ea60878b28d0317fe8a Mon Sep 17 00:00:00 2001 From: Marius Wichtner Date: Mon, 26 Jan 2026 17:50:58 +0100 Subject: [PATCH 2/2] feat(kilocode): add rules migration (Phase 2) Migrate Kilocode rules from .kilocoderules and .kilocode/rules/ to Opencode's instructions config array. Features: - Discover rules from .kilocoderules, .kilocode/rules/*.md, and mode-specific variants - Support global rules from ~/.kilocode/rules/ - Read-only migration (never modifies project files) - Combines with existing opencode config (never overwrites) - Kilocode-only (no .roorules or .clinerules migration) Files: - rules-migrator.ts: Core migration logic - config.ts: Integration point for direct CLI usage - config-injector.ts: Integration for VSCode extension path - rules-migrator.test.ts: 13 unit tests - config-injector.test.ts: 4 new integration tests - rules-migration.md: Documentation --- packages/opencode/src/config/config.ts | 21 ++ .../opencode/src/kilocode/config-injector.ts | 19 ++ .../src/kilocode/docs/rules-migration.md | 130 ++++++++++++ packages/opencode/src/kilocode/index.ts | 1 + .../opencode/src/kilocode/rules-migrator.ts | 136 ++++++++++++ .../test/kilocode/config-injector.test.ts | 86 ++++++++ .../test/kilocode/rules-migrator.test.ts | 195 ++++++++++++++++++ 7 files changed, 588 insertions(+) create mode 100644 packages/opencode/src/kilocode/docs/rules-migration.md create mode 100644 packages/opencode/src/kilocode/rules-migrator.ts create mode 100644 packages/opencode/test/kilocode/rules-migrator.test.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ba26ff4f2..0beb3a629 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -29,6 +29,7 @@ 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 export namespace Config { const log = Log.create({ service: "config" }) @@ -116,6 +117,26 @@ export namespace Config { } // kilocode_change end + // kilocode_change start - Load Kilocode rules + 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 }) + } + // 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 index fa8ee7990..eab4dace6 100644 --- a/packages/opencode/src/kilocode/config-injector.ts +++ b/packages/opencode/src/kilocode/config-injector.ts @@ -1,5 +1,6 @@ import { Config } from "../config/config" import { ModesMigrator } from "./modes-migrator" +import { RulesMigrator } from "./rules-migrator" // kilocode_change export namespace KilocodeConfigInjector { export interface InjectionResult { @@ -12,6 +13,8 @@ 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 { const warnings: string[] = [] @@ -30,6 +33,22 @@ export namespace KilocodeConfigInjector { config.agent = modesMigration.agents } + // 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, diff --git a/packages/opencode/src/kilocode/docs/rules-migration.md b/packages/opencode/src/kilocode/docs/rules-migration.md new file mode 100644 index 000000000..a0076a9ae --- /dev/null +++ b/packages/opencode/src/kilocode/docs/rules-migration.md @@ -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 diff --git a/packages/opencode/src/kilocode/index.ts b/packages/opencode/src/kilocode/index.ts index 6995757e8..e0c8b562a 100644 --- a/packages/opencode/src/kilocode/index.ts +++ b/packages/opencode/src/kilocode/index.ts @@ -1,2 +1,3 @@ export { ModesMigrator } from "./modes-migrator" +export { RulesMigrator } from "./rules-migrator" // kilocode_change export { KilocodeConfigInjector } from "./config-injector" diff --git a/packages/opencode/src/kilocode/rules-migrator.ts b/packages/opencode/src/kilocode/rules-migrator.ts new file mode 100644 index 000000000..ed5543e04 --- /dev/null +++ b/packages/opencode/src/kilocode/rules-migrator.ts @@ -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 { + return Bun.file(filepath).exists() + } + + async function isDirectory(filepath: string): Promise { + try { + const stat = await fs.stat(filepath) + return stat.isDirectory() + } catch { + return false + } + } + + async function findMarkdownFiles(dir: string): Promise { + 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 { + 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 { + 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 } + } +} diff --git a/packages/opencode/test/kilocode/config-injector.test.ts b/packages/opencode/test/kilocode/config-injector.test.ts index 45d98be06..510f098ff 100644 --- a/packages/opencode/test/kilocode/config-injector.test.ts +++ b/packages/opencode/test/kilocode/config-injector.test.ts @@ -2,6 +2,7 @@ import { test, expect, describe } from "bun:test" import { KilocodeConfigInjector } from "../../src/kilocode/config-injector" import { tmpdir } from "../fixture/fixture" import path from "path" +import fs from "fs/promises" describe("KilocodeConfigInjector", () => { describe("buildConfig", () => { @@ -59,6 +60,91 @@ describe("KilocodeConfigInjector", () => { expect(result.warnings[0]).toContain("code") expect(result.warnings[0]).toContain("skipped") }) + + // kilocode_change start - Rules migration tests + test("includes rules in config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules", "main.md"), "# Rules") + }, + }) + + const result = await KilocodeConfigInjector.buildConfig({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + const config = JSON.parse(result.configJson) + + expect(config.instructions).toBeDefined() + expect(config.instructions).toHaveLength(1) + expect(config.instructions[0]).toContain("main.md") + }) + + test("skips rules when includeRules is false", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules", "main.md"), "# Rules") + }, + }) + + const result = await KilocodeConfigInjector.buildConfig({ + projectDir: tmp.path, + skipGlobalPaths: true, + includeRules: false, + }) + const config = JSON.parse(result.configJson) + + expect(config.instructions).toBeUndefined() + }) + + test("combines modes and rules in config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Add custom mode + await Bun.write( + path.join(dir, ".kilocodemodes"), + `customModes: + - slug: translate + name: Translate + roleDefinition: You are a translator + groups: + - read`, + ) + // Add rules + await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules", "main.md"), "# Rules") + }, + }) + + 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.instructions).toBeDefined() + expect(config.instructions).toHaveLength(1) + }) + + test("adds warnings for legacy rule files", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocoderules"), "# Legacy rules") + }, + }) + + const result = await KilocodeConfigInjector.buildConfig({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + + expect(result.warnings.some((w) => w.includes("Legacy"))).toBe(true) + }) + // kilocode_change end }) describe("getEnvVars", () => { diff --git a/packages/opencode/test/kilocode/rules-migrator.test.ts b/packages/opencode/test/kilocode/rules-migrator.test.ts new file mode 100644 index 000000000..31c929a38 --- /dev/null +++ b/packages/opencode/test/kilocode/rules-migrator.test.ts @@ -0,0 +1,195 @@ +import { test, expect, describe } from "bun:test" +import { RulesMigrator } from "../../src/kilocode/rules-migrator" +import { tmpdir } from "../fixture/fixture" +import path from "path" +import fs from "fs/promises" + +describe("RulesMigrator", () => { + describe("discoverRules", () => { + test("discovers legacy .kilocoderules file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocoderules"), "# Project rules") + }, + }) + + const rules = await RulesMigrator.discoverRules(tmp.path) + + expect(rules).toHaveLength(1) + expect(rules[0].source).toBe("legacy") + expect(rules[0].path).toContain(".kilocoderules") + expect(rules[0].mode).toBeUndefined() + }) + + test("discovers .kilocode/rules/ directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules", "coding.md"), "# Coding rules") + await Bun.write(path.join(dir, ".kilocode", "rules", "testing.md"), "# Testing rules") + }, + }) + + const rules = await RulesMigrator.discoverRules(tmp.path) + + expect(rules).toHaveLength(2) + expect(rules.every((r) => r.source === "project")).toBe(true) + expect(rules.every((r) => r.mode === undefined)).toBe(true) + }) + + test("discovers mode-specific directory rules", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".kilocode", "rules-code"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules-code", "style.md"), "# Code style") + }, + }) + + const rules = await RulesMigrator.discoverRules(tmp.path) + + expect(rules).toHaveLength(1) + expect(rules[0].source).toBe("project") + expect(rules[0].mode).toBe("code") + }) + + test("discovers mode-specific legacy file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocoderules-architect"), "# Architect rules") + }, + }) + + const rules = await RulesMigrator.discoverRules(tmp.path) + + expect(rules).toHaveLength(1) + expect(rules[0].source).toBe("legacy") + expect(rules[0].mode).toBe("architect") + }) + + test("ignores non-markdown files in rules directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules", "rules.md"), "# Rules") + await Bun.write(path.join(dir, ".kilocode", "rules", "notes.txt"), "Notes") + await Bun.write(path.join(dir, ".kilocode", "rules", "config.json"), "{}") + }, + }) + + const rules = await RulesMigrator.discoverRules(tmp.path) + + expect(rules).toHaveLength(1) + expect(rules[0].path).toContain("rules.md") + }) + + test("returns empty array for project without rules", async () => { + await using tmp = await tmpdir() + + const rules = await RulesMigrator.discoverRules(tmp.path) + + expect(rules).toHaveLength(0) + }) + + test("discovers multiple rule sources together", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Legacy file + await Bun.write(path.join(dir, ".kilocoderules"), "# Legacy rules") + // Directory rules + await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules", "main.md"), "# Main rules") + // Mode-specific + await Bun.write(path.join(dir, ".kilocoderules-code"), "# Code rules") + }, + }) + + const rules = await RulesMigrator.discoverRules(tmp.path) + + expect(rules).toHaveLength(3) + expect(rules.some((r) => r.source === "legacy" && !r.mode)).toBe(true) + expect(rules.some((r) => r.source === "project")).toBe(true) + expect(rules.some((r) => r.mode === "code")).toBe(true) + }) + }) + + describe("migrate", () => { + test("returns instructions array with discovered rules", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules", "main.md"), "# Main rules") + }, + }) + + const result = await RulesMigrator.migrate({ projectDir: tmp.path }) + + expect(result.instructions).toHaveLength(1) + expect(result.instructions[0]).toContain("main.md") + }) + + test("warns about legacy files", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocoderules"), "# Legacy rules") + }, + }) + + const result = await RulesMigrator.migrate({ projectDir: tmp.path }) + + expect(result.warnings.some((w) => w.includes("Legacy"))).toBe(true) + expect(result.warnings.some((w) => w.includes(".kilocode/rules/"))).toBe(true) + }) + + test("skips mode-specific rules when includeModeSpecific is false", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocoderules-code"), "# Code rules") + }, + }) + + const result = await RulesMigrator.migrate({ + projectDir: tmp.path, + includeModeSpecific: false, + }) + + expect(result.instructions).toHaveLength(0) + expect(result.warnings.some((w) => w.includes("skipped"))).toBe(true) + }) + + test("includes mode-specific rules by default", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocoderules-code"), "# Code rules") + }, + }) + + const result = await RulesMigrator.migrate({ projectDir: tmp.path }) + + expect(result.instructions).toHaveLength(1) + }) + + test("returns empty result for project without rules", async () => { + await using tmp = await tmpdir() + + const result = await RulesMigrator.migrate({ projectDir: tmp.path }) + + expect(result.instructions).toHaveLength(0) + expect(result.warnings).toHaveLength(0) + }) + + test("combines all rule sources", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocoderules"), "# Legacy") + await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules", "main.md"), "# Main") + await Bun.write(path.join(dir, ".kilocoderules-architect"), "# Architect") + }, + }) + + const result = await RulesMigrator.migrate({ projectDir: tmp.path }) + + expect(result.instructions).toHaveLength(3) + }) + }) +})