diff --git a/fix-hooks-processor.py b/fix-hooks-processor.py new file mode 100644 index 000000000..56cc0fbb1 --- /dev/null +++ b/fix-hooks-processor.py @@ -0,0 +1,10 @@ +with open('src/features/hooks/hooks-processor.ts', 'r') as f: + content = f.read() + +bad_string = " supportedEvents: KILO_HOOK_EVENTS,\n OPENCODE_HOOK_EVENTS," +good_string = " supportedEvents: OPENCODE_HOOK_EVENTS," + +content = content.replace(bad_string, good_string) + +with open('src/features/hooks/hooks-processor.ts', 'w') as f: + f.write(content) diff --git a/patch-hooks-processor.py b/patch-hooks-processor.py new file mode 100644 index 000000000..1b9354fb3 --- /dev/null +++ b/patch-hooks-processor.py @@ -0,0 +1,50 @@ +import re + +with open('src/features/hooks/hooks-processor.ts', 'r') as f: + content = f.read() + +# Fix the Kilo block we added earlier +wrong_kilo_block = """ [ + "kilo", + { + class: KiloHooks, + meta: { + supportsProject: true, + supportsGlobal: true, + supportsImport: false, + }, + }, + ],""" + +right_kilo_block = """ [ + "kilo", + { + class: KiloHooks, + meta: { + supportsProject: true, + supportsGlobal: true, + supportsImport: false, + }, + supportedEvents: KILO_HOOK_EVENTS, + supportedHookTypes: ["command"], + supportsMatcher: true, + }, + ],""" + +if wrong_kilo_block in content: + content = content.replace(wrong_kilo_block, right_kilo_block) + +# Add "kilo" to hooksProcessorToolTargetTuple +tuple_str = 'const hooksProcessorToolTargetTuple = [' +tuple_kilo = 'const hooksProcessorToolTargetTuple = [\n "kilo",' +if '"kilo"' not in content.split(tuple_str)[1].split(']')[0]: + content = content.replace(tuple_str, tuple_kilo) + +# Add import KILO_HOOK_EVENTS +import_opencode_events = "OPENCODE_HOOK_EVENTS," +import_kilo_events = "KILO_HOOK_EVENTS,\n OPENCODE_HOOK_EVENTS," +if "KILO_HOOK_EVENTS" not in content: + content = content.replace(import_opencode_events, import_kilo_events) + +with open('src/features/hooks/hooks-processor.ts', 'w') as f: + f.write(content) diff --git a/patch-hooks-types.py b/patch-hooks-types.py new file mode 100644 index 000000000..6f481e663 --- /dev/null +++ b/patch-hooks-types.py @@ -0,0 +1,66 @@ +import re + +with open('src/types/hooks.ts', 'r') as f: + content = f.read() + +opencode_events = """/** Hook events supported by OpenCode. */ +export const OPENCODE_HOOK_EVENTS: readonly HookEvent[] = [ + "sessionStart", + "preToolUse", + "postToolUse", + "stop", + "afterFileEdit", + "afterShellExecution", + "permissionRequest", +];""" + +kilo_events = """/** Hook events supported by Kilo. */ +export const KILO_HOOK_EVENTS: readonly HookEvent[] = [ + "sessionStart", + "preToolUse", + "postToolUse", + "stop", + "afterFileEdit", + "afterShellExecution", + "permissionRequest", +];""" + +if kilo_events not in content: + content = content.replace(opencode_events, kilo_events + "\n\n" + opencode_events) + +with open('src/types/hooks.ts', 'w') as f: + f.write(content) +with open('src/types/hooks.ts', 'r') as f: + content = f.read() + +opencode_names = """/** + * Map canonical camelCase event names to OpenCode dot-notation. + */ +export const CANONICAL_TO_OPENCODE_EVENT_NAMES: Record = { + sessionStart: "session.created", + preToolUse: "tool.execute.before", + postToolUse: "tool.execute.after", + stop: "session.idle", + afterFileEdit: "file.edited", + afterShellExecution: "command.executed", + permissionRequest: "permission.asked", +};""" + +kilo_names = """/** + * Map canonical camelCase event names to Kilo dot-notation. + */ +export const CANONICAL_TO_KILO_EVENT_NAMES: Record = { + sessionStart: "session.created", + preToolUse: "tool.execute.before", + postToolUse: "tool.execute.after", + stop: "session.idle", + afterFileEdit: "file.edited", + afterShellExecution: "command.executed", + permissionRequest: "permission.asked", +};""" + +if kilo_names not in content: + content = content.replace(opencode_names, kilo_names + "\n\n" + opencode_names) + +with open('src/types/hooks.ts', 'w') as f: + f.write(content) diff --git a/patch-rulesync-skill.py b/patch-rulesync-skill.py new file mode 100644 index 000000000..fc0936603 --- /dev/null +++ b/patch-rulesync-skill.py @@ -0,0 +1,29 @@ +import re + +with open("src/features/skills/rulesync-skill.ts", "r") as f: + content = f.read() + +# Add kilo to RulesyncSkillFrontmatterSchemaInternal +schema_replacement = """ opencode: z.optional( + z.looseObject({ + "allowed-tools": z.optional(z.array(z.string())), + }), + ), + kilo: z.optional( + z.looseObject({ + "allowed-tools": z.optional(z.array(z.string())), + }), + ),""" +content = content.replace(' opencode: z.optional(\n z.looseObject({\n "allowed-tools": z.optional(z.array(z.string())),\n }),\n ),', schema_replacement) + +# Add kilo to RulesyncSkillFrontmatterInput +input_replacement = """ opencode?: { + "allowed-tools"?: string[]; + }; + kilo?: { + "allowed-tools"?: string[]; + };""" +content = content.replace(' opencode?: {\n "allowed-tools"?: string[];\n };', input_replacement) + +with open("src/features/skills/rulesync-skill.ts", "w") as f: + f.write(content) diff --git a/patch-subagents-processor-tuple.py b/patch-subagents-processor-tuple.py new file mode 100644 index 000000000..2e3298b5b --- /dev/null +++ b/patch-subagents-processor-tuple.py @@ -0,0 +1,12 @@ +import re + +with open('src/features/subagents/subagents-processor.ts', 'r') as f: + content = f.read() + +tuple_str = 'const subagentsProcessorToolTargetTuple = [' +tuple_kilo = 'const subagentsProcessorToolTargetTuple = [\n "kilo",' +if '"kilo"' not in content.split(tuple_str)[1].split(']')[0]: + content = content.replace(tuple_str, tuple_kilo) + +with open('src/features/subagents/subagents-processor.ts', 'w') as f: + f.write(content) diff --git a/patch-subagents-processor.py b/patch-subagents-processor.py new file mode 100644 index 000000000..6e4d05efb --- /dev/null +++ b/patch-subagents-processor.py @@ -0,0 +1,32 @@ +import re + +with open('src/features/subagents/subagents-processor.ts', 'r') as f: + content = f.read() + +opencode_import = 'import { OpenCodeSubagent } from "./opencode-subagent.js";' +kilo_import = 'import { KiloSubagent } from "./kilo-subagent.js";' + +if kilo_import not in content: + content = content.replace(opencode_import, kilo_import + "\n" + opencode_import) + +opencode_block = """ [ + "opencode", + { + class: OpenCodeSubagent, + meta: { supportsSimulated: false, supportsGlobal: true, filePattern: "*.md" }, + }, + ],""" + +kilo_block = """ [ + "kilo", + { + class: KiloSubagent, + meta: { supportsSimulated: false, supportsGlobal: true, filePattern: "*.md" }, + }, + ],""" + +if kilo_block not in content: + content = content.replace(opencode_block, kilo_block + "\n" + opencode_block) + +with open('src/features/subagents/subagents-processor.ts', 'w') as f: + f.write(content) diff --git a/src/features/commands/kilo-command.test.ts b/src/features/commands/kilo-command.test.ts index 744df0c78..937d57f06 100644 --- a/src/features/commands/kilo-command.test.ts +++ b/src/features/commands/kilo-command.test.ts @@ -4,25 +4,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RULESYNC_COMMANDS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; import { setupTestDirectory } from "../../test-utils/test-directories.js"; -import { writeFileContent } from "../../utils/file.js"; -import { KiloCommand } from "./kilo-command.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { stringifyFrontmatter } from "../../utils/frontmatter.js"; +import { KiloCommand, KiloCommandFrontmatterSchema } from "./kilo-command.js"; import { RulesyncCommand } from "./rulesync-command.js"; describe("KiloCommand", () => { let testDir: string; let cleanup: () => Promise; - const validContent = "# Sample workflow\n\nFollow these steps."; - - const markdownWithFrontmatter = `--- -title: Example ---- - -# Workflow -Step 1`; - beforeEach(async () => { - ({ testDir, cleanup } = await setupTestDirectory()); + const result = await setupTestDirectory(); + testDir = result.testDir; + cleanup = result.cleanup; vi.spyOn(process, "cwd").mockReturnValue(testDir); }); @@ -31,189 +25,125 @@ Step 1`; vi.restoreAllMocks(); }); - describe("getSettablePaths", () => { - it("should return workflow path for project mode", () => { - const paths = KiloCommand.getSettablePaths(); - - expect(paths).toEqual({ relativeDirPath: join(".kilocode", "workflows") }); - }); - - it("should use the same path in global mode", () => { - const paths = KiloCommand.getSettablePaths({ global: true }); - - expect(paths).toEqual({ relativeDirPath: join(".kilocode", "workflows") }); - }); - }); - describe("constructor", () => { - it("should create instance with valid content", () => { + it("should create a command with optional Kilo fields", () => { const command = new KiloCommand({ baseDir: testDir, - relativeDirPath: ".kilocode/workflows", + relativeDirPath: join(".kilo", "commands"), relativeFilePath: "test.md", - fileContent: validContent, - validate: true, + frontmatter: { + description: "Run tests", + agent: "build", + subtask: true, + model: "anthropic/claude-3-5-sonnet-20241022", + }, + body: "Run the full suite", }); - expect(command).toBeInstanceOf(KiloCommand); - expect(command.getFileContent()).toBe(validContent); - }); - - it("should skip validation when validate is false", () => { - const command = new KiloCommand({ - baseDir: testDir, - relativeDirPath: ".kilocode/workflows", - relativeFilePath: "test.md", - fileContent: validContent, - validate: false, + expect(command.getBody()).toBe("Run the full suite"); + expect(command.getFrontmatter()).toEqual({ + description: "Run tests", + agent: "build", + subtask: true, + model: "anthropic/claude-3-5-sonnet-20241022", }); - - expect(command).toBeInstanceOf(KiloCommand); - expect(command.getFileContent()).toBe(validContent); }); - }); - describe("getBody", () => { - it("should return the command body", () => { - const command = new KiloCommand({ - baseDir: testDir, - relativeDirPath: ".kilocode/workflows", - relativeFilePath: "test.md", - fileContent: validContent, - }); - - expect(command.getBody()).toBe(validContent); + it("should validate frontmatter when enabled", () => { + expect(() => { + new KiloCommand({ + baseDir: testDir, + relativeDirPath: join(".kilo", "commands"), + relativeFilePath: "invalid.md", + frontmatter: { description: 123 as unknown as string }, + body: "content", + validate: true, + }); + }).toThrow(); }); }); - describe("toRulesyncCommand", () => { - it("should convert to RulesyncCommand with default frontmatter", () => { - const kiloCommand = new KiloCommand({ - baseDir: testDir, - relativeDirPath: ".kilocode/workflows", - relativeFilePath: "test.md", - fileContent: validContent, - validate: true, + describe("getSettablePaths", () => { + it("should return project and global paths", () => { + expect(KiloCommand.getSettablePaths()).toEqual({ + relativeDirPath: join(".kilo", "commands"), + }); + expect(KiloCommand.getSettablePaths({ global: true })).toEqual({ + relativeDirPath: join(".config", "kilo", "commands"), }); - - const rulesyncCommand = kiloCommand.toRulesyncCommand(); - - expect(rulesyncCommand).toBeInstanceOf(RulesyncCommand); - expect(rulesyncCommand.getFrontmatter()).toEqual({ targets: ["*"] }); - expect(rulesyncCommand.getBody()).toBe(validContent); - expect(rulesyncCommand.getRelativeDirPath()).toBe(RULESYNC_COMMANDS_RELATIVE_DIR_PATH); }); }); describe("fromRulesyncCommand", () => { - it("should create KiloCommand from RulesyncCommand", () => { + it("should merge kilo frontmatter fields and respect global paths", () => { const rulesyncCommand = new RulesyncCommand({ baseDir: testDir, relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "workflow.md", - frontmatter: { targets: ["kilo"], description: "" }, - body: validContent, - fileContent: validContent, - validate: true, + relativeFilePath: "custom.md", + frontmatter: { + targets: ["kilo"], + description: "Analyze coverage", + kilo: { subtask: true }, + }, + body: "Analyze coverage details", + fileContent: stringifyFrontmatter("Analyze coverage details", { + targets: ["kilo"], + description: "Analyze coverage", + kilo: { subtask: true }, + }), }); - const kiloCommand = KiloCommand.fromRulesyncCommand({ + const command = KiloCommand.fromRulesyncCommand({ baseDir: testDir, rulesyncCommand, + global: true, }); - expect(kiloCommand).toBeInstanceOf(KiloCommand); - expect(kiloCommand.getRelativeDirPath()).toBe(join(".kilocode", "workflows")); - expect(kiloCommand.getFileContent()).toBe(validContent); + expect(command.getFrontmatter()).toEqual({ description: "Analyze coverage", subtask: true }); + expect(command.getRelativeDirPath()).toBe(join(".config", "kilo", "commands")); }); }); - describe("validate", () => { - it("should always succeed", () => { + describe("toRulesyncCommand", () => { + it("should convert to RulesyncCommand with kilo metadata", () => { const command = new KiloCommand({ baseDir: testDir, - relativeDirPath: ".kilocode/workflows", - relativeFilePath: "test.md", - fileContent: validContent, - validate: true, + relativeDirPath: join(".kilo", "commands"), + relativeFilePath: "custom.md", + frontmatter: { description: "Create component", agent: "plan" }, + body: "Create a new component named $ARGUMENTS", }); - expect(command.validate()).toEqual({ success: true, error: null }); + const rulesyncCommand = command.toRulesyncCommand(); + + expect(rulesyncCommand).toBeInstanceOf(RulesyncCommand); + expect(rulesyncCommand.getFrontmatter()).toEqual({ + targets: ["*"], + description: "Create component", + kilo: { agent: "plan" }, + }); + expect(rulesyncCommand.getRelativeDirPath()).toBe(RULESYNC_COMMANDS_RELATIVE_DIR_PATH); }); }); describe("fromFile", () => { - it("should load and strip frontmatter", async () => { - const workflowsDir = join(testDir, ".kilocode", "workflows"); - const filePath = join(workflowsDir, "workflow.md"); - await writeFileContent(filePath, markdownWithFrontmatter); + it("should load a command file and parse frontmatter", async () => { + const commandDir = join(testDir, ".kilo", "commands"); + await ensureDir(commandDir); + const filePath = join(commandDir, "task.md"); + await writeFileContent( + filePath, + `---\ndescription: Review component\nagent: review\n---\nCheck @src/components/Button.tsx`, + ); const command = await KiloCommand.fromFile({ baseDir: testDir, - relativeFilePath: "workflow.md", + relativeFilePath: "task.md", }); expect(command).toBeInstanceOf(KiloCommand); - expect(command.getRelativeDirPath()).toBe(join(".kilocode", "workflows")); - expect(command.getFileContent()).toBe("# Workflow\nStep 1"); - }); - - it("should support global workflows", async () => { - const workflowsDir = join(testDir, ".kilocode", "workflows"); - const filePath = join(workflowsDir, "global.md"); - await writeFileContent(filePath, validContent); - - const command = await KiloCommand.fromFile({ - baseDir: testDir, - relativeFilePath: "global.md", - global: true, - }); - - expect(command.getRelativeDirPath()).toBe(join(".kilocode", "workflows")); - expect(command.getFileContent()).toBe(validContent); - }); - }); - - describe("isTargetedByRulesyncCommand", () => { - it("should return true when rulesync targets include kilo", () => { - const rulesyncCommand = new RulesyncCommand({ - baseDir: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "workflow.md", - frontmatter: { targets: ["kilo"], description: "" }, - body: validContent, - fileContent: validContent, - validate: true, - }); - - expect(KiloCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true); - }); - - it("should return false when kilo is not targeted", () => { - const rulesyncCommand = new RulesyncCommand({ - baseDir: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "workflow.md", - frontmatter: { targets: ["cursor"], description: "" }, - body: validContent, - fileContent: validContent, - validate: true, - }); - - expect(KiloCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(false); - }); - }); - - describe("forDeletion", () => { - it("should create deletable command placeholder", () => { - const command = KiloCommand.forDeletion({ - baseDir: testDir, - relativeDirPath: ".kilocode/workflows", - relativeFilePath: "obsolete.md", - }); - - expect(command.isDeletable()).toBe(true); - expect(command.getFileContent()).toBe(""); + expect(KiloCommandFrontmatterSchema.safeParse(command.getFrontmatter()).success).toBe(true); + expect(command.getBody()).toBe("Check @src/components/Button.tsx"); }); }); }); diff --git a/src/features/commands/kilo-command.ts b/src/features/commands/kilo-command.ts index c5da8c6cf..0457bb998 100644 --- a/src/features/commands/kilo-command.ts +++ b/src/features/commands/kilo-command.ts @@ -1,8 +1,11 @@ import { join } from "node:path"; +import { optional, z } from "zod/mini"; + import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { formatError } from "../../utils/error.js"; import { readFileContent } from "../../utils/file.js"; -import { parseFrontmatter } from "../../utils/frontmatter.js"; +import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter.js"; import { RulesyncCommand, RulesyncCommandFrontmatter } from "./rulesync-command.js"; import { ToolCommand, @@ -12,27 +15,75 @@ import { ToolCommandSettablePaths, } from "./tool-command.js"; -export type KiloCommandParams = AiFileParams; +export const KiloCommandFrontmatterSchema = z.looseObject({ + description: z.optional(z.string()), + agent: optional(z.string()), + subtask: optional(z.boolean()), + model: optional(z.string()), +}); + +export type KiloCommandFrontmatter = z.infer; + +export type KiloCommandParams = { + frontmatter: KiloCommandFrontmatter; + body: string; +} & Omit; export class KiloCommand extends ToolCommand { - static getSettablePaths(_options: { global?: boolean } = {}): ToolCommandSettablePaths { + private readonly frontmatter: KiloCommandFrontmatter; + private readonly body: string; + + constructor({ frontmatter, body, ...rest }: KiloCommandParams) { + if (rest.validate) { + const result = KiloCommandFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error( + `Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`, + ); + } + } + + super({ + ...rest, + fileContent: stringifyFrontmatter(body, frontmatter), + }); + + this.frontmatter = frontmatter; + this.body = body; + } + + static getSettablePaths({ global }: { global?: boolean } = {}): ToolCommandSettablePaths { return { - relativeDirPath: join(".kilocode", "workflows"), + relativeDirPath: global ? join(".config", "kilo", "commands") : join(".kilo", "commands"), }; } + getBody(): string { + return this.body; + } + + getFrontmatter(): Record { + return this.frontmatter; + } + toRulesyncCommand(): RulesyncCommand { + const { description, ...restFields } = this.frontmatter; + const rulesyncFrontmatter: RulesyncCommandFrontmatter = { targets: ["*"], + description, + ...(Object.keys(restFields).length > 0 && { kilo: restFields }), }; + const fileContent = stringifyFrontmatter(this.body, rulesyncFrontmatter); + return new RulesyncCommand({ baseDir: process.cwd(), frontmatter: rulesyncFrontmatter, - body: this.getFileContent(), + body: this.body, relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath, relativeFilePath: this.relativeFilePath, - fileContent: this.getFileContent(), + fileContent, validate: true, }); } @@ -41,12 +92,23 @@ export class KiloCommand extends ToolCommand { baseDir = process.cwd(), rulesyncCommand, validate = true, + global = false, }: ToolCommandFromRulesyncCommandParams): KiloCommand { - const paths = this.getSettablePaths(); + const rulesyncFrontmatter = rulesyncCommand.getFrontmatter(); + const kiloFields = rulesyncFrontmatter.kilo ?? {}; + + const kiloFrontmatter: KiloCommandFrontmatter = { + description: rulesyncFrontmatter.description, + ...kiloFields, + }; + + const body = rulesyncCommand.getBody(); + const paths = this.getSettablePaths({ global }); return new KiloCommand({ baseDir: baseDir, - fileContent: rulesyncCommand.getBody(), + frontmatter: kiloFrontmatter, + body, relativeDirPath: paths.relativeDirPath, relativeFilePath: rulesyncCommand.getRelativeFilePath(), validate, @@ -54,40 +116,55 @@ export class KiloCommand extends ToolCommand { } validate(): ValidationResult { - return { success: true, error: null }; - } + if (!this.frontmatter) { + return { success: true, error: null }; + } - getBody(): string { - return this.getFileContent(); - } - - static isTargetedByRulesyncCommand(rulesyncCommand: RulesyncCommand): boolean { - return this.isTargetedByRulesyncCommandDefault({ - rulesyncCommand, - toolTarget: "kilo", - }); + const result = KiloCommandFrontmatterSchema.safeParse(this.frontmatter); + if (result.success) { + return { success: true, error: null }; + } + return { + success: false, + error: new Error( + `Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`, + ), + }; } static async fromFile({ baseDir = process.cwd(), relativeFilePath, validate = true, + global = false, }: ToolCommandFromFileParams): Promise { - const paths = this.getSettablePaths(); + const paths = this.getSettablePaths({ global }); const filePath = join(baseDir, paths.relativeDirPath, relativeFilePath); - const fileContent = await readFileContent(filePath); - const { body: content } = parseFrontmatter(fileContent, filePath); + const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath); + + const result = KiloCommandFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`); + } return new KiloCommand({ baseDir: baseDir, relativeDirPath: paths.relativeDirPath, relativeFilePath, - fileContent: content.trim(), + frontmatter: result.data, + body: content.trim(), validate, }); } + static isTargetedByRulesyncCommand(rulesyncCommand: RulesyncCommand): boolean { + return this.isTargetedByRulesyncCommandDefault({ + rulesyncCommand, + toolTarget: "kilo", + }); + } + static forDeletion({ baseDir = process.cwd(), relativeDirPath, @@ -97,7 +174,8 @@ export class KiloCommand extends ToolCommand { baseDir, relativeDirPath, relativeFilePath, - fileContent: "", + frontmatter: { description: "" }, + body: "", validate: false, }); } diff --git a/src/features/hooks/hooks-processor.test.ts b/src/features/hooks/hooks-processor.test.ts index cb73dacdc..46c8cd558 100644 --- a/src/features/hooks/hooks-processor.test.ts +++ b/src/features/hooks/hooks-processor.test.ts @@ -404,22 +404,24 @@ describe("HooksProcessor", () => { }); describe("getToolTargets", () => { - it("should return cursor, claudecode, copilot, opencode, factorydroid, and geminicli for project mode", () => { + it("should return cursor, claudecode, copilot, opencode, kilo, factorydroid, and geminicli for project mode", () => { const targets = HooksProcessor.getToolTargets({ global: false }); expect(targets).toEqual([ "cursor", "claudecode", "copilot", + "kilo", "opencode", "factorydroid", "geminicli", ]); }); - it("should return claudecode, opencode, factorydroid, and geminicli for global mode", () => { + it("should return claudecode, opencode, kilo, factorydroid, and geminicli for global mode", () => { const targets = HooksProcessor.getToolTargets({ global: true }); expect(targets).toEqual([ "claudecode", + "kilo", "opencode", "factorydroid", "geminicli", diff --git a/src/features/hooks/hooks-processor.ts b/src/features/hooks/hooks-processor.ts index 299299315..d3fbc1410 100644 --- a/src/features/hooks/hooks-processor.ts +++ b/src/features/hooks/hooks-processor.ts @@ -8,6 +8,7 @@ import { CURSOR_HOOK_EVENTS, DEEPAGENTS_HOOK_EVENTS, FACTORYDROID_HOOK_EVENTS, + KILO_HOOK_EVENTS, OPENCODE_HOOK_EVENTS, GEMINICLI_HOOK_EVENTS, type HookEvent, @@ -24,6 +25,7 @@ import { CursorHooks } from "./cursor-hooks.js"; import { DeepagentsHooks } from "./deepagents-hooks.js"; import { FactorydroidHooks } from "./factorydroid-hooks.js"; import { GeminicliHooks } from "./geminicli-hooks.js"; +import { KiloHooks } from "./kilo-hooks.js"; import { OpencodeHooks } from "./opencode-hooks.js"; import { RulesyncHooks } from "./rulesync-hooks.js"; import type { @@ -34,6 +36,7 @@ import type { import { ToolHooks } from "./tool-hooks.js"; const hooksProcessorToolTargetTuple = [ + "kilo", "cursor", "claudecode", "copilot", @@ -113,6 +116,20 @@ const toolHooksFactories = new Map([ supportsMatcher: false, }, ], + [ + "kilo", + { + class: KiloHooks, + meta: { + supportsProject: true, + supportsGlobal: true, + supportsImport: false, + }, + supportedEvents: KILO_HOOK_EVENTS, + supportedHookTypes: ["command"], + supportsMatcher: true, + }, + ], [ "opencode", { diff --git a/src/features/hooks/kilo-hooks.test.ts b/src/features/hooks/kilo-hooks.test.ts new file mode 100644 index 000000000..279d099f6 --- /dev/null +++ b/src/features/hooks/kilo-hooks.test.ts @@ -0,0 +1,522 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { RULESYNC_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { KiloHooks } from "./kilo-hooks.js"; +import { RulesyncHooks } from "./rulesync-hooks.js"; + +describe("KiloHooks", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("should return .kilo/plugins and rulesync-hooks.js", () => { + const paths = KiloHooks.getSettablePaths(); + expect(paths).toEqual({ + relativeDirPath: join(".kilo", "plugins"), + relativeFilePath: "rulesync-hooks.js", + }); + }); + + it("should return .config/kilo/plugins for global mode", () => { + const paths = KiloHooks.getSettablePaths({ global: true }); + expect(paths).toEqual({ + relativeDirPath: join(".config", "kilo", "plugins"), + relativeFilePath: "rulesync-hooks.js", + }); + }); + }); + + describe("fromRulesyncHooks", () => { + it("should filter shared hooks to Kilo-supported events only", () => { + const config = { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: ".rulesync/hooks/session-start.sh" }], + stop: [{ command: ".rulesync/hooks/audit.sh" }], + afterFileEdit: [{ command: "format.sh" }], + afterShellExecution: [{ command: "post-shell.sh" }], + permissionRequest: [{ command: "perm-check.sh" }], + // notification is not supported by Kilo + notification: [{ type: "command", command: "echo no" }], + // beforeSubmitPrompt has no Kilo equivalent + beforeSubmitPrompt: [{ command: "pre-prompt.sh" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const kiloHooks = KiloHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = kiloHooks.getFileContent(); + + // Generic events should be in the event handler with event.type checks + expect(content).toContain('event.type === "session.created"'); + expect(content).toContain(".rulesync/hooks/session-start.sh"); + expect(content).toContain('event.type === "session.idle"'); + expect(content).toContain(".rulesync/hooks/audit.sh"); + expect(content).toContain('event.type === "file.edited"'); + expect(content).toContain("format.sh"); + expect(content).toContain('event.type === "command.executed"'); + expect(content).toContain("post-shell.sh"); + + // permissionRequest maps to generic event permission.asked + expect(content).toContain('event.type === "permission.asked"'); + expect(content).toContain("perm-check.sh"); + + // Unsupported events should not appear + expect(content).not.toContain("notify.sh"); + expect(content).not.toContain("pre-prompt.sh"); + }); + + it("should generate tool event handlers with matcher support", () => { + const config = { + version: 1, + hooks: { + preToolUse: [ + { type: "command", command: ".rulesync/hooks/lint.sh", matcher: "Write|Edit" }, + ], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const kiloHooks = KiloHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = kiloHooks.getFileContent(); + expect(content).toContain('"tool.execute.before"'); + expect(content).toContain("input.tool"); + expect(content).toContain('new RegExp("Write|Edit")'); + expect(content).toContain(".rulesync/hooks/lint.sh"); + }); + + it("should generate tool event handlers without matcher when not specified", () => { + const config = { + version: 1, + hooks: { + postToolUse: [{ type: "command", command: ".rulesync/hooks/post-tool.sh" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const kiloHooks = KiloHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = kiloHooks.getFileContent(); + expect(content).toContain('"tool.execute.after"'); + expect(content).toContain(".rulesync/hooks/post-tool.sh"); + // Should not contain matcher logic + expect(content).not.toContain(".test(input.tool)"); + }); + + it("should skip prompt-type hooks", () => { + const config = { + version: 1, + hooks: { + sessionStart: [ + { type: "command", command: ".rulesync/hooks/session-start.sh" }, + { type: "prompt", prompt: "Remember to use TypeScript" }, + ], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const kiloHooks = KiloHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = kiloHooks.getFileContent(); + // sessionStart is a generic event, routed through event handler + expect(content).toContain('event.type === "session.created"'); + expect(content).toContain(".rulesync/hooks/session-start.sh"); + expect(content).not.toContain("Remember to use TypeScript"); + }); + + it("should merge config.kilo.hooks on top of shared hooks", () => { + const config = { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: "shared.sh" }], + }, + kilo: { + hooks: { + sessionStart: [{ type: "command", command: "kilo-override.sh" }], + stop: [{ command: "kilo-only.sh" }], + }, + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const kiloHooks = KiloHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = kiloHooks.getFileContent(); + expect(content).toContain("kilo-override.sh"); + expect(content).not.toContain("shared.sh"); + expect(content).toContain("kilo-only.sh"); + }); + + it("should handle empty hooks config", () => { + const config = { + version: 1, + hooks: {}, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const kiloHooks = KiloHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + expect(kiloHooks.getFileContent()).toBe( + [ + "export const RulesyncHooksPlugin = async ({ $ }) => {", + " return {", + " }", + "}", + "", + ].join("\n"), + ); + }); + + it("should escape ${} interpolation in commands", () => { + const config = { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: "echo ${HOME}" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const kiloHooks = KiloHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = kiloHooks.getFileContent(); + // ${} should be escaped in the template literal + expect(content).toContain("echo \\${HOME}"); + expect(content).not.toContain("echo ${HOME}"); + }); + + it("should handle multiple handlers for the same event", () => { + const config = { + version: 1, + hooks: { + preToolUse: [ + { type: "command", command: "lint.sh", matcher: "Write" }, + { type: "command", command: "format.sh", matcher: "Edit" }, + ], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const kiloHooks = KiloHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = kiloHooks.getFileContent(); + expect(content).toContain("lint.sh"); + expect(content).toContain("format.sh"); + expect(content).toContain('new RegExp("Write")'); + expect(content).toContain('new RegExp("Edit")'); + }); + + it("should throw on invalid regex in matcher", () => { + const config = { + version: 1, + hooks: { + preToolUse: [{ type: "command", command: "lint.sh", matcher: "[invalid" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + expect(() => + KiloHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }), + ).toThrow("Invalid regex pattern in hook matcher"); + }); + + it("should strip newline characters from matcher", () => { + const config = { + version: 1, + hooks: { + preToolUse: [{ type: "command", command: "lint.sh", matcher: "Write\n|Edit\r" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const kiloHooks = KiloHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = kiloHooks.getFileContent(); + expect(content).toContain('new RegExp("Write|Edit")'); + // The matcher itself should not contain newline/CR (they were stripped) + expect(content).not.toMatch(/\/Write\n/); + expect(content).not.toMatch(/Edit\r/); + }); + + it("should strip NUL byte from matcher", () => { + const config = { + version: 1, + hooks: { + preToolUse: [{ type: "command", command: "lint.sh", matcher: "Write\0|Edit" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const kiloHooks = KiloHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = kiloHooks.getFileContent(); + expect(content).toContain('new RegExp("Write|Edit")'); + }); + + it("should escape double quotes in matcher", () => { + const config = { + version: 1, + hooks: { + preToolUse: [{ type: "command", command: "lint.sh", matcher: 'Write"||true||"' }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const kiloHooks = KiloHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = kiloHooks.getFileContent(); + // Double quotes should be escaped in the RegExp string + expect(content).toContain('new RegExp("Write\\"||true||\\"")'); + // Should not contain unescaped double quotes that would break the JS string + expect(content).not.toContain('new RegExp("Write"'); + }); + + it("should escape backslashes in matcher for JS string embedding", () => { + const config = { + version: 1, + hooks: { + preToolUse: [{ type: "command", command: "lint.sh", matcher: "\\bWrite\\b" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const kiloHooks = KiloHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = kiloHooks.getFileContent(); + // \b should be double-escaped for embedding in a JS double-quoted string + expect(content).toContain('new RegExp("\\\\bWrite\\\\b")'); + }); + + it("should escape backticks in commands", () => { + const config = { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: "echo `date`" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const kiloHooks = KiloHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = kiloHooks.getFileContent(); + // Backticks should be escaped in the template literal + expect(content).toContain("echo \\`date\\`"); + }); + }); + + describe("toRulesyncHooks", () => { + it("should throw because Kilo hooks cannot be converted back", () => { + const kiloHooks = new KiloHooks({ + baseDir: testDir, + relativeDirPath: join(".kilo", "plugins"), + relativeFilePath: "rulesync-hooks.js", + fileContent: "export const Plugin = async ({ $ }) => { return {} }", + validate: false, + }); + + expect(() => kiloHooks.toRulesyncHooks()).toThrow( + "Not implemented because Kilo hooks are generated as a plugin file.", + ); + }); + }); + + describe("fromFile", () => { + it("should load from .kilo/plugins/rulesync-hooks.js", async () => { + const pluginsDir = join(testDir, ".kilo", "plugins"); + await ensureDir(pluginsDir); + const content = [ + "export const RulesyncHooksPlugin = async ({ $ }) => {", + " return {}", + "}", + ].join("\n"); + await writeFileContent(join(pluginsDir, "rulesync-hooks.js"), content); + + const kiloHooks = await KiloHooks.fromFile({ + baseDir: testDir, + validate: false, + }); + expect(kiloHooks).toBeInstanceOf(KiloHooks); + expect(kiloHooks.getFileContent()).toBe(content); + }); + }); + + describe("forDeletion", () => { + it("should return KiloHooks instance with empty content for deletion", () => { + const hooks = KiloHooks.forDeletion({ + baseDir: testDir, + relativeDirPath: join(".kilo", "plugins"), + relativeFilePath: "rulesync-hooks.js", + }); + expect(hooks).toBeInstanceOf(KiloHooks); + expect(hooks.getFileContent()).toBe(""); + }); + }); + + describe("isDeletable", () => { + it("should return true (plugin file is standalone and deletable)", () => { + const hooks = new KiloHooks({ + baseDir: testDir, + relativeDirPath: join(".kilo", "plugins"), + relativeFilePath: "rulesync-hooks.js", + fileContent: "", + validate: false, + }); + expect(hooks.isDeletable()).toBe(true); + }); + }); +}); diff --git a/src/features/hooks/kilo-hooks.ts b/src/features/hooks/kilo-hooks.ts new file mode 100644 index 000000000..bc48e6178 --- /dev/null +++ b/src/features/hooks/kilo-hooks.ts @@ -0,0 +1,241 @@ +import { join } from "node:path"; + +import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import type { HooksConfig } from "../../types/hooks.js"; +import { + CANONICAL_TO_KILO_EVENT_NAMES, + CONTROL_CHARS, + KILO_HOOK_EVENTS, +} from "../../types/hooks.js"; +import { readFileContent } from "../../utils/file.js"; +import type { RulesyncHooks } from "./rulesync-hooks.js"; +import { + ToolHooks, + type ToolHooksForDeletionParams, + type ToolHooksFromFileParams, + type ToolHooksFromRulesyncHooksParams, + type ToolHooksSettablePaths, +} from "./tool-hooks.js"; + +/** + * Kilo event names that are top-level named hooks on the Hooks interface. + * These receive `(input, output)` parameters with `input.tool` for matcher support. + * All other events must be routed through the generic `event` handler. + */ +const NAMED_HOOKS = new Set(["tool.execute.before", "tool.execute.after"]); + +/** + * Escape a command string for embedding inside a JS tagged template literal (backticks). + * Escapes backslashes, backticks, and `${` sequences. + */ +function escapeForTemplateLiteral(command: string): string { + return command.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); +} + +/** + * Validate and sanitize a matcher string for use in generated JS code. + * - Strips newline, carriage-return, and NUL bytes (defense-in-depth: + * the Zod `safeString` schema rejects these at input validation time, + * but this function provides a runtime safety net for `validate: false` paths) + * - Validates the result is a legal RegExp + * - Escapes for embedding inside a JS double-quoted string (`new RegExp("...")`) + */ +function validateAndSanitizeMatcher(matcher: string): string { + let sanitized = matcher; + for (const char of CONTROL_CHARS) { + sanitized = sanitized.replaceAll(char, ""); + } + try { + new RegExp(sanitized); + } catch { + throw new Error(`Invalid regex pattern in hook matcher: ${sanitized}`); + } + return sanitized.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +type KiloHandler = { + command: string; + matcher?: string; +}; + +type KiloHandlerGroup = Record; + +/** + * Group canonical hook definitions by their Kilo event name. + * Filters to command-type hooks and maps canonical events to Kilo events. + */ +function groupByKiloEvent(config: HooksConfig): { + namedEventHandlers: KiloHandlerGroup; + genericEventHandlers: KiloHandlerGroup; +} { + const kiloSupported: Set = new Set(KILO_HOOK_EVENTS); + const configHooks = { ...config.hooks, ...config.kilo?.hooks }; + const effectiveHooks: HooksConfig["hooks"] = {}; + + for (const [event, defs] of Object.entries(configHooks)) { + if (kiloSupported.has(event)) { + effectiveHooks[event] = defs; + } + } + + const namedEventHandlers: Record = {}; + const genericEventHandlers: Record = {}; + for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) { + const kiloEvent = CANONICAL_TO_KILO_EVENT_NAMES[canonicalEvent]; + if (!kiloEvent) continue; + + const handlers: KiloHandler[] = []; + for (const def of definitions) { + // Skip prompt-type hooks — unsupported + if (def.type === "prompt") continue; + if (!def.command) continue; + handlers.push({ + command: def.command, + matcher: def.matcher ? def.matcher : undefined, + }); + } + + if (handlers.length > 0) { + const grouped = NAMED_HOOKS.has(kiloEvent) ? namedEventHandlers : genericEventHandlers; + const existing = grouped[kiloEvent]; + if (existing) { + existing.push(...handlers); + } else { + grouped[kiloEvent] = handlers; + } + } + } + + return { namedEventHandlers, genericEventHandlers }; +} + +/** + * Generate the JavaScript plugin file content from canonical hooks config. + * + * Kilo plugins support two patterns: + * 1. Named typed hooks (top-level keys like "tool.execute.before") — receive (input, output) + * 2. Generic event handler — receives { event } and filters by event.type + * + * Named hooks are placed directly on the return object. + * Generic events are consolidated into a single `event` handler. + */ +function generatePluginCode(config: HooksConfig): string { + const { namedEventHandlers, genericEventHandlers } = groupByKiloEvent(config); + + const lines: string[] = []; + lines.push("export const RulesyncHooksPlugin = async ({ $ }) => {"); + lines.push(" return {"); + + // Generate the generic `event` handler if there are any generic events + if (Object.keys(genericEventHandlers).length > 0) { + lines.push(" event: async ({ event }) => {"); + for (const [eventName, handlers] of Object.entries(genericEventHandlers)) { + lines.push(` if (event.type === "${eventName}") {`); + for (const handler of handlers) { + const escapedCommand = escapeForTemplateLiteral(handler.command); + lines.push(` await $\`${escapedCommand}\``); + } + lines.push(" }"); + } + lines.push(" },"); + } + + // Generate named typed hooks (tool hooks with matcher support) + for (const [eventName, handlers] of Object.entries(namedEventHandlers)) { + lines.push(` "${eventName}": async (input) => {`); + for (const handler of handlers) { + const escapedCommand = escapeForTemplateLiteral(handler.command); + if (handler.matcher) { + const safeMatcher = validateAndSanitizeMatcher(handler.matcher); + lines.push(` if (new RegExp("${safeMatcher}").test(input.tool)) {`); + lines.push(` await $\`${escapedCommand}\``); + lines.push(" }"); + } else { + lines.push(` await $\`${escapedCommand}\``); + } + } + lines.push(" },"); + } + + lines.push(" }"); + lines.push("}"); + lines.push(""); + + return lines.join("\n"); +} + +export class KiloHooks extends ToolHooks { + constructor(params: AiFileParams) { + super({ + ...params, + fileContent: params.fileContent ?? "", + }); + } + + static getSettablePaths(options?: { global?: boolean }): ToolHooksSettablePaths { + return { + relativeDirPath: options?.global + ? join(".config", "kilo", "plugins") + : join(".kilo", "plugins"), + relativeFilePath: "rulesync-hooks.js", + }; + } + + static async fromFile({ + baseDir = process.cwd(), + validate = true, + global = false, + }: ToolHooksFromFileParams): Promise { + const paths = KiloHooks.getSettablePaths({ global }); + const fileContent = await readFileContent( + join(baseDir, paths.relativeDirPath, paths.relativeFilePath), + ); + return new KiloHooks({ + baseDir, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + static fromRulesyncHooks({ + baseDir = process.cwd(), + rulesyncHooks, + validate = true, + global = false, + }: ToolHooksFromRulesyncHooksParams & { global?: boolean }): KiloHooks { + const config = rulesyncHooks.getJson(); + const fileContent = generatePluginCode(config); + const paths = KiloHooks.getSettablePaths({ global }); + return new KiloHooks({ + baseDir, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + toRulesyncHooks(): RulesyncHooks { + throw new Error("Not implemented because Kilo hooks are generated as a plugin file."); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + static forDeletion({ + baseDir = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolHooksForDeletionParams): KiloHooks { + return new KiloHooks({ + baseDir, + relativeDirPath, + relativeFilePath, + fileContent: "", + validate: false, + }); + } +} diff --git a/src/features/mcp/kilo-mcp.test.ts b/src/features/mcp/kilo-mcp.test.ts index 9209b6649..dbe5e6e3d 100644 --- a/src/features/mcp/kilo-mcp.test.ts +++ b/src/features/mcp/kilo-mcp.test.ts @@ -2,8 +2,12 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { RULESYNC_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { + RULESYNC_MCP_SCHEMA_URL, + RULESYNC_RELATIVE_DIR_PATH, +} from "../../constants/rulesync-paths.js"; import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; import { KiloMcp } from "./kilo-mcp.js"; import { RulesyncMcp } from "./rulesync-mcp.js"; @@ -22,84 +26,2186 @@ describe("KiloMcp", () => { }); describe("getSettablePaths", () => { - it("should return project path", () => { - expect(KiloMcp.getSettablePaths()).toEqual({ - relativeDirPath: ".kilocode", - relativeFilePath: "mcp.json", + it("should return correct paths for local mode", () => { + const paths = KiloMcp.getSettablePaths(); + + expect(paths.relativeDirPath).toBe("."); + expect(paths.relativeFilePath).toBe("kilo.json"); + }); + + it("should return correct paths for global mode", () => { + const paths = KiloMcp.getSettablePaths({ global: true }); + + expect(paths.relativeDirPath).toBe(join(".config", "kilo")); + expect(paths.relativeFilePath).toBe("kilo.json"); + }); + }); + + describe("isDeletable", () => { + it("should always return false because kilo.json may contain other settings", () => { + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify({ mcp: {} }), + }); + + expect(kiloMcp.isDeletable()).toBe(false); + }); + + it("should return false when created via forDeletion with global: true", () => { + const kiloMcp = KiloMcp.forDeletion({ + relativeDirPath: join(".config", "kilo"), + relativeFilePath: "kilo.json", + global: true, + }); + + expect(kiloMcp.isDeletable()).toBe(false); + }); + }); + + describe("constructor", () => { + it("should create instance with default parameters", () => { + const validJsonContent = JSON.stringify({ + mcp: { + "test-server": { + type: "local", + command: ["node", "server.js"], + environment: {}, + enabled: true, + }, + }, + }); + + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: validJsonContent, + }); + + expect(kiloMcp).toBeInstanceOf(KiloMcp); + expect(kiloMcp.getRelativeDirPath()).toBe("."); + expect(kiloMcp.getRelativeFilePath()).toBe("kilo.json"); + expect(kiloMcp.getFileContent()).toBe(validJsonContent); + }); + + it("should create instance with custom baseDir", () => { + const validJsonContent = JSON.stringify({ + mcp: {}, + }); + + const kiloMcp = new KiloMcp({ + baseDir: "/custom/path", + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: validJsonContent, + }); + + expect(kiloMcp.getFilePath()).toBe("/custom/path/kilo.json"); + }); + + it("should parse JSON content correctly", () => { + const jsonData = { + mcp: { + "test-server": { + type: "local", + command: ["node", "server.js"], + environment: { NODE_ENV: "development" }, + enabled: true, + }, + }, + }; + const validJsonContent = JSON.stringify(jsonData); + + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: validJsonContent, + }); + + expect(kiloMcp.getJson()).toEqual(jsonData); + }); + + it("should handle empty JSON object", () => { + const emptyJsonContent = JSON.stringify({}); + + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: emptyJsonContent, + }); + + expect(kiloMcp.getJson()).toEqual({}); + }); + + it("should validate content by default", () => { + const validJsonContent = JSON.stringify({ + mcp: {}, + }); + + expect(() => { + const _instance = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: validJsonContent, + }); + }).not.toThrow(); + }); + + it("should skip validation when validate is false", () => { + const validJsonContent = JSON.stringify({ + mcp: {}, + }); + + expect(() => { + const _instance = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: validJsonContent, + validate: false, + }); + }).not.toThrow(); + }); + }); + + describe("fromFile", () => { + it("should create instance from file with default parameters", async () => { + const jsonData = { + mcp: { + filesystem: { + type: "local", + command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", testDir], + environment: {}, + enabled: true, + }, + }, + }; + await writeFileContent(join(testDir, "kilo.json"), JSON.stringify(jsonData, null, 2)); + + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + }); + + expect(kiloMcp).toBeInstanceOf(KiloMcp); + expect(kiloMcp.getJson()).toEqual(jsonData); + expect(kiloMcp.getFilePath()).toBe(join(testDir, "kilo.json")); + }); + + it("should initialize empty mcp if file does not exist", async () => { + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + }); + + expect(kiloMcp).toBeInstanceOf(KiloMcp); + expect(kiloMcp.getJson()).toEqual({ mcp: {} }); + expect(kiloMcp.getFilePath()).toBe(join(testDir, "kilo.jsonc")); + }); + + it("should initialize mcp if missing in existing file", async () => { + const jsonData = { + customConfig: { + setting: "value", + }, + }; + await writeFileContent(join(testDir, "kilo.json"), JSON.stringify(jsonData)); + + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + }); + + expect(kiloMcp.getJson()).toEqual({ + customConfig: { + setting: "value", + }, + mcp: {}, + }); + }); + + it("should create instance from file with custom baseDir", async () => { + const customDir = join(testDir, "custom"); + await ensureDir(customDir); + + const jsonData = { + mcp: { + git: { + type: "local", + command: ["node", "git-server.js"], + environment: {}, + enabled: true, + }, + }, + }; + await writeFileContent(join(customDir, "kilo.json"), JSON.stringify(jsonData)); + + const kiloMcp = await KiloMcp.fromFile({ + baseDir: customDir, + }); + + expect(kiloMcp.getFilePath()).toBe(join(customDir, "kilo.json")); + expect(kiloMcp.getJson()).toEqual(jsonData); + }); + + it("should handle validation when validate is true", async () => { + const jsonData = { + mcp: { + "valid-server": { + type: "local", + command: ["node", "server.js"], + environment: {}, + enabled: true, + }, + }, + }; + await writeFileContent(join(testDir, "kilo.json"), JSON.stringify(jsonData)); + + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + validate: true, + }); + + expect(kiloMcp.getJson()).toEqual(jsonData); + }); + + it("should skip validation when validate is false", async () => { + const jsonData = { + mcp: {}, + }; + await writeFileContent(join(testDir, "kilo.json"), JSON.stringify(jsonData)); + + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + validate: false, + }); + + expect(kiloMcp.getJson()).toEqual(jsonData); + }); + + it("should create instance from file in global mode", async () => { + const globalPath = join(testDir, ".config", "kilo", "kilo.json"); + + const jsonData = { + mcp: { + filesystem: { + type: "local", + command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", testDir], + environment: {}, + enabled: true, + }, + }, + }; + await writeFileContent(globalPath, JSON.stringify(jsonData, null, 2)); + + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + global: true, + }); + + expect(kiloMcp).toBeInstanceOf(KiloMcp); + expect(kiloMcp.getJson()).toEqual(jsonData); + expect(kiloMcp.getFilePath()).toBe(globalPath); + }); + + it("should create instance from file in local mode (default)", async () => { + const jsonData = { + mcp: { + git: { + type: "local", + command: ["node", "git-server.js"], + environment: {}, + enabled: true, + }, + }, + }; + await writeFileContent(join(testDir, "kilo.json"), JSON.stringify(jsonData)); + + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + global: false, + }); + + expect(kiloMcp.getFilePath()).toBe(join(testDir, "kilo.json")); + expect(kiloMcp.getJson()).toEqual(jsonData); + }); + + it("should initialize global config file if it does not exist", async () => { + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + global: true, + }); + + expect(kiloMcp).toBeInstanceOf(KiloMcp); + expect(kiloMcp.getJson()).toEqual({ mcp: {} }); + expect(kiloMcp.getFilePath()).toBe(join(testDir, ".config", "kilo", "kilo.jsonc")); + }); + + it("should preserve non-mcp properties in global mode", async () => { + const existingGlobalConfig = { + mcp: { + "old-server": { + type: "local", + command: ["node", "old-server.js"], + environment: {}, + enabled: true, + }, + }, + userSettings: { + theme: "dark", + fontSize: 14, + }, + version: "1.0.0", + }; + await writeFileContent( + join(testDir, ".config", "kilo", "kilo.json"), + JSON.stringify(existingGlobalConfig, null, 2), + ); + + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + global: true, + }); + + const json = kiloMcp.getJson(); + expect(json.mcp).toEqual({ + "old-server": { + type: "local", + command: ["node", "old-server.js"], + environment: {}, + enabled: true, + }, + }); + expect((json as any).userSettings).toEqual({ + theme: "dark", + fontSize: 14, }); + expect((json as any).version).toBe("1.0.0"); }); }); describe("fromRulesyncMcp", () => { - it("should convert exposed servers for project mode", () => { + it("should create instance from RulesyncMcp with default parameters", async () => { + const jsonData = { + mcpServers: { + "test-server": { + command: "node", + args: ["test-server.js"], + }, + }, + }; const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ baseDir: testDir, + rulesyncMcp, + }); + + expect(kiloMcp).toBeInstanceOf(KiloMcp); + // fromRulesyncMcp converts standard MCP format to Kilo format + expect(kiloMcp.getJson()).toEqual({ + mcp: { + "test-server": { + type: "local", + command: ["node", "test-server.js"], + enabled: true, + }, + }, + }); + expect(kiloMcp.getRelativeDirPath()).toBe("."); + expect(kiloMcp.getRelativeFilePath()).toBe("kilo.jsonc"); + }); + + it("should create instance from RulesyncMcp with custom baseDir", async () => { + const jsonData = { + mcpServers: { + "custom-server": { + command: "python", + args: ["server.py"], + env: { + PYTHONPATH: "/custom/path", + }, + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + baseDir: "/custom/base", relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, relativeFilePath: ".mcp.json", - fileContent: JSON.stringify({ - mcpServers: { - exposedServer: { command: "node", args: ["server.js"], exposed: true }, - hiddenServer: { command: "python", args: ["hidden.py"] }, + fileContent: JSON.stringify(jsonData), + }); + + const customDir = join(testDir, "target"); + await ensureDir(customDir); + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: customDir, + rulesyncMcp, + }); + + expect(kiloMcp.getFilePath()).toBe(join(customDir, "kilo.jsonc")); + // fromRulesyncMcp converts standard MCP format to Kilo format + expect(kiloMcp.getJson()).toEqual({ + mcp: { + "custom-server": { + type: "local", + command: ["python", "server.py"], + enabled: true, + environment: { + PYTHONPATH: "/custom/path", + }, }, - }), + }, + }); + }); + + it("should handle validation when validate is true", async () => { + const jsonData = { + mcpServers: { + "validated-server": { + command: "node", + args: ["validated-server.js"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, validate: true, }); - const kiloMcp = KiloMcp.fromRulesyncMcp({ rulesyncMcp }); + // fromRulesyncMcp converts standard MCP format to Kilo format + expect(kiloMcp.getJson()).toEqual({ + mcp: { + "validated-server": { + type: "local", + command: ["node", "validated-server.js"], + enabled: true, + }, + }, + }); + }); + + it("should skip validation when validate is false", async () => { + const jsonData = { + mcpServers: {}, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + validate: false, + }); + + expect(kiloMcp.getJson()).toEqual({ mcp: {} }); + }); + + it("should handle empty mcpServers object", async () => { + const jsonData = { + mcpServers: {}, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + }); + + expect(kiloMcp.getJson()).toEqual({ mcp: {} }); + }); - expect(kiloMcp.getRelativeDirPath()).toBe(".kilocode"); - expect(kiloMcp.getRelativeFilePath()).toBe("mcp.json"); - expect(JSON.parse(kiloMcp.getFileContent())).toEqual({ + it("should create instance from RulesyncMcp in global mode", async () => { + const jsonData = { mcpServers: { - exposedServer: { command: "node", args: ["server.js"] }, - hiddenServer: { command: "python", args: ["hidden.py"] }, + "global-server": { + command: "node", + args: ["global-server.js"], + }, }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), }); - }); - }); - describe("fromFile", () => { - it("should initialize missing project file", async () => { - const kiloMcp = await KiloMcp.fromFile({ baseDir: testDir }); + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + global: true, + }); - expect(kiloMcp.getFilePath()).toBe(join(testDir, ".kilocode", "mcp.json")); - expect(JSON.parse(kiloMcp.getFileContent())).toEqual({ mcpServers: {} }); + expect(kiloMcp).toBeInstanceOf(KiloMcp); + // fromRulesyncMcp converts standard MCP format to Kilo format + expect(kiloMcp.getJson()).toEqual({ + mcp: { + "global-server": { + type: "local", + command: ["node", "global-server.js"], + enabled: true, + }, + }, + }); + expect(kiloMcp.getRelativeDirPath()).toBe(join(".config", "kilo")); + expect(kiloMcp.getRelativeFilePath()).toBe("kilo.jsonc"); }); - }); - describe("toRulesyncMcp", () => { - it("should convert to Rulesync format", () => { - const kiloMcp = new KiloMcp({ + it("should create instance from RulesyncMcp in local mode (default)", async () => { + const jsonData = { + mcpServers: { + "local-server": { + command: "python", + args: ["local-server.py"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ baseDir: testDir, - relativeDirPath: ".kilocode", - relativeFilePath: "mcp.json", - fileContent: JSON.stringify({ - mcpServers: { - api: { command: "node", args: ["server.js"] }, + rulesyncMcp, + global: false, + }); + + expect(kiloMcp.getFilePath()).toBe(join(testDir, "kilo.jsonc")); + // fromRulesyncMcp converts standard MCP format to Kilo format + expect(kiloMcp.getJson()).toEqual({ + mcp: { + "local-server": { + type: "local", + command: ["python", "local-server.py"], + enabled: true, }, - }), - validate: true, + }, }); + expect(kiloMcp.getRelativeDirPath()).toBe("."); + expect(kiloMcp.getRelativeFilePath()).toBe("kilo.jsonc"); + }); - const rulesyncMcp = kiloMcp.toRulesyncMcp(); + it("should preserve non-mcp properties when updating global config", async () => { + const existingGlobalConfig = { + mcp: { + "old-server": { + type: "local", + command: ["node", "old-server.js"], + environment: {}, + enabled: true, + }, + }, + userSettings: { + theme: "dark", + }, + version: "1.0.0", + }; + await writeFileContent( + join(testDir, ".config", "kilo", "kilo.json"), + JSON.stringify(existingGlobalConfig, null, 2), + ); + + const newMcpServers = { + mcpServers: { + "new-server": { + command: "python", + args: ["new-server.py"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: ".rulesync", + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(newMcpServers), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + global: true, + }); - expect(rulesyncMcp.getFilePath()).toBe(join(testDir, ".rulesync", "mcp.json")); - expect(rulesyncMcp.getMcpServers()).toEqual({ - api: { command: "node", args: ["server.js"] }, + const json = kiloMcp.getJson(); + // fromRulesyncMcp converts standard MCP format to Kilo format + expect(json.mcp).toEqual({ + "new-server": { + type: "local", + command: ["python", "new-server.py"], + enabled: true, + }, }); + expect((json as any).userSettings).toEqual({ + theme: "dark", + }); + expect((json as any).version).toBe("1.0.0"); }); - }); - describe("forDeletion", () => { - it("should create deletable placeholder", () => { - const kiloMcp = KiloMcp.forDeletion({ + it("should merge mcp when updating global config", async () => { + const existingGlobalConfig = { + mcp: { + "existing-server": { + type: "local", + command: ["node", "existing-server.js"], + environment: {}, + enabled: true, + }, + }, + customProperty: "value", + }; + await writeFileContent( + join(testDir, ".config", "kilo", "kilo.json"), + JSON.stringify(existingGlobalConfig, null, 2), + ); + + const newMcpConfig = { + mcpServers: { + "new-server": { + command: "python", + args: ["new-server.py"], + }, + "another-server": { + command: "node", + args: ["another.js"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: ".rulesync", + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(newMcpConfig), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + global: true, + }); + + const json = kiloMcp.getJson(); + // Should replace mcp entirely, not merge individual servers + // fromRulesyncMcp converts standard MCP format to Kilo format + expect(json.mcp).toEqual({ + "new-server": { + type: "local", + command: ["python", "new-server.py"], + enabled: true, + }, + "another-server": { + type: "local", + command: ["node", "another.js"], + enabled: true, + }, + }); + expect((json as any).customProperty).toBe("value"); + }); + + it("should convert enabledTools to top-level tools map with server prefix", async () => { + const jsonData = { + mcpServers: { + "my-server": { + command: "node", + args: ["server.js"], + enabledTools: ["search", "list"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + }); + + expect(kiloMcp.getJson()).toEqual({ + mcp: { + "my-server": { + type: "local", + command: ["node", "server.js"], + enabled: true, + }, + }, + tools: { + "my-server_search": true, + "my-server_list": true, + }, + }); + }); + + it("should convert disabledTools to top-level tools map with server prefix", async () => { + const jsonData = { + mcpServers: { + "my-server": { + command: "node", + args: ["server.js"], + disabledTools: ["search", "list"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + }); + + expect(kiloMcp.getJson()).toEqual({ + mcp: { + "my-server": { + type: "local", + command: ["node", "server.js"], + enabled: true, + }, + }, + tools: { + "my-server_search": false, + "my-server_list": false, + }, + }); + }); + + it("should convert both enabledTools and disabledTools to top-level tools map", async () => { + const jsonData = { + mcpServers: { + "my-server": { + command: "node", + args: ["server.js"], + enabledTools: ["search"], + disabledTools: ["list"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + }); + + expect(kiloMcp.getJson()).toEqual({ + mcp: { + "my-server": { + type: "local", + command: ["node", "server.js"], + enabled: true, + }, + }, + tools: { + "my-server_search": true, + "my-server_list": false, + }, + }); + }); + + it("should convert enabledTools/disabledTools for multiple servers", async () => { + const jsonData = { + mcpServers: { + "server-a": { + command: "node", + args: ["a.js"], + disabledTools: ["search"], + }, + "server-b": { + command: "node", + args: ["b.js"], + enabledTools: ["list"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + }); + + expect(kiloMcp.getJson()).toEqual({ + mcp: { + "server-a": { + type: "local", + command: ["node", "a.js"], + enabled: true, + }, + "server-b": { + type: "local", + command: ["node", "b.js"], + enabled: true, + }, + }, + tools: { + "server-a_search": false, + "server-b_list": true, + }, + }); + }); + + it("should convert enabledTools/disabledTools for remote servers", async () => { + const jsonData = { + mcpServers: { + "remote-server": { + type: "sse", + url: "https://example.com/mcp", + enabledTools: ["fetch"], + disabledTools: ["search"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + }); + + expect(kiloMcp.getJson()).toEqual({ + mcp: { + "remote-server": { + type: "remote", + url: "https://example.com/mcp", + enabled: true, + }, + }, + tools: { + "remote-server_fetch": true, + "remote-server_search": false, + }, + }); + }); + + it("should not include tools key when no enabledTools/disabledTools are specified", async () => { + const jsonData = { + mcpServers: { + "test-server": { + command: "node", + args: ["server.js"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + }); + + expect(kiloMcp.getJson()).toEqual({ + mcp: { + "test-server": { + type: "local", + command: ["node", "server.js"], + enabled: true, + }, + }, + }); + expect(kiloMcp.getJson().tools).toBeUndefined(); + }); + + it("should fully override tools and not preserve existing tools from file", async () => { + const existingConfig = { + mcp: { + "old-server": { + type: "local", + command: ["node", "old.js"], + enabled: true, + }, + }, + tools: { + "old-server_search": false, + unrelated_tool: true, + }, + }; + await writeFileContent(join(testDir, "kilo.json"), JSON.stringify(existingConfig, null, 2)); + + const jsonData = { + mcpServers: { + "new-server": { + command: "node", + args: ["new.js"], + disabledTools: ["list"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + }); + + // Should fully override: only new server tools, no preserved unrelated tools + expect(kiloMcp.getJson().tools).toEqual({ + "new-server_list": false, + }); + }); + + it("should remove stale tools key when new config has no enabledTools/disabledTools", async () => { + const existingConfig = { + mcp: { + "old-server": { + type: "local", + command: ["node", "old.js"], + enabled: true, + }, + }, + tools: { + "old-server_search": false, + unrelated_tool: true, + }, + }; + await writeFileContent(join(testDir, "kilo.json"), JSON.stringify(existingConfig, null, 2)); + + const jsonData = { + mcpServers: { + "new-server": { + command: "node", + args: ["new.js"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + }); + + // tools key should be removed entirely when no enabledTools/disabledTools + expect(kiloMcp.getJson().tools).toBeUndefined(); + }); + + it("should read existing kilo.jsonc file and preserve it", async () => { + const jsoncContent = `{ + // Existing server configuration + "mcp": { + "existingServer": { + "type": "local", + "command": ["node", "existing.js"], + "enabled": true + } + } +}`; + await writeFileContent(join(testDir, "kilo.jsonc"), jsoncContent); + + const jsonData = { + mcpServers: { + "new-server": { + command: "python", + args: ["new-server.py"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(jsonData), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + }); + + // Should have both the new server from rulesyncMcp and preserve other properties + const newServer = kiloMcp.getJson().mcp?.["new-server"]; + expect(newServer).toBeDefined(); + if (newServer?.type === "local") { + expect(newServer.type).toBe("local"); + } + // Note: existing server is replaced because we're updating mcp section + // This is expected behavior as we're regenerating the mcp config + }); + + it("should prefer kilo.jsonc over kilo.json when generating from RulesyncMcp", async () => { + const jsonContent = { + mcp: { + "json-server": { + type: "local", + command: ["node", "json.js"], + enabled: true, + }, + }, + }; + const jsoncContent = `{ + "mcp": { + "jsonc-server": { + "type": "local", + "command": ["node", "jsonc.js"], + "enabled": true + } + } +}`; + await writeFileContent(join(testDir, "kilo.json"), JSON.stringify(jsonContent)); + await writeFileContent(join(testDir, "kilo.jsonc"), jsoncContent); + + const rulesyncMcpData = { + mcpServers: { + "new-server": { + command: "python", + args: ["new-server.py"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(rulesyncMcpData), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + }); + + // Should use content from jsonc file + expect(kiloMcp.getRelativeFilePath()).toContain("jsonc"); + }); + + it("should create kilo.jsonc as preferred format when no existing files", async () => { + const rulesyncMcpData = { + mcpServers: { + "new-server": { + command: "python", + args: ["new-server.py"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(rulesyncMcpData), + }); + + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + }); + + // When creating new, should prefer jsonc + expect(kiloMcp.getRelativeFilePath()).toBe("kilo.jsonc"); + }); + }); + + describe("toRulesyncMcp", () => { + it("should convert to RulesyncMcp with standard format (local -> stdio)", () => { + const jsonData = { + mcp: { + filesystem: { + type: "local", + command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + environment: {}, + enabled: true, + }, + }, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + }); + + const rulesyncMcp = kiloMcp.toRulesyncMcp(); + + expect(rulesyncMcp).toBeInstanceOf(RulesyncMcp); + // Should convert to standard format: type: "stdio", command: string, args: string[] + expect(JSON.parse(rulesyncMcp.getFileContent())).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + mcpServers: { + filesystem: { + type: "stdio", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + env: {}, + }, + }, + }); + expect(rulesyncMcp.getRelativeDirPath()).toBe(RULESYNC_RELATIVE_DIR_PATH); + expect(rulesyncMcp.getRelativeFilePath()).toBe("mcp.json"); + }); + + it("should convert environment to env and preserve baseDir", () => { + const jsonData = { + mcp: { + "complex-server": { + type: "local", + command: ["node", "complex-server.js", "--port", "3000"], + environment: { + NODE_ENV: "production", + DEBUG: "mcp:*", + }, + enabled: true, + }, + }, + }; + const kiloMcp = new KiloMcp({ + baseDir: "/test/dir", + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + }); + + const rulesyncMcp = kiloMcp.toRulesyncMcp(); + + expect(rulesyncMcp.getBaseDir()).toBe("/test/dir"); + expect(JSON.parse(rulesyncMcp.getFileContent())).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + mcpServers: { + "complex-server": { + type: "stdio", + command: "node", + args: ["complex-server.js", "--port", "3000"], + env: { + NODE_ENV: "production", + DEBUG: "mcp:*", + }, + }, + }, + }); + }); + + it("should handle empty mcp object when converting", () => { + const jsonData = { + mcp: {}, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + }); + + const rulesyncMcp = kiloMcp.toRulesyncMcp(); + + expect(JSON.parse(rulesyncMcp.getFileContent())).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + mcpServers: {}, + }); + }); + + it("should extract only mcp when converting to RulesyncMcp", () => { + const jsonData = { + mcp: { + "test-server": { + type: "local", + command: ["node", "server.js"], + environment: {}, + enabled: true, + }, + }, + userSettings: { + theme: "light", + }, + version: "2.0.0", + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + }); + + const rulesyncMcp = kiloMcp.toRulesyncMcp(); + + const exportedJson = JSON.parse(rulesyncMcp.getFileContent()); + expect(exportedJson).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + mcpServers: { + "test-server": { + type: "stdio", + command: "node", + args: ["server.js"], + env: {}, + }, + }, + }); + expect((exportedJson as any).userSettings).toBeUndefined(); + expect((exportedJson as any).version).toBeUndefined(); + }); + + it("should convert remote type to sse", () => { + const jsonData = { + mcp: { + "remote-server": { + type: "remote", + url: "https://example.com/mcp", + headers: { + Authorization: "Bearer token", + }, + enabled: true, + }, + }, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + }); + + const rulesyncMcp = kiloMcp.toRulesyncMcp(); + + expect(JSON.parse(rulesyncMcp.getFileContent())).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + mcpServers: { + "remote-server": { + type: "sse", + url: "https://example.com/mcp", + headers: { + Authorization: "Bearer token", + }, + }, + }, + }); + }); + + it("should convert disabled servers (enabled: false -> disabled: true)", () => { + const jsonData = { + mcp: { + "disabled-server": { + type: "local", + command: ["node", "server.js"], + enabled: false, + }, + }, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + }); + + const rulesyncMcp = kiloMcp.toRulesyncMcp(); + + expect(JSON.parse(rulesyncMcp.getFileContent())).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + mcpServers: { + "disabled-server": { + type: "stdio", + command: "node", + args: ["server.js"], + disabled: true, + }, + }, + }); + }); + + it("should preserve cwd when converting", () => { + const jsonData = { + mcp: { + "cwd-server": { + type: "local", + command: ["node", "server.js"], + cwd: "/custom/path", + enabled: true, + }, + }, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + }); + + const rulesyncMcp = kiloMcp.toRulesyncMcp(); + + expect(JSON.parse(rulesyncMcp.getFileContent())).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + mcpServers: { + "cwd-server": { + type: "stdio", + command: "node", + args: ["server.js"], + cwd: "/custom/path", + }, + }, + }); + }); + + it("should throw error when command array is empty", () => { + const jsonData = { + mcp: { + "empty-command-server": { + type: "local", + command: [], + enabled: true, + }, + }, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + validate: false, + }); + + expect(() => kiloMcp.toRulesyncMcp()).toThrow( + 'Server "empty-command-server" has an empty command array', + ); + }); + + it("should convert tools map to enabledTools per server (strip prefix)", () => { + const jsonData = { + mcp: { + "my-server": { + type: "local", + command: ["node", "server.js"], + enabled: true, + }, + }, + tools: { + "my-server_search": true, + "my-server_list": true, + }, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + }); + + const rulesyncMcp = kiloMcp.toRulesyncMcp(); + + expect(JSON.parse(rulesyncMcp.getFileContent())).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + mcpServers: { + "my-server": { + type: "stdio", + command: "node", + args: ["server.js"], + enabledTools: ["search", "list"], + }, + }, + }); + }); + + it("should convert tools map to disabledTools per server (strip prefix)", () => { + const jsonData = { + mcp: { + "my-server": { + type: "local", + command: ["node", "server.js"], + enabled: true, + }, + }, + tools: { + "my-server_search": false, + "my-server_list": false, + }, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + }); + + const rulesyncMcp = kiloMcp.toRulesyncMcp(); + + expect(JSON.parse(rulesyncMcp.getFileContent())).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + mcpServers: { + "my-server": { + type: "stdio", + command: "node", + args: ["server.js"], + disabledTools: ["search", "list"], + }, + }, + }); + }); + + it("should convert tools map to both enabledTools and disabledTools per server", () => { + const jsonData = { + mcp: { + "my-server": { + type: "local", + command: ["node", "server.js"], + enabled: true, + }, + }, + tools: { + "my-server_search": true, + "my-server_list": false, + "my-server_read": true, + }, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + }); + + const rulesyncMcp = kiloMcp.toRulesyncMcp(); + + expect(JSON.parse(rulesyncMcp.getFileContent())).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + mcpServers: { + "my-server": { + type: "stdio", + command: "node", + args: ["server.js"], + enabledTools: ["search", "read"], + disabledTools: ["list"], + }, + }, + }); + }); + + it("should only assign tools to the correct server by prefix", () => { + const jsonData = { + mcp: { + "server-a": { + type: "local", + command: ["node", "a.js"], + enabled: true, + }, + "server-b": { + type: "local", + command: ["node", "b.js"], + enabled: true, + }, + }, + tools: { + "server-a_search": false, + "server-b_list": true, + unrelated_tool: false, + }, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + }); + + const rulesyncMcp = kiloMcp.toRulesyncMcp(); + + expect(JSON.parse(rulesyncMcp.getFileContent())).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + mcpServers: { + "server-a": { + type: "stdio", + command: "node", + args: ["a.js"], + disabledTools: ["search"], + }, + "server-b": { + type: "stdio", + command: "node", + args: ["b.js"], + enabledTools: ["list"], + }, + }, + }); + }); + + it("should handle tools on remote servers", () => { + const jsonData = { + mcp: { + "remote-server": { + type: "remote", + url: "https://example.com/mcp", + enabled: true, + }, + }, + tools: { + "remote-server_search": false, + "remote-server_fetch": true, + }, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + }); + + const rulesyncMcp = kiloMcp.toRulesyncMcp(); + + expect(JSON.parse(rulesyncMcp.getFileContent())).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + mcpServers: { + "remote-server": { + type: "sse", + url: "https://example.com/mcp", + enabledTools: ["fetch"], + disabledTools: ["search"], + }, + }, + }); + }); + + it("should not include enabledTools/disabledTools when tools map is empty", () => { + const jsonData = { + mcp: { + "my-server": { + type: "local", + command: ["node", "server.js"], + enabled: true, + }, + }, + tools: {}, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + }); + + const rulesyncMcp = kiloMcp.toRulesyncMcp(); + + expect(JSON.parse(rulesyncMcp.getFileContent())).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + mcpServers: { + "my-server": { + type: "stdio", + command: "node", + args: ["server.js"], + }, + }, + }); + }); + + it("should not include enabledTools/disabledTools when no tools key exists", () => { + const jsonData = { + mcp: { + "my-server": { + type: "local", + command: ["node", "server.js"], + enabled: true, + }, + }, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + }); + + const rulesyncMcp = kiloMcp.toRulesyncMcp(); + + expect(JSON.parse(rulesyncMcp.getFileContent())).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + mcpServers: { + "my-server": { + type: "stdio", + command: "node", + args: ["server.js"], + }, + }, + }); + }); + }); + + describe("validate", () => { + it("should return successful validation result", () => { + const jsonData = { + mcp: { + "test-server": { + type: "local", + command: ["node", "server.js"], + environment: {}, + enabled: true, + }, + }, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + validate: false, // Skip validation in constructor to test method directly + }); + + const result = kiloMcp.validate(); + + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + }); + + it("should always return success (no validation logic implemented)", () => { + const jsonData = { + mcp: {}, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + validate: false, + }); + + const result = kiloMcp.validate(); + + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + }); + + it("should return success for complex MCP configuration", () => { + const jsonData = { + mcp: { + filesystem: { + type: "local", + command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"], + environment: { + NODE_ENV: "development", + }, + enabled: true, + }, + git: { + type: "local", + command: ["node", "git-server.js"], + environment: {}, + enabled: true, + }, + sqlite: { + type: "local", + command: ["python", "sqlite-server.py", "--database", "/path/to/db.sqlite"], + environment: { + PYTHONPATH: "/custom/path", + DEBUG: "true", + }, + enabled: true, + }, + }, + globalSettings: { + timeout: 30000, + retries: 3, + }, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + validate: false, + }); + + const result = kiloMcp.validate(); + + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + }); + + it("should return success for configuration with tools map", () => { + const jsonData = { + mcp: { + "my-server": { + type: "local", + command: ["node", "server.js"], + enabled: true, + }, + }, + tools: { + "my-server_search": true, + "my-server_list": false, + }, + }; + const kiloMcp = new KiloMcp({ + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(jsonData), + validate: false, + }); + + const result = kiloMcp.validate(); + + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + }); + }); + + describe("integration", () => { + it("should handle complete workflow: fromFile -> toRulesyncMcp -> fromRulesyncMcp", async () => { + const originalJsonData = { + mcp: { + "workflow-server": { + type: "local", + command: ["node", "workflow-server.js", "--config", "config.json"], + environment: { + NODE_ENV: "test", + }, + enabled: true, + }, + }, + }; + await writeFileContent(join(testDir, "kilo.json"), JSON.stringify(originalJsonData, null, 2)); + + // Step 1: Load from file + const originalKiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + }); + + // Step 2: Convert to RulesyncMcp (now converts to standard format) + const rulesyncMcp = originalKiloMcp.toRulesyncMcp(); + + // Verify RulesyncMcp has standard format + const rulesyncJson = JSON.parse(rulesyncMcp.getFileContent()); + expect(rulesyncJson.mcpServers["workflow-server"]).toEqual({ + type: "stdio", + command: "node", + args: ["workflow-server.js", "--config", "config.json"], + env: { + NODE_ENV: "test", + }, + }); + + // Step 3: Create new KiloMcp from RulesyncMcp + const newKiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + }); + + // After round-trip, should be back to Kilo format + expect(newKiloMcp.getJson()).toEqual({ + mcp: { + "workflow-server": { + type: "local", + command: ["node", "workflow-server.js", "--config", "config.json"], + environment: { + NODE_ENV: "test", + }, + enabled: true, + }, + }, + }); + expect(newKiloMcp.getFilePath()).toBe(join(testDir, "kilo.json")); + }); + + it("should maintain data consistency across transformations", async () => { + const complexJsonData = { + mcp: { + "primary-server": { + type: "local", + command: ["node", "primary.js", "--mode", "production"], + environment: { + NODE_ENV: "production", + LOG_LEVEL: "info", + API_KEY: "secret", + }, + enabled: true, + }, + "secondary-server": { + type: "local", + command: ["python", "secondary.py", "--workers", "4"], + environment: { + PYTHONPATH: "/app/lib", + }, + enabled: true, + }, + }, + config: { + timeout: 60000, + maxRetries: 5, + logLevel: "debug", + }, + }; + + // Create KiloMcp + const kiloMcp = new KiloMcp({ + baseDir: testDir, + relativeDirPath: ".", + relativeFilePath: "kilo.json", + fileContent: JSON.stringify(complexJsonData), + }); + + // Convert to RulesyncMcp + const rulesyncMcp = kiloMcp.toRulesyncMcp(); + + // Verify only mcp is in exported data + const exportedJson = JSON.parse(rulesyncMcp.getFileContent()); + expect(exportedJson.mcpServers).toBeDefined(); + expect((exportedJson as any).config).toBeUndefined(); + }); + + it("should handle complete workflow in global mode", async () => { + const originalJsonData = { + mcp: { + "global-workflow-server": { + type: "local", + command: ["node", "global-server.js"], + environment: {}, + enabled: true, + }, + }, + }; + await writeFileContent( + join(testDir, ".config", "kilo", "kilo.json"), + JSON.stringify(originalJsonData, null, 2), + ); + + // Step 1: Load from global config + const originalKiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + global: true, + }); + + // Step 2: Convert to RulesyncMcp (now converts to standard format) + const rulesyncMcp = originalKiloMcp.toRulesyncMcp(); + + // Verify RulesyncMcp has standard format + const rulesyncJson = JSON.parse(rulesyncMcp.getFileContent()); + expect(rulesyncJson.mcpServers["global-workflow-server"]).toEqual({ + type: "stdio", + command: "node", + args: ["global-server.js"], + env: {}, + }); + + // Step 3: Create new KiloMcp from RulesyncMcp in global mode + const newKiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + global: true, + }); + + // After round-trip, should be back to Kilo format + expect(newKiloMcp.getJson()).toEqual({ + mcp: { + "global-workflow-server": { + type: "local", + command: ["node", "global-server.js"], + environment: {}, + enabled: true, + }, + }, + }); + expect(newKiloMcp.getFilePath()).toBe(join(testDir, ".config", "kilo", "kilo.json")); + }); + + it("should round-trip enabledTools/disabledTools through Kilo format", async () => { + // Start with Kilo format: mcp + tools map + const originalJsonData = { + mcp: { + "my-server": { + type: "local", + command: ["node", "server.js"], + enabled: true, + }, + }, + tools: { + "my-server_search": true, + "my-server_list": false, + }, + }; + await writeFileContent(join(testDir, "kilo.json"), JSON.stringify(originalJsonData, null, 2)); + + // Step 1: Load from file + const originalKiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + }); + + // Step 2: Convert to RulesyncMcp + const rulesyncMcp = originalKiloMcp.toRulesyncMcp(); + + // Verify RulesyncMcp has enabledTools/disabledTools + const rulesyncJson = JSON.parse(rulesyncMcp.getFileContent()); + expect(rulesyncJson.mcpServers["my-server"]).toEqual({ + type: "stdio", + command: "node", + args: ["server.js"], + enabledTools: ["search"], + disabledTools: ["list"], + }); + + // Step 3: Convert back to Kilo format + const newKiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + }); + + // After round-trip, should be back to Kilo format with tools map + expect(newKiloMcp.getJson()).toEqual({ + mcp: { + "my-server": { + type: "local", + command: ["node", "server.js"], + enabled: true, + }, + }, + tools: { + "my-server_search": true, + "my-server_list": false, + }, + }); + }); + + it("should round-trip enabledTools/disabledTools from rulesync format", async () => { + // Start with rulesync format + const rulesyncData = { + mcpServers: { + "server-a": { + command: "node", + args: ["a.js"], + enabledTools: ["search", "read"], + disabledTools: ["write"], + }, + "server-b": { + type: "sse", + url: "https://example.com/mcp", + disabledTools: ["delete"], + }, + }, + }; + const rulesyncMcp = new RulesyncMcp({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify(rulesyncData), + }); + + // Step 1: Convert to Kilo + const kiloMcp = await KiloMcp.fromRulesyncMcp({ + baseDir: testDir, + rulesyncMcp, + }); + + // Verify Kilo format has tools map + expect(kiloMcp.getJson().tools).toEqual({ + "server-a_search": true, + "server-a_read": true, + "server-a_write": false, + "server-b_delete": false, + }); + + // Step 2: Convert back to rulesync + const backToRulesync = kiloMcp.toRulesyncMcp(); + const backJson = JSON.parse(backToRulesync.getFileContent()); + + expect(backJson.mcpServers["server-a"].enabledTools).toEqual(["search", "read"]); + expect(backJson.mcpServers["server-a"].disabledTools).toEqual(["write"]); + expect(backJson.mcpServers["server-b"].disabledTools).toEqual(["delete"]); + }); + }); + + describe("error handling", () => { + it("should handle missing files by returning default empty mcp", async () => { + // When both jsonc and json are missing, should return default mcp + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + }); + + expect(kiloMcp.getJson().mcp).toEqual({}); + }); + + it("should handle missing files in global mode by returning default empty mcp", async () => { + // When global files don't exist, should return default mcp + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + global: true, + }); + + expect(kiloMcp.getJson().mcp).toEqual({}); + }); + + it("should handle null mcp in existing file", async () => { + const jsonData = { + mcp: null, + }; + await writeFileContent(join(testDir, "kilo.json"), JSON.stringify(jsonData)); + + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + }); + + expect(kiloMcp.getJson().mcp).toEqual({}); + }); + + it("should handle undefined mcp in existing file", async () => { + const jsonData = { + otherProperty: "value", + }; + await writeFileContent(join(testDir, "kilo.json"), JSON.stringify(jsonData)); + + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + }); + + expect(kiloMcp.getJson().mcp).toEqual({}); + expect((kiloMcp.getJson() as any).otherProperty).toBe("value"); + }); + + it("should handle empty file", async () => { + await writeFileContent(join(testDir, "kilo.json"), ""); + + await expect( + KiloMcp.fromFile({ + baseDir: testDir, + }), + ).rejects.toThrow(); + }); + + it("should handle file with only whitespace", async () => { + await writeFileContent(join(testDir, "kilo.json"), " \n\t "); + + await expect( + KiloMcp.fromFile({ + baseDir: testDir, + }), + ).rejects.toThrow(); + }); + + it("should read kilo.jsonc file with comments", async () => { + const jsoncContent = `{ + // This is a comment + "mcp": { + "exampleServer": { + "type": "local", + "command": ["npx", "example"], + "enabled": true + } + } +}`; + await writeFileContent(join(testDir, "kilo.jsonc"), jsoncContent); + + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + }); + + const exampleServer = kiloMcp.getJson().mcp?.exampleServer; + expect(exampleServer).toBeDefined(); + if (exampleServer?.type === "local") { + expect(exampleServer.type).toBe("local"); + expect((exampleServer as any).command).toEqual(["npx", "example"]); + } + }); + + it("should prefer kilo.jsonc over kilo.json when both exist", async () => { + const jsonContent = { + mcp: { + fromJson: { + type: "local", + command: ["json"], + enabled: true, + }, + }, + }; + const jsoncContent = `{ + "mcp": { + "fromJsonc": { + "type": "local", + "command": ["jsonc"], + "enabled": true + } + } +}`; + await writeFileContent(join(testDir, "kilo.json"), JSON.stringify(jsonContent)); + await writeFileContent(join(testDir, "kilo.jsonc"), jsoncContent); + + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + }); + + expect(kiloMcp.getJson().mcp?.fromJsonc).toBeDefined(); + expect(kiloMcp.getJson().mcp?.fromJson).toBeUndefined(); + }); + + it("should fall back to kilo.json when kilo.jsonc does not exist", async () => { + const jsonContent = { + mcp: { + fromJson: { + type: "local", + command: ["json"], + enabled: true, + }, + }, + }; + await writeFileContent(join(testDir, "kilo.json"), JSON.stringify(jsonContent)); + + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + }); + + expect(kiloMcp.getJson().mcp?.fromJson).toBeDefined(); + }); + + it("should read kilo.jsonc in global mode", async () => { + const jsoncContent = `{ + "mcp": { + "globalServer": { + "type": "local", + "command": ["npx", "global"], + "enabled": true + } + } +}`; + await writeFileContent(join(testDir, ".config", "kilo", "kilo.jsonc"), jsoncContent); + + const kiloMcp = await KiloMcp.fromFile({ + baseDir: testDir, + global: true, + }); + + expect(kiloMcp.getJson().mcp?.globalServer).toBeDefined(); + }); + + it("should prefer kilo.jsonc over kilo.json in global mode", async () => { + const jsonContent = { + mcp: { + fromJson: { + type: "local", + command: ["json"], + enabled: true, + }, + }, + }; + const jsoncContent = `{ + "mcp": { + "fromJsonc": { + "type": "local", + "command": ["jsonc"], + "enabled": true + } + } +}`; + await writeFileContent( + join(testDir, ".config", "kilo", "kilo.json"), + JSON.stringify(jsonContent), + ); + await writeFileContent(join(testDir, ".config", "kilo", "kilo.jsonc"), jsoncContent); + + const kiloMcp = await KiloMcp.fromFile({ baseDir: testDir, - relativeDirPath: ".kilocode", - relativeFilePath: "obsolete.json", + global: true, }); - expect(kiloMcp.isDeletable()).toBe(true); - expect(kiloMcp.getFileContent()).toBe("{}"); + expect(kiloMcp.getJson().mcp?.fromJsonc).toBeDefined(); + expect(kiloMcp.getJson().mcp?.fromJson).toBeUndefined(); }); }); }); diff --git a/src/features/mcp/kilo-mcp.ts b/src/features/mcp/kilo-mcp.ts index 17d183772..3070a8eca 100644 --- a/src/features/mcp/kilo-mcp.ts +++ b/src/features/mcp/kilo-mcp.ts @@ -1,6 +1,10 @@ import { join } from "node:path"; +import { parse as parseJsonc } from "jsonc-parser"; +import { z } from "zod/mini"; + import { ValidationResult } from "../../types/ai-file.js"; +import { McpServers } from "../../types/mcp.js"; import { readFileContentOrNull } from "../../utils/file.js"; import { RulesyncMcp } from "./rulesync-mcp.js"; import { @@ -12,67 +16,308 @@ import { ToolMcpSettablePaths, } from "./tool-mcp.js"; +// Kilo MCP server schemas +// Kilo uses "local"/"remote" instead of "stdio"/"sse"/"http", +// "environment" instead of "env", and "enabled" instead of "disabled" + +// Kilo native format for local servers +const KiloMcpLocalServerSchema = z.object({ + type: z.literal("local"), + command: z.array(z.string()), + environment: z.optional(z.record(z.string(), z.string())), + enabled: z._default(z.boolean(), true), + cwd: z.optional(z.string()), +}); + +// Kilo native format for remote servers +const KiloMcpRemoteServerSchema = z.object({ + type: z.literal("remote"), + url: z.string(), + headers: z.optional(z.record(z.string(), z.string())), + enabled: z._default(z.boolean(), true), +}); + +// Kilo MCP server schema (local or remote) +const KiloMcpServerSchema = z.union([KiloMcpLocalServerSchema, KiloMcpRemoteServerSchema]); + +// Use looseObject to allow additional properties like model, provider, agent, +// etc. +const KiloConfigSchema = z.looseObject({ + $schema: z.optional(z.string()), + mcp: z.optional(z.record(z.string(), KiloMcpServerSchema)), + tools: z.optional(z.record(z.string(), z.boolean())), +}); + +type KiloConfig = z.infer; +type KiloMcpServer = z.infer; + +/** + * Convert Kilo native format back to standard MCP format + * - type: "local" -> "stdio", "remote" -> "sse" + * - command (array) -> command (first element) + args (rest) + * - environment -> env + * - enabled -> disabled (inverted) + * - top-level tools map -> per-server enabledTools/disabledTools (strip server prefix) + */ +function convertFromKiloFormat( + kiloMcp: Record, + tools?: Record, +): McpServers { + return Object.fromEntries( + Object.entries(kiloMcp).map(([serverName, serverConfig]) => { + // Extract enabledTools and disabledTools from top-level tools map + const enabledTools: string[] = []; + const disabledTools: string[] = []; + const prefix = `${serverName}_`; + + if (tools) { + for (const [toolName, enabled] of Object.entries(tools)) { + if (toolName.startsWith(prefix)) { + const toolSuffix = toolName.slice(prefix.length); + if (enabled) { + enabledTools.push(toolSuffix); + } else { + disabledTools.push(toolSuffix); + } + } + } + } + + if (serverConfig.type === "remote") { + return [ + serverName, + { + type: "sse" as const, + url: serverConfig.url, + ...(serverConfig.enabled === false && { disabled: true }), + ...(serverConfig.headers && { headers: serverConfig.headers }), + ...(enabledTools.length > 0 && { enabledTools }), + ...(disabledTools.length > 0 && { disabledTools }), + }, + ]; + } + + // local server -> stdio + const [command, ...args] = serverConfig.command; + if (!command) { + throw new Error(`Server "${serverName}" has an empty command array`); + } + return [ + serverName, + { + type: "stdio" as const, + command, + ...(args.length > 0 && { args }), + ...(serverConfig.enabled === false && { disabled: true }), + ...(serverConfig.environment && { env: serverConfig.environment }), + ...(serverConfig.cwd && { cwd: serverConfig.cwd }), + ...(enabledTools.length > 0 && { enabledTools }), + ...(disabledTools.length > 0 && { disabledTools }), + }, + ]; + }), + ); +} + +/** + * Convert standard MCP format to Kilo native format + * - type: "stdio" -> "local", "sse"/"http" -> "remote" + * - command + args -> command (merged array) + * - env -> environment + * - disabled -> enabled (inverted) + * - enabledTools/disabledTools -> top-level tools map (with server name prefix) + */ +function convertToKiloFormat(mcpServers: McpServers): { + mcp: Record; + tools: Record; +} { + const tools: Record = {}; + + const mcp = Object.fromEntries( + Object.entries(mcpServers).map(([serverName, serverConfig]) => { + const isRemote = + serverConfig.type === "sse" || serverConfig.type === "http" || serverConfig.url; + + // Collect enabledTools/disabledTools into the top-level tools map + if (serverConfig.enabledTools) { + for (const tool of serverConfig.enabledTools) { + tools[`${serverName}_${tool}`] = true; + } + } + if (serverConfig.disabledTools) { + for (const tool of serverConfig.disabledTools) { + tools[`${serverName}_${tool}`] = false; + } + } + + if (isRemote) { + const remoteServer: KiloMcpServer = { + type: "remote", + url: serverConfig.url ?? serverConfig.httpUrl ?? "", + enabled: serverConfig.disabled !== undefined ? !serverConfig.disabled : true, + ...(serverConfig.headers && { headers: serverConfig.headers }), + }; + return [serverName, remoteServer]; + } + + // Build command array: merge command and args + const commandArray: string[] = []; + if (serverConfig.command) { + if (Array.isArray(serverConfig.command)) { + commandArray.push(...serverConfig.command); + } else { + commandArray.push(serverConfig.command); + } + } + if (serverConfig.args) { + commandArray.push(...serverConfig.args); + } + + const localServer: KiloMcpServer = { + type: "local", + command: commandArray, + enabled: serverConfig.disabled !== undefined ? !serverConfig.disabled : true, + ...(serverConfig.env && { environment: serverConfig.env }), + ...(serverConfig.cwd && { cwd: serverConfig.cwd }), + }; + return [serverName, localServer]; + }), + ); + + return { mcp, tools }; +} + export class KiloMcp extends ToolMcp { - private readonly json: Record; + private readonly json: KiloConfig; constructor(params: ToolMcpParams) { super(params); - this.json = JSON.parse(this.fileContent || "{}"); + this.json = KiloConfigSchema.parse(parseJsonc(this.fileContent || "{}")); } - getJson(): Record { + getJson(): KiloConfig { return this.json; } - static getSettablePaths(): ToolMcpSettablePaths { + /** + * kilo.json may contain other settings, so it should not be deleted. + */ + override isDeletable(): boolean { + return false; + } + + static getSettablePaths({ global }: { global?: boolean } = {}): ToolMcpSettablePaths { + if (global) { + return { + relativeDirPath: join(".config", "kilo"), + relativeFilePath: "kilo.json", + }; + } return { - relativeDirPath: ".kilocode", - relativeFilePath: "mcp.json", + relativeDirPath: ".", + relativeFilePath: "kilo.json", }; } static async fromFile({ baseDir = process.cwd(), validate = true, + global = false, }: ToolMcpFromFileParams): Promise { - const paths = this.getSettablePaths(); - const fileContent = - (await readFileContentOrNull(join(baseDir, paths.relativeDirPath, paths.relativeFilePath))) ?? - '{"mcpServers":{}}'; + const basePaths = this.getSettablePaths({ global }); + const jsonDir = join(baseDir, basePaths.relativeDirPath); + + let fileContent: string | null = null; + let relativeFilePath = "kilo.jsonc"; + + const jsoncPath = join(jsonDir, "kilo.jsonc"); + const jsonPath = join(jsonDir, "kilo.json"); + + // Always try JSONC first (preferred format), then fall back to JSON + fileContent = await readFileContentOrNull(jsoncPath); + if (!fileContent) { + fileContent = await readFileContentOrNull(jsonPath); + if (fileContent) { + relativeFilePath = "kilo.json"; + } + } + + const fileContentToUse = fileContent ?? '{"mcp":{}}'; + const json = parseJsonc(fileContentToUse); + const newJson = { ...json, mcp: json.mcp ?? {} }; return new KiloMcp({ baseDir, - relativeDirPath: paths.relativeDirPath, - relativeFilePath: paths.relativeFilePath, - fileContent, + relativeDirPath: basePaths.relativeDirPath, + relativeFilePath, + fileContent: JSON.stringify(newJson, null, 2), validate, }); } - static fromRulesyncMcp({ + static async fromRulesyncMcp({ baseDir = process.cwd(), rulesyncMcp, validate = true, - }: ToolMcpFromRulesyncMcpParams): KiloMcp { - const paths = this.getSettablePaths(); - const fileContent = JSON.stringify({ mcpServers: rulesyncMcp.getMcpServers() }, null, 2); + global = false, + }: ToolMcpFromRulesyncMcpParams): Promise { + const basePaths = this.getSettablePaths({ global }); + const jsonDir = join(baseDir, basePaths.relativeDirPath); + + let fileContent: string | null = null; + let relativeFilePath = "kilo.jsonc"; + + const jsoncPath = join(jsonDir, "kilo.jsonc"); + const jsonPath = join(jsonDir, "kilo.json"); + + // Try JSONC first (preferred format), then fall back to JSON + fileContent = await readFileContentOrNull(jsoncPath); + if (!fileContent) { + fileContent = await readFileContentOrNull(jsonPath); + if (fileContent) { + relativeFilePath = "kilo.json"; + } + } + + // If neither exists, default to jsonc and empty mcp object + if (!fileContent) { + fileContent = JSON.stringify({ mcp: {} }, null, 2); + } + + const json = parseJsonc(fileContent); + const { mcp: convertedMcp, tools: mcpTools } = convertToKiloFormat(rulesyncMcp.getMcpServers()); + + const { tools: _existingTools, ...jsonWithoutTools } = json; + const newJson = { + ...jsonWithoutTools, + mcp: convertedMcp, + ...(Object.keys(mcpTools).length > 0 && { tools: mcpTools }), + }; return new KiloMcp({ baseDir, - relativeDirPath: paths.relativeDirPath, - relativeFilePath: paths.relativeFilePath, - fileContent, + relativeDirPath: basePaths.relativeDirPath, + relativeFilePath, + fileContent: JSON.stringify(newJson, null, 2), validate, }); } toRulesyncMcp(): RulesyncMcp { + const convertedMcpServers = convertFromKiloFormat(this.json.mcp ?? {}, this.json.tools); return this.toRulesyncMcpDefault({ - fileContent: JSON.stringify({ mcpServers: this.json.mcpServers ?? {} }, null, 2), + fileContent: JSON.stringify({ mcpServers: convertedMcpServers }, null, 2), }); } validate(): ValidationResult { + // Parse fileContent directly since this.json may not be initialized yet + // when validate() is called from parent constructor + const json = JSON.parse(this.fileContent || "{}"); + const result = KiloConfigSchema.safeParse(json); + if (!result.success) { + return { success: false, error: result.error }; + } return { success: true, error: null }; } @@ -80,6 +325,7 @@ export class KiloMcp extends ToolMcp { baseDir = process.cwd(), relativeDirPath, relativeFilePath, + global = false, }: ToolMcpForDeletionParams): KiloMcp { return new KiloMcp({ baseDir, @@ -87,6 +333,7 @@ export class KiloMcp extends ToolMcp { relativeFilePath, fileContent: "{}", validate: false, + global, }); } } diff --git a/src/features/rules/kilo-rule.test.ts b/src/features/rules/kilo-rule.test.ts index dc0c7527f..ea3fbccb2 100644 --- a/src/features/rules/kilo-rule.test.ts +++ b/src/features/rules/kilo-rule.test.ts @@ -2,7 +2,11 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { RULESYNC_RULES_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { + RULESYNC_OVERVIEW_FILE_NAME, + RULESYNC_RELATIVE_DIR_PATH, + RULESYNC_RULES_RELATIVE_DIR_PATH, +} from "../../constants/rulesync-paths.js"; import { setupTestDirectory } from "../../test-utils/test-directories.js"; import { ensureDir, writeFileContent } from "../../utils/file.js"; import { KiloRule } from "./kilo-rule.js"; @@ -25,108 +29,200 @@ describe("KiloRule", () => { describe("constructor", () => { it("should create instance with default parameters", () => { const kiloRule = new KiloRule({ - relativeDirPath: ".kilocode/rules", - relativeFilePath: "test-rule.md", - fileContent: "# Test Rule\n\nThis is a test rule.", + relativeDirPath: ".kilo/rules", + relativeFilePath: "test-memory.md", + fileContent: "# Test Memory\n\nThis is a test memory.", }); expect(kiloRule).toBeInstanceOf(KiloRule); - expect(kiloRule.getRelativeDirPath()).toBe(".kilocode/rules"); - expect(kiloRule.getRelativeFilePath()).toBe("test-rule.md"); - expect(kiloRule.getFileContent()).toBe("# Test Rule\n\nThis is a test rule."); + expect(kiloRule.getRelativeDirPath()).toBe(".kilo/rules"); + expect(kiloRule.getRelativeFilePath()).toBe("test-memory.md"); + expect(kiloRule.getFileContent()).toBe("# Test Memory\n\nThis is a test memory."); }); it("should create instance with custom baseDir", () => { const kiloRule = new KiloRule({ baseDir: "/custom/path", - relativeDirPath: ".kilocode/rules", - relativeFilePath: "custom-rule.md", - fileContent: "# Custom Rule", + relativeDirPath: ".kilo/rules", + relativeFilePath: "custom-memory.md", + fileContent: "# Custom Memory", }); - expect(kiloRule.getFilePath()).toBe("/custom/path/.kilocode/rules/custom-rule.md"); + expect(kiloRule.getFilePath()).toBe("/custom/path/.kilo/rules/custom-memory.md"); }); - it("should create instance with validation enabled", () => { + it("should create instance for root AGENTS.md file", () => { const kiloRule = new KiloRule({ - relativeDirPath: ".kilocode/rules", - relativeFilePath: "validated-rule.md", - fileContent: "# Validated Rule\n\nThis is a validated rule.", - validate: true, + relativeDirPath: ".", + relativeFilePath: "AGENTS.md", + fileContent: "# Project Overview\n\nThis is the main Kilo agent memory.", + root: true, }); - expect(kiloRule).toBeInstanceOf(KiloRule); + expect(kiloRule.getRelativeDirPath()).toBe("."); + expect(kiloRule.getRelativeFilePath()).toBe("AGENTS.md"); + expect(kiloRule.getFileContent()).toBe( + "# Project Overview\n\nThis is the main Kilo agent memory.", + ); + expect(kiloRule.isRoot()).toBe(true); }); - it("should create instance with validation disabled", () => { + it("should validate content by default", () => { + expect(() => { + const _instance = new KiloRule({ + relativeDirPath: ".kilo/rules", + relativeFilePath: "test.md", + fileContent: "", // empty content should be valid since validate always returns success + }); + }).not.toThrow(); + }); + + it("should skip validation when requested", () => { + expect(() => { + const _instance = new KiloRule({ + relativeDirPath: ".kilo/rules", + relativeFilePath: "test.md", + fileContent: "", + validate: false, + }); + }).not.toThrow(); + }); + + it("should handle root rule parameter", () => { const kiloRule = new KiloRule({ - relativeDirPath: ".kilocode/rules", - relativeFilePath: "unvalidated-rule.md", - fileContent: "# Unvalidated Rule", - validate: false, + relativeDirPath: ".", + relativeFilePath: "AGENTS.md", + fileContent: "# Root Memory", + root: true, }); - expect(kiloRule).toBeInstanceOf(KiloRule); + expect(kiloRule.getFileContent()).toBe("# Root Memory"); + expect(kiloRule.isRoot()).toBe(true); }); }); - describe("toRulesyncRule", () => { - it("should convert KiloRule to RulesyncRule", () => { - const kiloRule = new KiloRule({ - relativeDirPath: ".kilocode/rules", - relativeFilePath: "conversion-test.md", - fileContent: "# Conversion Test\n\nThis rule will be converted.", + describe("fromFile", () => { + it("should create instance from root AGENTS.md file", async () => { + // Setup test file - for root, the file should be directly at baseDir/AGENTS.md + const testContent = "# Kilo Project\n\nProject overview and agent instructions."; + await writeFileContent(join(testDir, "AGENTS.md"), testContent); + + const kiloRule = await KiloRule.fromFile({ + baseDir: testDir, + relativeFilePath: "AGENTS.md", }); - const rulesyncRule = kiloRule.toRulesyncRule(); + expect(kiloRule.getRelativeDirPath()).toBe("."); + expect(kiloRule.getRelativeFilePath()).toBe("AGENTS.md"); + expect(kiloRule.getFileContent()).toBe(testContent); + expect(kiloRule.getFilePath()).toBe(join(testDir, "AGENTS.md")); + expect(kiloRule.isRoot()).toBe(true); + }); - expect(rulesyncRule).toBeInstanceOf(RulesyncRule); - expect(rulesyncRule.getFileContent()).toContain("# Conversion Test"); - expect(rulesyncRule.getFileContent()).toContain("This rule will be converted."); + it("should create instance from memory file", async () => { + // Setup test file + const memoriesDir = join(testDir, ".kilo/rules"); + await ensureDir(memoriesDir); + const testContent = "# Memory Rule\n\nContent from memory file."; + await writeFileContent(join(memoriesDir, "memory-test.md"), testContent); + + const kiloRule = await KiloRule.fromFile({ + baseDir: testDir, + relativeFilePath: "memory-test.md", + }); + + expect(kiloRule.getRelativeDirPath()).toBe(".kilo/rules"); + expect(kiloRule.getRelativeFilePath()).toBe("memory-test.md"); + expect(kiloRule.getFileContent()).toBe(testContent); + expect(kiloRule.getFilePath()).toBe(join(testDir, ".kilo/rules/memory-test.md")); + expect(kiloRule.isRoot()).toBe(false); }); - it("should preserve file path information in conversion", () => { - const kiloRule = new KiloRule({ + it("should use default baseDir when not provided", async () => { + // Setup test file in test directory - process.cwd() is mocked to return testDir + const testContent = "# Default BaseDir Test"; + await writeFileContent(join(testDir, "AGENTS.md"), testContent); + + const kiloRule = await KiloRule.fromFile({ + relativeFilePath: "AGENTS.md", + }); + + expect(kiloRule.getRelativeDirPath()).toBe("."); + expect(kiloRule.getRelativeFilePath()).toBe("AGENTS.md"); + expect(kiloRule.getFileContent()).toBe(testContent); + }); + + it("should handle validation parameter", async () => { + const testContent = "# Validation Test"; + await writeFileContent(join(testDir, "AGENTS.md"), testContent); + + const kiloRuleWithValidation = await KiloRule.fromFile({ baseDir: testDir, - relativeDirPath: ".kilocode/rules", - relativeFilePath: "path-test.md", - fileContent: "# Path Test", + relativeFilePath: "AGENTS.md", + validate: true, }); - const rulesyncRule = kiloRule.toRulesyncRule(); + const kiloRuleWithoutValidation = await KiloRule.fromFile({ + baseDir: testDir, + relativeFilePath: "AGENTS.md", + validate: false, + }); - expect(rulesyncRule.getRelativeFilePath()).toBe("path-test.md"); + expect(kiloRuleWithValidation.getFileContent()).toBe(testContent); + expect(kiloRuleWithoutValidation.getFileContent()).toBe(testContent); }); - it("should convert back to a RulesyncRule with correct frontmatter", () => { - const kiloRule = new KiloRule({ + it("should throw error when file does not exist", async () => { + await expect( + KiloRule.fromFile({ + baseDir: testDir, + relativeFilePath: "nonexistent.md", + }), + ).rejects.toThrow(); + }); + + it("should detect root vs non-root files correctly", async () => { + // Setup root AGENTS.md file and memory files + const memoriesDir = join(testDir, ".kilo/rules"); + await ensureDir(memoriesDir); + + const rootContent = "# Root Project Overview"; + const memoryContent = "# Memory Rule"; + + // Root file goes directly in baseDir + await writeFileContent(join(testDir, "AGENTS.md"), rootContent); + // Memory file goes in .kilo/rules + await writeFileContent(join(memoriesDir, "memory.md"), memoryContent); + + const rootRule = await KiloRule.fromFile({ baseDir: testDir, - relativeDirPath: ".kilocode/rules", - relativeFilePath: "team.md", - fileContent: "# Team Rules", + relativeFilePath: "AGENTS.md", }); - const rulesyncRule = kiloRule.toRulesyncRule(); + const memoryRule = await KiloRule.fromFile({ + baseDir: testDir, + relativeFilePath: "memory.md", + }); - expect(rulesyncRule.getRelativeDirPath()).toBe(RULESYNC_RULES_RELATIVE_DIR_PATH); - expect(rulesyncRule.getRelativeFilePath()).toBe("team.md"); - expect(rulesyncRule.getBody()).toBe("# Team Rules"); - expect(rulesyncRule.getFrontmatter().targets).toEqual(["*"]); + expect(rootRule.isRoot()).toBe(true); + expect(rootRule.getRelativeDirPath()).toBe("."); + expect(memoryRule.isRoot()).toBe(false); + expect(memoryRule.getRelativeDirPath()).toBe(".kilo/rules"); }); }); describe("fromRulesyncRule", () => { - it("should create KiloRule from RulesyncRule with default parameters", () => { + it("should create instance from RulesyncRule for root rule", () => { const rulesyncRule = new RulesyncRule({ - relativeDirPath: ".", - relativeFilePath: "source-rule.md", + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "test-rule.md", frontmatter: { - description: "Source rule description", + root: true, targets: ["*"], - root: false, + description: "Test root rule", globs: [], }, - body: "# Source Rule\n\nThis is from RulesyncRule.", + body: "# Test RulesyncRule\n\nContent from rulesync.", }); const kiloRule = KiloRule.fromRulesyncRule({ @@ -134,22 +230,49 @@ describe("KiloRule", () => { }); expect(kiloRule).toBeInstanceOf(KiloRule); - expect(kiloRule.getRelativeDirPath()).toBe(".kilocode/rules"); - expect(kiloRule.getRelativeFilePath()).toBe("source-rule.md"); - expect(kiloRule.getFileContent()).toContain("# Source Rule\n\nThis is from RulesyncRule."); + expect(kiloRule.getRelativeDirPath()).toBe("."); + expect(kiloRule.getRelativeFilePath()).toBe("AGENTS.md"); + expect(kiloRule.getFileContent()).toContain("# Test RulesyncRule\n\nContent from rulesync."); + expect(kiloRule.isRoot()).toBe(true); }); - it("should create KiloRule from RulesyncRule with custom baseDir", () => { + it("should create instance from RulesyncRule for non-root rule", () => { const rulesyncRule = new RulesyncRule({ - relativeDirPath: ".", - relativeFilePath: "custom-base-rule.md", + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "detail-rule.md", frontmatter: { - description: "Custom base rule description", + root: false, targets: ["*"], + description: "Test detail rule", + globs: [], + }, + body: "# Detail RulesyncRule\n\nContent from detail rulesync.", + }); + + const kiloRule = KiloRule.fromRulesyncRule({ + rulesyncRule, + }); + + expect(kiloRule).toBeInstanceOf(KiloRule); + expect(kiloRule.getRelativeDirPath()).toBe(".kilo/rules"); + expect(kiloRule.getRelativeFilePath()).toBe("detail-rule.md"); + expect(kiloRule.getFileContent()).toContain( + "# Detail RulesyncRule\n\nContent from detail rulesync.", + ); + expect(kiloRule.isRoot()).toBe(false); + }); + + it("should use custom baseDir", () => { + const rulesyncRule = new RulesyncRule({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "custom-base.md", + frontmatter: { root: false, + targets: ["*"], + description: "", globs: [], }, - body: "# Custom Base Rule", + body: "# Custom Base Directory", }); const kiloRule = KiloRule.fromRulesyncRule({ @@ -157,58 +280,220 @@ describe("KiloRule", () => { rulesyncRule, }); - expect(kiloRule.getFilePath()).toBe("/custom/base/.kilocode/rules/custom-base-rule.md"); + expect(kiloRule.getFilePath()).toBe("/custom/base/.kilo/rules/custom-base.md"); }); - it("should create KiloRule from RulesyncRule with validation enabled", () => { + it("should handle validation parameter", () => { const rulesyncRule = new RulesyncRule({ - relativeDirPath: ".", - relativeFilePath: "validated-conversion.md", + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "validation.md", frontmatter: { - description: "Validated conversion description", - targets: ["*"], root: false, + targets: ["*"], + description: "", globs: [], }, - body: "# Validated Conversion", + body: "# Validation Test", }); - const kiloRule = KiloRule.fromRulesyncRule({ + const kiloRuleWithValidation = KiloRule.fromRulesyncRule({ rulesyncRule, validate: true, }); - expect(kiloRule).toBeInstanceOf(KiloRule); + const kiloRuleWithoutValidation = KiloRule.fromRulesyncRule({ + rulesyncRule, + validate: false, + }); + + expect(kiloRuleWithValidation.getFileContent()).toContain("# Validation Test"); + expect(kiloRuleWithoutValidation.getFileContent()).toContain("# Validation Test"); }); - it("should create KiloRule from RulesyncRule with validation disabled", () => { + it("should handle subprojectPath from agentsmd field", () => { const rulesyncRule = new RulesyncRule({ - relativeDirPath: ".", - relativeFilePath: "unvalidated-conversion.md", + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", frontmatter: { - description: "Unvalidated conversion description", - targets: ["*"], root: false, - globs: [], + targets: ["kilo"], + agentsmd: { + subprojectPath: "packages/my-app", + }, }, - body: "# Unvalidated Conversion", + body: "# Subproject Kilo\n\nContent for subproject.", }); const kiloRule = KiloRule.fromRulesyncRule({ + baseDir: testDir, rulesyncRule, - validate: false, }); - expect(kiloRule).toBeInstanceOf(KiloRule); + expect(kiloRule.getFileContent()).toBe("# Subproject Kilo\n\nContent for subproject."); + expect(kiloRule.getRelativeDirPath()).toBe("packages/my-app"); + expect(kiloRule.getRelativeFilePath()).toBe("AGENTS.md"); + }); + + it("should ignore subprojectPath for root rules", () => { + const rulesyncRule = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { + root: true, + targets: ["kilo"], + agentsmd: { + subprojectPath: "packages/my-app", // Should be ignored + }, + }, + body: "# Root Kilo\n\nRoot content.", + }); + + const kiloRule = KiloRule.fromRulesyncRule({ + baseDir: testDir, + rulesyncRule, + }); + + expect(kiloRule.getFileContent()).toBe("# Root Kilo\n\nRoot content."); + expect(kiloRule.getRelativeDirPath()).toBe("."); + expect(kiloRule.getRelativeFilePath()).toBe("AGENTS.md"); + }); + + it("should handle empty subprojectPath", () => { + const rulesyncRule = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { + root: false, + targets: ["kilo"], + agentsmd: { + subprojectPath: "", + }, + }, + body: "# Empty Subproject Kilo\n\nContent.", + }); + + const kiloRule = KiloRule.fromRulesyncRule({ + baseDir: testDir, + rulesyncRule, + }); + + expect(kiloRule.getFileContent()).toBe("# Empty Subproject Kilo\n\nContent."); + expect(kiloRule.getRelativeDirPath()).toBe(".kilo/rules"); + expect(kiloRule.getRelativeFilePath()).toBe("test.md"); + }); + + it("should handle complex nested subprojectPath", () => { + const rulesyncRule = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "nested.md", + frontmatter: { + root: false, + targets: ["kilo"], + agentsmd: { + subprojectPath: "packages/apps/my-app/src", + }, + }, + body: "# Nested Subproject Kilo\n\nDeeply nested content.", + }); + + const kiloRule = KiloRule.fromRulesyncRule({ + baseDir: testDir, + rulesyncRule, + }); + + expect(kiloRule.getFileContent()).toBe("# Nested Subproject Kilo\n\nDeeply nested content."); + expect(kiloRule.getRelativeDirPath()).toBe("packages/apps/my-app/src"); + expect(kiloRule.getRelativeFilePath()).toBe("AGENTS.md"); + }); + + it("should handle undefined agentsmd field", () => { + const rulesyncRule = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "test.md", + frontmatter: { + root: false, + targets: ["kilo"], + }, + body: "# No agentsmd\n\nContent without agentsmd.", + }); + + const kiloRule = KiloRule.fromRulesyncRule({ + baseDir: testDir, + rulesyncRule, + }); + + expect(kiloRule.getFileContent()).toBe("# No agentsmd\n\nContent without agentsmd."); + }); + }); + + describe("toRulesyncRule", () => { + it("should convert KiloRule to RulesyncRule for root rule", () => { + const kiloRule = new KiloRule({ + baseDir: testDir, + relativeDirPath: ".", + relativeFilePath: "AGENTS.md", + fileContent: "# Convert Test\n\nThis will be converted.", + root: true, + }); + + const rulesyncRule = kiloRule.toRulesyncRule(); + + expect(rulesyncRule).toBeInstanceOf(RulesyncRule); + expect(rulesyncRule.getRelativeDirPath()).toBe(RULESYNC_RULES_RELATIVE_DIR_PATH); + expect(rulesyncRule.getRelativeFilePath()).toBe(RULESYNC_OVERVIEW_FILE_NAME); + expect(rulesyncRule.getFileContent()).toContain("# Convert Test\n\nThis will be converted."); + }); + + it("should convert KiloRule to RulesyncRule for memory rule", () => { + const kiloRule = new KiloRule({ + baseDir: testDir, + relativeDirPath: ".kilo/rules", + relativeFilePath: "memory-convert.md", + fileContent: "# Memory Convert Test\n\nThis memory will be converted.", + root: false, + }); + + const rulesyncRule = kiloRule.toRulesyncRule(); + + expect(rulesyncRule).toBeInstanceOf(RulesyncRule); + expect(rulesyncRule.getRelativeDirPath()).toBe(RULESYNC_RULES_RELATIVE_DIR_PATH); + expect(rulesyncRule.getRelativeFilePath()).toBe("memory-convert.md"); + expect(rulesyncRule.getFileContent()).toContain( + "# Memory Convert Test\n\nThis memory will be converted.", + ); + }); + + it("should preserve metadata in conversion", () => { + const kiloRule = new KiloRule({ + baseDir: "/test/path", + relativeDirPath: ".", + relativeFilePath: "AGENTS.md", + fileContent: "# Metadata Test\n\nWith metadata preserved.", + root: true, + }); + + const rulesyncRule = kiloRule.toRulesyncRule(); + + expect(rulesyncRule.getFilePath()).toBe( + join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME), + ); + expect(rulesyncRule.getFileContent()).toContain( + "# Metadata Test\n\nWith metadata preserved.", + ); }); }); describe("validate", () => { - it("should always return successful validation", () => { + it("should always return success", () => { const kiloRule = new KiloRule({ - relativeDirPath: ".kilocode/rules", - relativeFilePath: "validation-test.md", - fileContent: "# Validation Test", + relativeDirPath: ".", + relativeFilePath: "AGENTS.md", + fileContent: "# Any content is valid", }); const result = kiloRule.validate(); @@ -217,9 +502,9 @@ describe("KiloRule", () => { expect(result.error).toBeNull(); }); - it("should return successful validation even with empty content", () => { + it("should return success for empty content", () => { const kiloRule = new KiloRule({ - relativeDirPath: ".kilocode/rules", + relativeDirPath: ".kilo/rules", relativeFilePath: "empty.md", fileContent: "", }); @@ -230,199 +515,353 @@ describe("KiloRule", () => { expect(result.error).toBeNull(); }); - it("should return successful validation with complex content", () => { - const complexContent = `# Complex Rule - ---- -description: This is a complex rule with frontmatter ---- + it("should return success for any content format", () => { + const contents = [ + "# Markdown content", + "Plain text content", + "---\nfrontmatter: true\n---\nContent with frontmatter", + "/* Code comments */", + "Invalid markdown ### ###", + "Special characters: éñ中文🎉", + "Multi-line\ncontent\nwith\nbreaks", + ]; + + for (const content of contents) { + const kiloRule = new KiloRule({ + relativeDirPath: ".", + relativeFilePath: "AGENTS.md", + fileContent: content, + }); + + const result = kiloRule.validate(); + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + } + }); + }); -## Section 1 + describe("integration tests", () => { + it("should handle complete workflow from file to rulesync rule", async () => { + // Create original file + const originalContent = "# Integration Test\n\nComplete workflow test."; + await writeFileContent(join(testDir, "AGENTS.md"), originalContent); -Some content here. + // Load from file + const kiloRule = await KiloRule.fromFile({ + baseDir: testDir, + relativeFilePath: "AGENTS.md", + }); -## Section 2 + // Convert to rulesync rule + const rulesyncRule = kiloRule.toRulesyncRule(); -- Item 1 -- Item 2 -- Item 3 + // Verify conversion + expect(rulesyncRule.getFileContent()).toContain(originalContent); + expect(rulesyncRule.getRelativeDirPath()).toBe(RULESYNC_RULES_RELATIVE_DIR_PATH); + expect(rulesyncRule.getRelativeFilePath()).toBe(RULESYNC_OVERVIEW_FILE_NAME); + }); -\`\`\`javascript -console.log("Code example"); -\`\`\` -`; + it("should handle complete workflow from memory file to rulesync rule", async () => { + // Create memory file + const memoriesDir = join(testDir, ".kilo/rules"); + await ensureDir(memoriesDir); + const originalContent = "# Memory Integration Test\n\nMemory workflow test."; + await writeFileContent(join(memoriesDir, "memory-integration.md"), originalContent); - const kiloRule = new KiloRule({ - relativeDirPath: ".kilocode/rules", - relativeFilePath: "complex.md", - fileContent: complexContent, + // Load from file + const kiloRule = await KiloRule.fromFile({ + baseDir: testDir, + relativeFilePath: "memory-integration.md", }); - const result = kiloRule.validate(); + // Convert to rulesync rule + const rulesyncRule = kiloRule.toRulesyncRule(); - expect(result.success).toBe(true); - expect(result.error).toBeNull(); + // Verify conversion + expect(rulesyncRule.getFileContent()).toContain(originalContent); + expect(rulesyncRule.getRelativeDirPath()).toBe(RULESYNC_RULES_RELATIVE_DIR_PATH); + expect(rulesyncRule.getRelativeFilePath()).toBe("memory-integration.md"); }); - }); - describe("fromFile", () => { - it("should create KiloRule from file with default parameters", async () => { - const kilorulesDir = join(testDir, ".kilocode/rules"); - await ensureDir(kilorulesDir); + it("should handle roundtrip conversion rulesync -> kilo -> rulesync", () => { + const originalBody = "# Roundtrip Test\n\nContent should remain the same."; - const testFileContent = "# File Test\n\nThis is loaded from file."; - await writeFileContent(join(kilorulesDir, "file-test.md"), testFileContent); + // Start with rulesync rule (root) + const originalRulesync = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "roundtrip.md", + frontmatter: { + root: true, + targets: ["*"], + description: "Roundtrip test", + globs: [], + }, + body: originalBody, + }); - const kiloRule = await KiloRule.fromFile({ + // Convert to kilo rule + const kiloRule = KiloRule.fromRulesyncRule({ baseDir: testDir, - relativeFilePath: "file-test.md", + rulesyncRule: originalRulesync, }); - expect(kiloRule).toBeInstanceOf(KiloRule); - expect(kiloRule.getRelativeDirPath()).toBe(".kilocode/rules"); - expect(kiloRule.getRelativeFilePath()).toBe("file-test.md"); - expect(kiloRule.getFileContent()).toBe(testFileContent); - expect(kiloRule.getFilePath()).toBe(join(testDir, ".kilocode/rules", "file-test.md")); + // Convert back to rulesync rule + const finalRulesync = kiloRule.toRulesyncRule(); + + // Verify content preservation + expect(finalRulesync.getFileContent()).toContain(originalBody); + expect(finalRulesync.getRelativeFilePath()).toBe(RULESYNC_OVERVIEW_FILE_NAME); // Should be overview.md for root }); - it("should create KiloRule from file with custom baseDir", async () => { - const customBaseDir = join(testDir, "custom"); - const kilorulesDir = join(customBaseDir, ".kilocode/rules"); - await ensureDir(kilorulesDir); + it("should handle roundtrip conversion rulesync -> kilo -> rulesync for detail rule", () => { + const originalBody = "# Detail Roundtrip Test\n\nDetail content should remain the same."; - const testFileContent = "# Custom Base File Test"; - await writeFileContent(join(kilorulesDir, "custom-base.md"), testFileContent); + // Start with rulesync rule (non-root) + const originalRulesync = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "detail-roundtrip.md", + frontmatter: { + root: false, + targets: ["*"], + description: "Detail roundtrip test", + globs: [], + }, + body: originalBody, + }); - const kiloRule = await KiloRule.fromFile({ - baseDir: customBaseDir, - relativeFilePath: "custom-base.md", + // Convert to kilo rule + const kiloRule = KiloRule.fromRulesyncRule({ + baseDir: testDir, + rulesyncRule: originalRulesync, }); - expect(kiloRule.getFilePath()).toBe(join(customBaseDir, ".kilocode/rules", "custom-base.md")); - expect(kiloRule.getFileContent()).toBe(testFileContent); - }); + // Convert back to rulesync rule + const finalRulesync = kiloRule.toRulesyncRule(); - it("should create KiloRule from file with validation enabled", async () => { - const kilorulesDir = join(testDir, ".kilocode/rules"); - await ensureDir(kilorulesDir); + // Verify content preservation + expect(finalRulesync.getFileContent()).toContain(originalBody); + expect(finalRulesync.getRelativeFilePath()).toBe("detail-roundtrip.md"); + }); - const testFileContent = "# Validated File Test"; - await writeFileContent(join(kilorulesDir, "validated-file.md"), testFileContent); + it("should preserve directory structure in file paths", async () => { + // Test nested directory structure + const nestedDir = join(testDir, ".kilo/rules/nested"); + await ensureDir(nestedDir); + const content = "# Nested Rule\n\nIn a nested directory."; + await writeFileContent(join(nestedDir, "nested-rule.md"), content); + // This should work with the current implementation since fromFile + // determines path based on the relativeFilePath parameter const kiloRule = await KiloRule.fromFile({ baseDir: testDir, - relativeFilePath: "validated-file.md", - validate: true, + relativeFilePath: "nested/nested-rule.md", }); - expect(kiloRule).toBeInstanceOf(KiloRule); - expect(kiloRule.getFileContent()).toBe(testFileContent); + expect(kiloRule.getRelativeDirPath()).toBe(".kilo/rules"); + expect(kiloRule.getRelativeFilePath()).toBe("nested/nested-rule.md"); + expect(kiloRule.getFileContent()).toBe(content); }); + }); - it("should create KiloRule from file with validation disabled", async () => { - const kilorulesDir = join(testDir, ".kilocode/rules"); - await ensureDir(kilorulesDir); + describe("edge cases", () => { + it("should handle files with special characters in names", () => { + const kiloRule = new KiloRule({ + relativeDirPath: ".kilo/rules", + relativeFilePath: "special-chars@#$.md", + fileContent: "# Special chars in filename", + }); - const testFileContent = "# Unvalidated File Test"; - await writeFileContent(join(kilorulesDir, "unvalidated-file.md"), testFileContent); + expect(kiloRule.getRelativeFilePath()).toBe("special-chars@#$.md"); + }); - const kiloRule = await KiloRule.fromFile({ - baseDir: testDir, - relativeFilePath: "unvalidated-file.md", - validate: false, + it("should handle very long content", () => { + const longContent = "# Long Content\n\n" + "A".repeat(10000); + const kiloRule = new KiloRule({ + relativeDirPath: ".kilo/rules", + relativeFilePath: "long-content.md", + fileContent: longContent, }); - expect(kiloRule).toBeInstanceOf(KiloRule); - expect(kiloRule.getFileContent()).toBe(testFileContent); + expect(kiloRule.getFileContent()).toBe(longContent); + expect(kiloRule.validate().success).toBe(true); + }); + + it("should handle content with various line endings", () => { + const contentVariations = [ + "Line 1\nLine 2\nLine 3", // Unix + "Line 1\r\nLine 2\r\nLine 3", // Windows + "Line 1\rLine 2\rLine 3", // Old Mac + "Mixed\nLine\r\nEndings\rHere", // Mixed + ]; + + for (const content of contentVariations) { + const kiloRule = new KiloRule({ + relativeDirPath: ".kilo/rules", + relativeFilePath: "line-endings.md", + fileContent: content, + }); + + expect(kiloRule.validate().success).toBe(true); + expect(kiloRule.getFileContent()).toBe(content); + } }); - it("should load file with frontmatter correctly", async () => { - const kilorulesDir = join(testDir, ".kilocode/rules"); - await ensureDir(kilorulesDir); + it("should handle Unicode content", () => { + const unicodeContent = + "# Unicode Test 🚀\n\nEmojis: 😀🎉\nChinese: 你好世界\nArabic: مرحبا بالعالم\nRussian: Привет мир"; + const kiloRule = new KiloRule({ + relativeDirPath: ".kilo/rules", + relativeFilePath: "unicode.md", + fileContent: unicodeContent, + }); + + expect(kiloRule.getFileContent()).toBe(unicodeContent); + expect(kiloRule.validate().success).toBe(true); + }); + }); + + describe("getSettablePaths", () => { + it("should return correct paths for root and nonRoot", () => { + const paths = KiloRule.getSettablePaths(); - const testFileContent = `--- -description: This is a rule with frontmatter ---- + expect(paths.root).toEqual({ + relativeDirPath: ".", + relativeFilePath: "AGENTS.md", + }); -# Rule with Frontmatter + expect(paths.nonRoot).toEqual({ + relativeDirPath: ".kilo/rules", + }); + }); -This rule has YAML frontmatter.`; + it("should have consistent paths structure", () => { + const paths = KiloRule.getSettablePaths(); - await writeFileContent(join(kilorulesDir, "frontmatter-test.md"), testFileContent); + expect(paths).toHaveProperty("root"); + expect(paths).toHaveProperty("nonRoot"); + expect(paths.root).toHaveProperty("relativeDirPath"); + expect(paths.root).toHaveProperty("relativeFilePath"); + expect(paths.nonRoot).toHaveProperty("relativeDirPath"); + }); + }); + + describe("getSettablePaths with global flag", () => { + it("should return global-specific paths", () => { + const paths = KiloRule.getSettablePaths({ global: true }); + + expect(paths).toHaveProperty("root"); + expect(paths.root).toEqual({ + relativeDirPath: ".config/kilo", + relativeFilePath: "AGENTS.md", + }); + expect(paths).not.toHaveProperty("nonRoot"); + }); + + it("should have different paths than regular getSettablePaths", () => { + const globalPaths = KiloRule.getSettablePaths({ global: true }); + const regularPaths = KiloRule.getSettablePaths(); + + expect(globalPaths.root.relativeDirPath).not.toBe(regularPaths.root.relativeDirPath); + expect(globalPaths.root.relativeFilePath).toBe(regularPaths.root.relativeFilePath); + }); + }); + + describe("fromFile with global flag", () => { + it("should load root file from .config/kilo/AGENTS.md when global=true", async () => { + const globalDir = join(testDir, ".config/kilo"); + await ensureDir(globalDir); + const testContent = "# Global Kilo\n\nGlobal user configuration."; + await writeFileContent(join(globalDir, "AGENTS.md"), testContent); const kiloRule = await KiloRule.fromFile({ baseDir: testDir, - relativeFilePath: "frontmatter-test.md", + relativeFilePath: "AGENTS.md", + global: true, }); - expect(kiloRule.getFileContent()).toBe(testFileContent); + expect(kiloRule.getRelativeDirPath()).toBe(".config/kilo"); + expect(kiloRule.getRelativeFilePath()).toBe("AGENTS.md"); + expect(kiloRule.getFileContent()).toBe(testContent); + expect(kiloRule.getFilePath()).toBe(join(testDir, ".config/kilo/AGENTS.md")); }); - it("should handle nested directory structure", async () => { - const nestedDir = join(testDir, ".kilocode/rules", "category", "subcategory"); - await ensureDir(nestedDir); - - const testFileContent = "# Nested Rule\n\nThis is in a nested directory."; - const relativeFilePath = join("category", "subcategory", "nested.md"); - await writeFileContent(join(testDir, ".kilocode/rules", relativeFilePath), testFileContent); + it("should use global paths when global=true", async () => { + const globalDir = join(testDir, ".config/kilo"); + await ensureDir(globalDir); + const testContent = "# Global Mode Test"; + await writeFileContent(join(globalDir, "AGENTS.md"), testContent); const kiloRule = await KiloRule.fromFile({ baseDir: testDir, - relativeFilePath, + relativeFilePath: "AGENTS.md", + global: true, }); - expect(kiloRule.getRelativeFilePath()).toBe(relativeFilePath); - expect(kiloRule.getFileContent()).toBe(testFileContent); + const globalPaths = KiloRule.getSettablePaths({ global: true }); + expect(kiloRule.getRelativeDirPath()).toBe(globalPaths.root.relativeDirPath); + expect(kiloRule.getRelativeFilePath()).toBe(globalPaths.root.relativeFilePath); }); - }); - describe("forDeletion", () => { - it("should create a non-validated rule for cleanup", () => { - const rule = KiloRule.forDeletion({ + it("should use regular paths when global=false", async () => { + const testContent = "# Non-Global Mode Test"; + await writeFileContent(join(testDir, "AGENTS.md"), testContent); + + const kiloRule = await KiloRule.fromFile({ baseDir: testDir, - relativeDirPath: ".kilocode/rules", - relativeFilePath: "obsolete.md", + relativeFilePath: "AGENTS.md", + global: false, }); - expect(rule.isDeletable()).toBe(true); - expect(rule.getFilePath()).toBe(join(testDir, ".kilocode/rules/obsolete.md")); + expect(kiloRule.getRelativeDirPath()).toBe("."); + expect(kiloRule.getRelativeFilePath()).toBe("AGENTS.md"); }); }); - describe("integration with ToolRule base class", () => { - it("should inherit ToolRule functionality", () => { - const kiloRule = new KiloRule({ - relativeDirPath: ".kilocode/rules", - relativeFilePath: "integration-test.md", - fileContent: "# Integration Test", + describe("fromRulesyncRule with global flag", () => { + it("should use global paths when global=true for root rule", () => { + const rulesyncRule = new RulesyncRule({ + relativeDirPath: `${RULESYNC_RELATIVE_DIR_PATH}/rules`, + relativeFilePath: "test-rule.md", + frontmatter: { + root: true, + targets: ["*"], + description: "Test root rule", + globs: [], + }, + body: "# Global Test RulesyncRule\n\nContent from rulesync.", }); - // Test inherited methods - expect(typeof kiloRule.getRelativeDirPath).toBe("function"); - expect(typeof kiloRule.getRelativeFilePath).toBe("function"); - expect(typeof kiloRule.getFileContent).toBe("function"); - expect(typeof kiloRule.getFilePath).toBe("function"); + const kiloRule = KiloRule.fromRulesyncRule({ + rulesyncRule, + global: true, + }); + + expect(kiloRule.getRelativeDirPath()).toBe(".config/kilo"); + expect(kiloRule.getRelativeFilePath()).toBe("AGENTS.md"); }); - it("should work with ToolRule static methods", () => { + it("should use regular paths when global=false for root rule", () => { const rulesyncRule = new RulesyncRule({ - relativeDirPath: ".", - relativeFilePath: "toolrule-test.md", + relativeDirPath: `${RULESYNC_RELATIVE_DIR_PATH}/rules`, + relativeFilePath: "test-rule.md", frontmatter: { - description: "ToolRule test description", + root: true, targets: ["*"], - root: false, + description: "Test root rule", globs: [], }, - body: "# ToolRule Test", + body: "# Regular Test RulesyncRule\n\nContent from rulesync.", }); const kiloRule = KiloRule.fromRulesyncRule({ rulesyncRule, + global: false, }); - expect(kiloRule).toBeInstanceOf(KiloRule); - expect(kiloRule.getRelativeDirPath()).toBe(".kilocode/rules"); + expect(kiloRule.getRelativeDirPath()).toBe("."); + expect(kiloRule.getRelativeFilePath()).toBe("AGENTS.md"); }); }); @@ -430,7 +869,7 @@ This rule has YAML frontmatter.`; it("should return true for rules targeting kilo", () => { const rulesyncRule = new RulesyncRule({ baseDir: testDir, - relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, relativeFilePath: "test.md", frontmatter: { targets: ["kilo"], @@ -444,7 +883,7 @@ This rule has YAML frontmatter.`; it("should return true for rules targeting all tools (*)", () => { const rulesyncRule = new RulesyncRule({ baseDir: testDir, - relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, relativeFilePath: "test.md", frontmatter: { targets: ["*"], @@ -458,7 +897,7 @@ This rule has YAML frontmatter.`; it("should return false for rules not targeting kilo", () => { const rulesyncRule = new RulesyncRule({ baseDir: testDir, - relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, relativeFilePath: "test.md", frontmatter: { targets: ["cursor", "copilot"], @@ -472,7 +911,7 @@ This rule has YAML frontmatter.`; it("should return false for empty targets", () => { const rulesyncRule = new RulesyncRule({ baseDir: testDir, - relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, relativeFilePath: "test.md", frontmatter: { targets: [], @@ -486,7 +925,7 @@ This rule has YAML frontmatter.`; it("should handle mixed targets including kilo", () => { const rulesyncRule = new RulesyncRule({ baseDir: testDir, - relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, relativeFilePath: "test.md", frontmatter: { targets: ["cursor", "kilo", "copilot"], @@ -500,7 +939,7 @@ This rule has YAML frontmatter.`; it("should handle undefined targets in frontmatter", () => { const rulesyncRule = new RulesyncRule({ baseDir: testDir, - relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, relativeFilePath: "test.md", frontmatter: {}, body: "Test content", @@ -509,14 +948,4 @@ This rule has YAML frontmatter.`; expect(KiloRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(true); }); }); - - describe("getSettablePaths", () => { - it("should return the same paths for both project and global mode", () => { - const projectPaths = KiloRule.getSettablePaths(); - const globalPaths = KiloRule.getSettablePaths({ global: true }); - - expect(projectPaths.nonRoot.relativeDirPath).toBe(".kilocode/rules"); - expect(globalPaths.nonRoot.relativeDirPath).toBe(".kilocode/rules"); - }); - }); }); diff --git a/src/features/rules/kilo-rule.ts b/src/features/rules/kilo-rule.ts index 4ea8dec23..0bba0559a 100644 --- a/src/features/rules/kilo-rule.ts +++ b/src/features/rules/kilo-rule.ts @@ -7,29 +7,47 @@ import { ToolRule, ToolRuleForDeletionParams, ToolRuleFromFileParams, - ToolRuleFromRulesyncRuleParams, + type ToolRuleFromRulesyncRuleParams, + ToolRuleParams, ToolRuleSettablePaths, + ToolRuleSettablePathsGlobal, buildToolPath, } from "./tool-rule.js"; -export type KiloRuleSettablePaths = Pick; +export type KiloRuleParams = ToolRuleParams; + +export type KiloRuleSettablePaths = Omit & { + root: { + relativeDirPath: string; + relativeFilePath: string; + }; +}; + +export type KiloRuleSettablePathsGlobal = ToolRuleSettablePathsGlobal; -/** - * Rule generator for Kilo Code - * - * Generates Markdown rule files for Kilo Code's custom rules system. - * Supports both project-level and global rules using the `.kilocode/rules` directory. - */ export class KiloRule extends ToolRule { - static getSettablePaths( - _options: { - global?: boolean; - excludeToolDir?: boolean; - } = {}, - ): KiloRuleSettablePaths { + static getSettablePaths({ + global, + excludeToolDir, + }: { + global?: boolean; + excludeToolDir?: boolean; + } = {}): KiloRuleSettablePaths | KiloRuleSettablePathsGlobal { + if (global) { + return { + root: { + relativeDirPath: buildToolPath(".config/kilo", ".", excludeToolDir), + relativeFilePath: "AGENTS.md", + }, + }; + } return { + root: { + relativeDirPath: ".", + relativeFilePath: "AGENTS.md", + }, nonRoot: { - relativeDirPath: buildToolPath(".kilocode", "rules", _options.excludeToolDir), + relativeDirPath: buildToolPath(".kilo", "rules", excludeToolDir), }, }; } @@ -38,17 +56,40 @@ export class KiloRule extends ToolRule { baseDir = process.cwd(), relativeFilePath, validate = true, + global = false, }: ToolRuleFromFileParams): Promise { - const fileContent = await readFileContent( - join(baseDir, this.getSettablePaths().nonRoot.relativeDirPath, relativeFilePath), - ); + const paths = this.getSettablePaths({ global }); + const isRoot = relativeFilePath === paths.root.relativeFilePath; + + if (isRoot) { + const relativePath = paths.root.relativeFilePath; + const fileContent = await readFileContent( + join(baseDir, paths.root.relativeDirPath, relativePath), + ); + return new KiloRule({ + baseDir, + relativeDirPath: paths.root.relativeDirPath, + relativeFilePath: paths.root.relativeFilePath, + fileContent, + validate, + root: true, + }); + } + + if (!paths.nonRoot) { + throw new Error(`nonRoot path is not set for ${relativeFilePath}`); + } + + const relativePath = join(paths.nonRoot.relativeDirPath, relativeFilePath); + const fileContent = await readFileContent(join(baseDir, relativePath)); return new KiloRule({ baseDir, - relativeDirPath: this.getSettablePaths().nonRoot.relativeDirPath, + relativeDirPath: paths.nonRoot.relativeDirPath, relativeFilePath: relativeFilePath, fileContent, validate, + root: false, }); } @@ -56,13 +97,16 @@ export class KiloRule extends ToolRule { baseDir = process.cwd(), rulesyncRule, validate = true, + global = false, }: ToolRuleFromRulesyncRuleParams): KiloRule { + const paths = this.getSettablePaths({ global }); return new KiloRule( - this.buildToolRuleParamsDefault({ + this.buildToolRuleParamsAgentsmd({ baseDir, rulesyncRule, validate, - nonRootPath: this.getSettablePaths().nonRoot, + rootPath: paths.root, + nonRootPath: paths.nonRoot, }), ); } @@ -72,6 +116,8 @@ export class KiloRule extends ToolRule { } validate(): ValidationResult { + // Kilo rules are always valid since they use plain markdown format + // Similar to AgentsMdRule, no complex frontmatter validation needed return { success: true, error: null }; } @@ -79,13 +125,18 @@ export class KiloRule extends ToolRule { baseDir = process.cwd(), relativeDirPath, relativeFilePath, + global = false, }: ToolRuleForDeletionParams): KiloRule { + const paths = this.getSettablePaths({ global }); + const isRoot = relativeFilePath === paths.root.relativeFilePath; + return new KiloRule({ baseDir, relativeDirPath, relativeFilePath, fileContent: "", validate: false, + root: isRoot, }); } diff --git a/src/features/skills/kilo-skill.test.ts b/src/features/skills/kilo-skill.test.ts index 09d3f8ee8..f1ac05af1 100644 --- a/src/features/skills/kilo-skill.test.ts +++ b/src/features/skills/kilo-skill.test.ts @@ -6,7 +6,7 @@ import { SKILL_FILE_NAME } from "../../constants/general.js"; import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; import { setupTestDirectory } from "../../test-utils/test-directories.js"; import { ensureDir, writeFileContent } from "../../utils/file.js"; -import { KiloSkill } from "./kilo-skill.js"; +import { KiloSkill, KiloSkillFrontmatter, KiloSkillFrontmatterSchema } from "./kilo-skill.js"; import { RulesyncSkill } from "./rulesync-skill.js"; describe("KiloSkill", () => { @@ -25,171 +25,245 @@ describe("KiloSkill", () => { vi.restoreAllMocks(); }); - describe("getSettablePaths", () => { - it("should return .kilocode/skills for project mode", () => { - const paths = KiloSkill.getSettablePaths(); - expect(paths.relativeDirPath).toBe(join(".kilocode", "skills")); - }); - - it("should use same relative path for global mode", () => { - const projectPaths = KiloSkill.getSettablePaths({ global: false }); - const globalPaths = KiloSkill.getSettablePaths({ global: true }); - expect(projectPaths.relativeDirPath).toBe(join(".kilocode", "skills")); - expect(globalPaths.relativeDirPath).toBe(join(".kilocode", "skills")); - }); - }); - describe("constructor", () => { - it("should create instance when data is valid", () => { + it("should create instance with valid content", () => { const skill = new KiloSkill({ baseDir: testDir, - dirName: "api-design", - frontmatter: { name: "api-design", description: "API conventions" }, - body: "Document API conventions.", + relativeDirPath: join(".kilo", "skills"), + dirName: "test-skill", + frontmatter: { + name: "Test Skill", + description: "Test skill description", + "allowed-tools": ["Bash", "Read"], + }, + body: "This is the body of the kilo skill.", validate: true, }); expect(skill).toBeInstanceOf(KiloSkill); expect(skill.getFrontmatter()).toEqual({ - name: "api-design", - description: "API conventions", + name: "Test Skill", + description: "Test skill description", + "allowed-tools": ["Bash", "Read"], }); - expect(skill.getBody()).toBe("Document API conventions."); - }); - - it("should throw when frontmatter name does not match directory", () => { - expect( - () => - new KiloSkill({ - baseDir: testDir, - dirName: "api-design", - frontmatter: { name: "api", description: "desc" }, - body: "content", - validate: true, - }), - ).toThrow(/frontmatter name/); }); - }); - - describe("fromDir", () => { - it("should load valid skill directory", async () => { - const skillDir = join(testDir, ".kilocode", "skills", "api-design"); - await ensureDir(skillDir); - const skillContent = `--- -name: api-design -description: API conventions ---- - -Document API conventions.`; - await writeFileContent(join(skillDir, SKILL_FILE_NAME), skillContent); - const skill = await KiloSkill.fromDir({ + it("should create instance without validation when validate is false", () => { + const skill = new KiloSkill({ baseDir: testDir, - dirName: "api-design", + relativeDirPath: join(".kilo", "skills"), + dirName: "test-skill", + frontmatter: { + name: "Test Skill", + description: "Test description", + }, + body: "Test body", + validate: false, }); - expect(skill).toBeInstanceOf(KiloSkill); - expect(skill.getFrontmatter()).toEqual({ - name: "api-design", - description: "API conventions", - }); + expect(skill.getBody()).toBe("Test body"); }); - it("should throw when name in frontmatter differs from directory", async () => { - const skillDir = join(testDir, ".kilocode", "skills", "api-design"); - await ensureDir(skillDir); - const skillContent = `--- -name: api -description: API conventions ---- - -Document API conventions.`; - await writeFileContent(join(skillDir, SKILL_FILE_NAME), skillContent); - - await expect( - KiloSkill.fromDir({ + it("should throw error for invalid frontmatter when validation is enabled", () => { + expect(() => { + new KiloSkill({ baseDir: testDir, - dirName: "api-design", - }), - ).rejects.toThrow(/must match directory name/); + relativeDirPath: join(".kilo", "skills"), + dirName: "test-skill", + frontmatter: { + name: "", + description: "", + "allowed-tools": "invalid" as unknown as string[], + }, + body: "Test body", + validate: true, + }); + }).toThrow(/Invalid frontmatter/); + }); + }); + + describe("getSettablePaths", () => { + it("should return project and global paths", () => { + expect(KiloSkill.getSettablePaths()).toEqual({ + relativeDirPath: join(".kilo", "skills"), + }); + expect(KiloSkill.getSettablePaths({ global: true })).toEqual({ + relativeDirPath: join(".config", "kilo", "skills"), + }); }); }); describe("toRulesyncSkill", () => { - it("should convert to RulesyncSkill with wildcard targets", () => { - const kiloSkill = new KiloSkill({ + it("should convert to RulesyncSkill and keep allowed-tools", () => { + const skill = new KiloSkill({ baseDir: testDir, - dirName: "api-design", - frontmatter: { name: "api-design", description: "API conventions" }, - body: "Document API conventions.", + dirName: "test-skill", + frontmatter: { + name: "Test Skill", + description: "Test description", + "allowed-tools": ["Bash", "Read"], + }, + body: "Test body", + validate: true, }); - const rulesyncSkill = kiloSkill.toRulesyncSkill(); + const rulesyncSkill = skill.toRulesyncSkill(); expect(rulesyncSkill).toBeInstanceOf(RulesyncSkill); - expect(rulesyncSkill.getFrontmatter()).toEqual({ - name: "api-design", - description: "API conventions", - targets: ["*"], + expect(rulesyncSkill.getFrontmatter().kilo).toEqual({ + "allowed-tools": ["Bash", "Read"], }); }); }); describe("fromRulesyncSkill", () => { - it("should create KiloSkill from RulesyncSkill", () => { + it("should create instance from RulesyncSkill with project paths", () => { const rulesyncSkill = new RulesyncSkill({ baseDir: testDir, relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, - dirName: "api-design", + dirName: "test-skill", frontmatter: { - name: "api-design", - description: "API conventions", - targets: ["kilo"], + name: "Test Skill", + description: "Test skill description", + kilo: { + "allowed-tools": ["Bash", "Read"], + }, + }, + body: "Test body", + validate: true, + }); + + const skill = KiloSkill.fromRulesyncSkill({ + rulesyncSkill, + global: false, + }); + + expect(skill).toBeInstanceOf(KiloSkill); + expect(skill.getRelativeDirPath()).toBe(join(".kilo", "skills")); + expect(skill.getFrontmatter()["allowed-tools"]).toEqual(["Bash", "Read"]); + }); + + it("should create instance from RulesyncSkill and respect global paths", () => { + const rulesyncSkill = new RulesyncSkill({ + baseDir: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "test-skill", + frontmatter: { + name: "Test Skill", + description: "Test skill description", + kilo: { + "allowed-tools": ["Bash", "Read"], + }, }, - body: "Document API conventions.", + body: "Test body", + validate: true, }); - const kiloSkill = KiloSkill.fromRulesyncSkill({ rulesyncSkill }); + const skill = KiloSkill.fromRulesyncSkill({ + rulesyncSkill, + global: true, + }); + + expect(skill).toBeInstanceOf(KiloSkill); + expect(skill.getRelativeDirPath()).toBe(join(".config", "kilo", "skills")); + expect(skill.getFrontmatter()["allowed-tools"]).toEqual(["Bash", "Read"]); + }); + }); + + describe("fromDir", () => { + it("should create instance from valid skill directory", async () => { + const skillDir = join(testDir, ".kilo", "skills", "test-skill"); + await ensureDir(skillDir); + const skillContent = `--- +name: Test Skill +description: Test skill description +allowed-tools: + - Bash + - Read +--- + +This is the body of the kilo skill. +It can be multiline.`; + await writeFileContent(join(skillDir, SKILL_FILE_NAME), skillContent); - expect(kiloSkill).toBeInstanceOf(KiloSkill); - expect(kiloSkill.getFrontmatter()).toEqual({ - name: "api-design", - description: "API conventions", + const skill = await KiloSkill.fromDir({ + baseDir: testDir, + dirName: "test-skill", }); - expect(kiloSkill.getDirName()).toBe("api-design"); - expect(kiloSkill.getRelativeDirPath()).toBe(join(".kilocode", "skills")); + + expect(skill).toBeInstanceOf(KiloSkill); + expect(skill.getFrontmatter()).toEqual({ + name: "Test Skill", + description: "Test skill description", + "allowed-tools": ["Bash", "Read"], + }); + expect(skill.getBody()).toBe("This is the body of the kilo skill.\nIt can be multiline."); }); }); describe("isTargetedByRulesyncSkill", () => { - it("should accept wildcard targets", () => { + it("should return true when targets include kilo", () => { const rulesyncSkill = new RulesyncSkill({ - dirName: "api-design", - frontmatter: { name: "api-design", description: "API conventions", targets: ["*"] }, - body: "content", + baseDir: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "test-skill", + frontmatter: { + name: "Test Skill", + description: "Test skill description", + targets: ["kilo"], + }, + body: "Test body", + validate: true, }); expect(KiloSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(true); }); - it("should accept kilo-specific targets", () => { + it("should return true when targets include wildcard", () => { const rulesyncSkill = new RulesyncSkill({ - dirName: "api-design", - frontmatter: { name: "api-design", description: "API conventions", targets: ["kilo"] }, - body: "content", + baseDir: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "test-skill", + frontmatter: { + name: "Test Skill", + description: "Test skill description", + targets: ["*"], + }, + body: "Test body", + validate: true, }); expect(KiloSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(true); }); - it("should reject non-matching targets", () => { + it("should return false when targets do not include kilo or wildcard", () => { const rulesyncSkill = new RulesyncSkill({ - dirName: "api-design", - frontmatter: { name: "api-design", description: "API conventions", targets: ["roo"] }, - body: "content", + baseDir: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "test-skill", + frontmatter: { + name: "Test Skill", + description: "Test skill description", + targets: ["claudecode", "cursor"], + }, + body: "Test body", + validate: true, }); expect(KiloSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(false); }); }); + + describe("validation schema", () => { + it("should validate allowed-tools as optional array", () => { + const validFrontmatter: KiloSkillFrontmatter = { + name: "Test Skill", + description: "Test description", + "allowed-tools": ["Bash"], + }; + + const result = KiloSkillFrontmatterSchema.safeParse(validFrontmatter); + expect(result.success).toBe(true); + }); + }); }); diff --git a/src/features/skills/kilo-skill.ts b/src/features/skills/kilo-skill.ts index 110dab32b..07bf4b444 100644 --- a/src/features/skills/kilo-skill.ts +++ b/src/features/skills/kilo-skill.ts @@ -15,14 +15,15 @@ import { ToolSkillSettablePaths, } from "./tool-skill.js"; -const KiloSkillFrontmatterSchema = z.looseObject({ +export const KiloSkillFrontmatterSchema = z.looseObject({ name: z.string(), description: z.string(), + "allowed-tools": z.optional(z.array(z.string())), }); -type KiloSkillFrontmatter = z.infer; +export type KiloSkillFrontmatter = z.infer; -type KiloSkillParams = { +export type KiloSkillParams = { baseDir?: string; relativeDirPath?: string; dirName: string; @@ -33,14 +34,10 @@ type KiloSkillParams = { global?: boolean; }; -/** - * Represents a Kilo Code skill directory. - * Skills are stored under .kilocode/skills/ directories with SKILL.md files. - */ export class KiloSkill extends ToolSkill { constructor({ baseDir = process.cwd(), - relativeDirPath = join(".kilocode", "skills"), + relativeDirPath = join(".kilo", "skills"), dirName, frontmatter, body, @@ -69,13 +66,9 @@ export class KiloSkill extends ToolSkill { } } - static getSettablePaths({ - global: _global = false, - }: { - global?: boolean; - } = {}): ToolSkillSettablePaths { + static getSettablePaths({ global = false }: { global?: boolean } = {}): ToolSkillSettablePaths { return { - relativeDirPath: join(".kilocode", "skills"), + relativeDirPath: global ? join(".config", "kilo", "skills") : join(".kilo", "skills"), }; } @@ -89,13 +82,12 @@ export class KiloSkill extends ToolSkill { } validate(): ValidationResult { - if (!this.mainFile) { + if (this.mainFile === undefined) { return { success: false, error: new Error(`${this.getDirPath()}: ${SKILL_FILE_NAME} file does not exist`), }; } - const result = KiloSkillFrontmatterSchema.safeParse(this.mainFile.frontmatter); if (!result.success) { return { @@ -106,15 +98,6 @@ export class KiloSkill extends ToolSkill { }; } - if (result.data.name !== this.getDirName()) { - return { - success: false, - error: new Error( - `${this.getDirPath()}: frontmatter name (${result.data.name}) must match directory name (${this.getDirName()})`, - ), - }; - } - return { success: true, error: null }; } @@ -124,6 +107,11 @@ export class KiloSkill extends ToolSkill { name: frontmatter.name, description: frontmatter.description, targets: ["*"], + ...(frontmatter["allowed-tools"] && { + kilo: { + "allowed-tools": frontmatter["allowed-tools"], + }, + }), }; return new RulesyncSkill({ @@ -144,18 +132,20 @@ export class KiloSkill extends ToolSkill { validate = true, global = false, }: ToolSkillFromRulesyncSkillParams): KiloSkill { - const settablePaths = KiloSkill.getSettablePaths({ global }); const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); const kiloFrontmatter: KiloSkillFrontmatter = { name: rulesyncFrontmatter.name, description: rulesyncFrontmatter.description, + "allowed-tools": rulesyncFrontmatter.kilo?.["allowed-tools"], }; + const settablePaths = KiloSkill.getSettablePaths({ global }); + return new KiloSkill({ baseDir, relativeDirPath: settablePaths.relativeDirPath, - dirName: kiloFrontmatter.name, + dirName: rulesyncSkill.getDirName(), frontmatter: kiloFrontmatter, body: rulesyncSkill.getBody(), otherFiles: rulesyncSkill.getOtherFiles(), @@ -183,18 +173,6 @@ export class KiloSkill extends ToolSkill { ); } - if (result.data.name !== loaded.dirName) { - const skillFilePath = join( - loaded.baseDir, - loaded.relativeDirPath, - loaded.dirName, - SKILL_FILE_NAME, - ); - throw new Error( - `Frontmatter name (${result.data.name}) must match directory name (${loaded.dirName}) in ${skillFilePath}`, - ); - } - return new KiloSkill({ baseDir: loaded.baseDir, relativeDirPath: loaded.relativeDirPath, diff --git a/src/features/skills/rulesync-skill.ts b/src/features/skills/rulesync-skill.ts index 9552704a0..c03e0def7 100644 --- a/src/features/skills/rulesync-skill.ts +++ b/src/features/skills/rulesync-skill.ts @@ -31,6 +31,11 @@ const RulesyncSkillFrontmatterSchemaInternal = z.looseObject({ "allowed-tools": z.optional(z.array(z.string())), }), ), + kilo: z.optional( + z.looseObject({ + "allowed-tools": z.optional(z.array(z.string())), + }), + ), deepagents: z.optional( z.looseObject({ "allowed-tools": z.optional(z.array(z.string())), @@ -64,6 +69,9 @@ export type RulesyncSkillFrontmatterInput = { opencode?: { "allowed-tools"?: string[]; }; + kilo?: { + "allowed-tools"?: string[]; + }; deepagents?: { "allowed-tools"?: string[]; }; diff --git a/src/features/subagents/kilo-subagent.test.ts b/src/features/subagents/kilo-subagent.test.ts new file mode 100644 index 000000000..36d2c9bf0 --- /dev/null +++ b/src/features/subagents/kilo-subagent.test.ts @@ -0,0 +1,246 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { writeFileContent } from "../../utils/file.js"; +import { KiloSubagent, KiloSubagentFrontmatterSchema } from "./kilo-subagent.js"; +import { RulesyncSubagent } from "./rulesync-subagent.js"; + +describe("KiloSubagent", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + const testSetup = await setupTestDirectory(); + testDir = testSetup.testDir; + cleanup = testSetup.cleanup; + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + it("should return settable paths for project and global scopes", () => { + expect(KiloSubagent.getSettablePaths()).toEqual({ + relativeDirPath: ".kilo/agent", + }); + + expect(KiloSubagent.getSettablePaths({ global: true })).toEqual({ + relativeDirPath: join(".config", "kilo", "agent"), + }); + }); + + it("should create a RulesyncSubagent with kilo section and subagent mode", () => { + const subagent = new KiloSubagent({ + baseDir: testDir, + relativeDirPath: ".kilo/agent", + relativeFilePath: "review.md", + frontmatter: { + description: "Reviews code", + mode: "subagent", + temperature: 0.2, + }, + body: "Review the provided changes", + fileContent: "", + validate: true, + }); + + const rulesync = subagent.toRulesyncSubagent(); + expect(rulesync.getFrontmatter()).toEqual({ + targets: ["*"], + name: "review", + description: "Reviews code", + kilo: { + temperature: 0.2, + mode: "subagent", + }, + }); + expect(rulesync.getBody()).toBe("Review the provided changes"); + }); + + it("should build Kilo subagent from Rulesync subagent and preserve mode", () => { + const rulesyncSubagent = new RulesyncSubagent({ + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "docs-writer.md", + frontmatter: { + targets: ["kilo"], + name: "docs-writer", + description: "Writes documentation", + kilo: { + mode: "primary", // should be preserved + model: "model-x", + }, + }, + body: "Document the APIs", + validate: false, + }); + + const toolSubagent = KiloSubagent.fromRulesyncSubagent({ + rulesyncSubagent, + global: true, + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + }) as KiloSubagent; + + expect(toolSubagent).toBeInstanceOf(KiloSubagent); + expect(toolSubagent.getFrontmatter()).toEqual({ + name: "docs-writer", + description: "Writes documentation", + model: "model-x", + mode: "primary", + }); + expect(toolSubagent.getRelativeDirPath()).toBe(join(".config", "kilo", "agent")); + }); + + it("should build Kilo subagent with default mode when not specified", () => { + const rulesyncSubagent = new RulesyncSubagent({ + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "docs-writer.md", + frontmatter: { + targets: ["kilo"], + name: "docs-writer", + description: "Writes documentation", + kilo: { + model: "model-x", + }, + }, + body: "Document the APIs", + validate: false, + }); + + const toolSubagent = KiloSubagent.fromRulesyncSubagent({ + rulesyncSubagent, + global: true, + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + }) as KiloSubagent; + + expect(toolSubagent).toBeInstanceOf(KiloSubagent); + expect(toolSubagent.getFrontmatter()).toEqual({ + name: "docs-writer", + description: "Writes documentation", + model: "model-x", + mode: "subagent", + }); + expect(toolSubagent.getRelativeDirPath()).toBe(join(".config", "kilo", "agent")); + }); + + it("should preserve primary mode for Kilo subagent", () => { + // Regression test for: kilo.mode was hardcoded to 'subagent' instead of being preserved + const rulesyncSubagent = new RulesyncSubagent({ + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "primary-agent.md", + frontmatter: { + targets: ["*"], + name: "primary-agent", + description: "A primary mode agent", + kilo: { + mode: "primary", + hidden: false, + tools: { + bash: true, + edit: true, + }, + }, + }, + body: "Test body for primary agent", + validate: false, + }); + + const toolSubagent = KiloSubagent.fromRulesyncSubagent({ + rulesyncSubagent, + global: true, + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + }) as KiloSubagent; + + expect(toolSubagent.getFrontmatter().mode).toBe("primary"); + expect(toolSubagent.getFrontmatter().name).toBe("primary-agent"); + expect(toolSubagent.getFrontmatter().tools).toEqual({ + bash: true, + edit: true, + }); + }); + + it("should load from file and validate frontmatter", async () => { + const dirPath = join(testDir, ".kilo", "agent"); + const filePath = join(dirPath, "general.md"); + + await writeFileContent( + filePath, + `--- +description: General purpose helper +mode: subagent +temperature: 0.1 +--- +Assist with any tasks`, + ); + + const subagent = await KiloSubagent.fromFile({ + relativeFilePath: "general.md", + }); + + expect(subagent.getFrontmatter()).toEqual({ + description: "General purpose helper", + mode: "subagent", + temperature: 0.1, + }); + expect(subagent.getBody()).toBe("Assist with any tasks"); + }); + + it("should expose schema for direct validation", () => { + const result = KiloSubagentFrontmatterSchema.safeParse({ + description: "Valid agent", + mode: "subagent", + }); + + expect(result.success).toBe(true); + }); + + it("should apply default mode 'subagent' when mode is omitted", async () => { + const dirPath = join(testDir, ".kilo", "agent"); + const filePath = join(dirPath, "no-mode.md"); + + await writeFileContent( + filePath, + `--- +description: Agent without explicit mode +temperature: 0.5 +--- +Body content`, + ); + + const subagent = await KiloSubagent.fromFile({ + relativeFilePath: "no-mode.md", + }); + + expect(subagent.getFrontmatter().mode).toBe("subagent"); + }); + + it("should preserve custom mode value when explicitly set", async () => { + const dirPath = join(testDir, ".kilo", "agent"); + const filePath = join(dirPath, "custom-mode.md"); + + await writeFileContent( + filePath, + `--- +description: Agent with custom mode +mode: all +--- +Body content`, + ); + + const subagent = await KiloSubagent.fromFile({ + relativeFilePath: "custom-mode.md", + }); + + expect(subagent.getFrontmatter().mode).toBe("all"); + }); +}); diff --git a/src/features/subagents/kilo-subagent.ts b/src/features/subagents/kilo-subagent.ts new file mode 100644 index 000000000..703f87d62 --- /dev/null +++ b/src/features/subagents/kilo-subagent.ts @@ -0,0 +1,191 @@ +import { basename, join } from "node:path"; + +import { z } from "zod/mini"; + +import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContent } from "../../utils/file.js"; +import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter.js"; +import { RulesyncSubagent, RulesyncSubagentFrontmatter } from "./rulesync-subagent.js"; +import { + ToolSubagent, + ToolSubagentForDeletionParams, + ToolSubagentFromFileParams, + ToolSubagentFromRulesyncSubagentParams, + ToolSubagentSettablePaths, +} from "./tool-subagent.js"; + +export const KiloSubagentFrontmatterSchema = z.looseObject({ + description: z.optional(z.string()), + mode: z._default(z.string(), "subagent"), + name: z.optional(z.string()), +}); + +export type KiloSubagentFrontmatter = z.infer; + +export type KiloSubagentParams = { + frontmatter: KiloSubagentFrontmatter; + body: string; +} & AiFileParams; + +export class KiloSubagent extends ToolSubagent { + private readonly frontmatter: KiloSubagentFrontmatter; + private readonly body: string; + + constructor({ frontmatter, body, ...rest }: KiloSubagentParams) { + if (rest.validate !== false) { + const result = KiloSubagentFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error( + `Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`, + ); + } + } + + super({ + ...rest, + }); + + this.frontmatter = frontmatter; + this.body = body; + } + + static getSettablePaths({ + global = false, + }: { + global?: boolean; + } = {}): ToolSubagentSettablePaths { + return { + relativeDirPath: global ? join(".config", "kilo", "agent") : join(".kilo", "agent"), + }; + } + + getFrontmatter(): KiloSubagentFrontmatter { + return this.frontmatter; + } + + getBody(): string { + return this.body; + } + + toRulesyncSubagent(): RulesyncSubagent { + const { description, mode, name, ...kiloSection } = this.frontmatter; + const rulesyncFrontmatter: RulesyncSubagentFrontmatter = { + targets: ["*"] as const, + name: name ?? basename(this.getRelativeFilePath(), ".md"), + description, + kilo: { mode, ...kiloSection }, + }; + + return new RulesyncSubagent({ + baseDir: ".", // RulesyncSubagent baseDir is always the project root directory + frontmatter: rulesyncFrontmatter, + body: this.body, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: this.getRelativeFilePath(), + validate: true, + }); + } + + static fromRulesyncSubagent({ + baseDir = process.cwd(), + rulesyncSubagent, + validate = true, + global = false, + }: ToolSubagentFromRulesyncSubagentParams): ToolSubagent { + const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter(); + const kiloSection = rulesyncFrontmatter.kilo ?? {}; + + const kiloFrontmatter: KiloSubagentFrontmatter = { + ...kiloSection, + description: rulesyncFrontmatter.description, + mode: typeof kiloSection.mode === "string" ? kiloSection.mode : "subagent", + ...(rulesyncFrontmatter.name && { name: rulesyncFrontmatter.name }), + }; + + const body = rulesyncSubagent.getBody(); + const fileContent = stringifyFrontmatter(body, kiloFrontmatter); + const paths = this.getSettablePaths({ global }); + + return new KiloSubagent({ + baseDir, + frontmatter: kiloFrontmatter, + body, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: rulesyncSubagent.getRelativeFilePath(), + fileContent, + validate, + global, + }); + } + + validate(): ValidationResult { + if (!this.frontmatter) { + return { success: true, error: null }; + } + + const result = KiloSubagentFrontmatterSchema.safeParse(this.frontmatter); + if (result.success) { + return { success: true, error: null }; + } + + return { + success: false, + error: new Error( + `Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`, + ), + }; + } + + static isTargetedByRulesyncSubagent(rulesyncSubagent: RulesyncSubagent): boolean { + return this.isTargetedByRulesyncSubagentDefault({ + rulesyncSubagent, + toolTarget: "kilo", + }); + } + + static async fromFile({ + baseDir = process.cwd(), + relativeFilePath, + validate = true, + global = false, + }: ToolSubagentFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(baseDir, paths.relativeDirPath, relativeFilePath); + const fileContent = await readFileContent(filePath); + const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath); + + const result = KiloSubagentFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`); + } + + return new KiloSubagent({ + baseDir, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + frontmatter: result.data, + body: content.trim(), + fileContent, + validate, + global, + }); + } + + static forDeletion({ + baseDir = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolSubagentForDeletionParams): KiloSubagent { + return new KiloSubagent({ + baseDir, + relativeDirPath, + relativeFilePath, + frontmatter: { description: "", mode: "subagent" }, + body: "", + fileContent: "", + validate: false, + }); + } +} diff --git a/src/features/subagents/subagents-processor.test.ts b/src/features/subagents/subagents-processor.test.ts index 2928a4458..b1f95dc97 100644 --- a/src/features/subagents/subagents-processor.test.ts +++ b/src/features/subagents/subagents-processor.test.ts @@ -918,7 +918,7 @@ Second global content`; }); describe("getToolTargets with global: true", () => { - it("should return claudecode, codexcli, cursor, opencode, and rovodev as global-supported targets", () => { + it("should return claudecode, codexcli, cursor, kilo, opencode, and rovodev as global-supported targets", () => { const toolTargets = SubagentsProcessor.getToolTargets({ global: true }); expect(Array.isArray(toolTargets)).toBe(true); @@ -927,6 +927,7 @@ Second global content`; "claudecode-legacy", "codexcli", "cursor", + "kilo", "opencode", "rovodev", ]); @@ -968,6 +969,7 @@ Second global content`; "factorydroid", "geminicli", "junie", + "kilo", "kiro", "opencode", "roo", @@ -992,6 +994,7 @@ Second global content`; "copilot", "cursor", "codexcli", + "kilo", "opencode", ]; validTargets.forEach((target) => { diff --git a/src/features/subagents/subagents-processor.ts b/src/features/subagents/subagents-processor.ts index 9281e2b7e..556598d05 100644 --- a/src/features/subagents/subagents-processor.ts +++ b/src/features/subagents/subagents-processor.ts @@ -18,6 +18,7 @@ import { DeepagentsSubagent } from "./deepagents-subagent.js"; import { FactorydroidSubagent } from "./factorydroid-subagent.js"; import { GeminiCliSubagent } from "./geminicli-subagent.js"; import { JunieSubagent } from "./junie-subagent.js"; +import { KiloSubagent } from "./kilo-subagent.js"; import { KiroSubagent } from "./kiro-subagent.js"; import { OpenCodeSubagent } from "./opencode-subagent.js"; import { RooSubagent } from "./roo-subagent.js"; @@ -59,6 +60,7 @@ type ToolSubagentFactory = { * Using a tuple to preserve order for consistent iteration. */ const subagentsProcessorToolTargetTuple = [ + "kilo", "agentsmd", "claudecode", "claudecode-legacy", @@ -162,6 +164,13 @@ const toolSubagentFactories = new Map = Obj Object.entries(CANONICAL_TO_FACTORYDROID_EVENT_NAMES).map(([k, v]) => [v, k]), ); +/** + * Map canonical camelCase event names to Kilo dot-notation. + */ +export const CANONICAL_TO_KILO_EVENT_NAMES: Record = { + sessionStart: "session.created", + preToolUse: "tool.execute.before", + postToolUse: "tool.execute.after", + stop: "session.idle", + afterFileEdit: "file.edited", + afterShellExecution: "command.executed", + permissionRequest: "permission.asked", +}; + /** * Map canonical camelCase event names to OpenCode dot-notation. */ diff --git a/update-kilo-command.py b/update-kilo-command.py new file mode 100644 index 000000000..881f37720 --- /dev/null +++ b/update-kilo-command.py @@ -0,0 +1,26 @@ +import re + +with open("src/features/commands/opencode-command.ts", "r") as f: + content = f.read() + +content = content.replace("OpenCode", "Kilo") +content = content.replace("opencode", "kilo") +content = content.replace("opencode\", \"command", "kilo\", \"commands") +content = content.replace(".opencode/command", ".kilo/commands") +content = content.replace(".config/opencode/command", ".config/kilo/commands") +content = content.replace("opencode-command", "kilo-command") + +with open("src/features/commands/kilo-command.ts", "w") as f: + f.write(content) + +with open("src/features/commands/opencode-command.test.ts", "r") as f: + content = f.read() + +content = content.replace("OpenCode", "Kilo") +content = content.replace("opencode", "kilo") +content = content.replace(".opencode/command", ".kilo/commands") +content = content.replace(".config/opencode/command", ".config/kilo/commands") +content = content.replace("opencode-command", "kilo-command") + +with open("src/features/commands/kilo-command.test.ts", "w") as f: + f.write(content) diff --git a/update-kilo-mcp.py b/update-kilo-mcp.py new file mode 100644 index 000000000..0e8a84033 --- /dev/null +++ b/update-kilo-mcp.py @@ -0,0 +1,29 @@ +import re + +with open("src/features/mcp/opencode-mcp.ts", "r") as f: + content = f.read() + +content = content.replace("OpenCode", "Kilo") +content = content.replace("opencode", "kilo") +content = content.replace("Opencode", "Kilo") +content = content.replace("opencode.json", "kilo.json") +content = content.replace("opencode.jsonc", "kilo.jsonc") +content = content.replace(".config/opencode", ".config/kilo") +content = content.replace("opencode-mcp", "kilo-mcp") + +with open("src/features/mcp/kilo-mcp.ts", "w") as f: + f.write(content) + +with open("src/features/mcp/opencode-mcp.test.ts", "r") as f: + content = f.read() + +content = content.replace("OpenCode", "Kilo") +content = content.replace("opencode", "kilo") +content = content.replace("Opencode", "Kilo") +content = content.replace("opencode.json", "kilo.json") +content = content.replace("opencode.jsonc", "kilo.jsonc") +content = content.replace(".config/opencode", ".config/kilo") +content = content.replace("opencode-mcp", "kilo-mcp") + +with open("src/features/mcp/kilo-mcp.test.ts", "w") as f: + f.write(content) diff --git a/update-kilo-rule.py b/update-kilo-rule.py new file mode 100644 index 000000000..4ed165849 --- /dev/null +++ b/update-kilo-rule.py @@ -0,0 +1,25 @@ +import re + +with open("src/features/rules/opencode-rule.ts", "r") as f: + content = f.read() + +content = content.replace("OpenCode", "Kilo") +content = content.replace("opencode", "kilo") +content = content.replace(".config/kilo", ".config/kilo") +content = content.replace(".kilo\", \"memories\"", ".kilo\", \"rules\"") +content = content.replace("opencode-rule", "kilo-rule") + +with open("src/features/rules/kilo-rule.ts", "w") as f: + f.write(content) + +with open("src/features/rules/opencode-rule.test.ts", "r") as f: + content = f.read() + +content = content.replace("OpenCode", "Kilo") +content = content.replace("opencode", "kilo") +content = content.replace(".config/kilo", ".config/kilo") +content = content.replace(".kilo/memories", ".kilo/rules") +content = content.replace("opencode-rule", "kilo-rule") + +with open("src/features/rules/kilo-rule.test.ts", "w") as f: + f.write(content) diff --git a/update-kilo-skill.py b/update-kilo-skill.py new file mode 100644 index 000000000..74e068317 --- /dev/null +++ b/update-kilo-skill.py @@ -0,0 +1,26 @@ +import re + +with open("src/features/skills/opencode-skill.ts", "r") as f: + content = f.read() + +content = content.replace("OpenCode", "Kilo") +content = content.replace("opencode", "kilo") +content = content.replace("opencode\", \"skill", "kilo\", \"skills") +content = content.replace(".opencode/skill", ".kilo/skills") +content = content.replace(".config/opencode/skill", ".config/kilo/skills") +content = content.replace("opencode-skill", "kilo-skill") + +with open("src/features/skills/kilo-skill.ts", "w") as f: + f.write(content) + +with open("src/features/skills/opencode-skill.test.ts", "r") as f: + content = f.read() + +content = content.replace("OpenCode", "Kilo") +content = content.replace("opencode", "kilo") +content = content.replace(".opencode/skill", ".kilo/skills") +content = content.replace(".config/opencode/skill", ".config/kilo/skills") +content = content.replace("opencode-skill", "kilo-skill") + +with open("src/features/skills/kilo-skill.test.ts", "w") as f: + f.write(content)