diff --git a/.gitignore b/.gitignore index 584d27f62..ad5d61326 100644 --- a/.gitignore +++ b/.gitignore @@ -284,11 +284,11 @@ rulesync.local.jsonc **/.junie/mcp.json **/.junie/skills/ **/.junie/agents/ -**/.kilocode/rules/ -**/.kilocode/skills/ -**/.kilocode/workflows/ -**/.kilocode/mcp.json -**/.kilocodeignore +**/.kilo/rules/ +**/.kilo/skills/ +**/.kilo/workflows/ +**/.kilo/mcp.json +**/.kiloignore **/.kiro/steering/ **/.kiro/prompts/ **/.kiro/skills/ diff --git a/.rulesync/skills/skill-creator/scripts/init_skill.py b/.rulesync/skills/skill-creator/scripts/init_skill.py deleted file mode 100755 index 329ad4e5a..000000000 --- a/.rulesync/skills/skill-creator/scripts/init_skill.py +++ /dev/null @@ -1,303 +0,0 @@ -#!/usr/bin/env python3 -""" -Skill Initializer - Creates a new skill from template - -Usage: - init_skill.py --path - -Examples: - init_skill.py my-new-skill --path skills/public - init_skill.py my-api-helper --path skills/private - init_skill.py custom-skill --path /custom/location -""" - -import sys -from pathlib import Path - - -SKILL_TEMPLATE = """--- -name: {skill_name} -description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.] ---- - -# {skill_title} - -## Overview - -[TODO: 1-2 sentences explaining what this skill enables] - -## Structuring This Skill - -[TODO: Choose the structure that best fits this skill's purpose. Common patterns: - -**1. Workflow-Based** (best for sequential processes) -- Works well when there are clear step-by-step procedures -- Example: DOCX skill with "Workflow Decision Tree" → "Reading" → "Creating" → "Editing" -- Structure: ## Overview → ## Workflow Decision Tree → ## Step 1 → ## Step 2... - -**2. Task-Based** (best for tool collections) -- Works well when the skill offers different operations/capabilities -- Example: PDF skill with "Quick Start" → "Merge PDFs" → "Split PDFs" → "Extract Text" -- Structure: ## Overview → ## Quick Start → ## Task Category 1 → ## Task Category 2... - -**3. Reference/Guidelines** (best for standards or specifications) -- Works well for brand guidelines, coding standards, or requirements -- Example: Brand styling with "Brand Guidelines" → "Colors" → "Typography" → "Features" -- Structure: ## Overview → ## Guidelines → ## Specifications → ## Usage... - -**4. Capabilities-Based** (best for integrated systems) -- Works well when the skill provides multiple interrelated features -- Example: Product Management with "Core Capabilities" → numbered capability list -- Structure: ## Overview → ## Core Capabilities → ### 1. Feature → ### 2. Feature... - -Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations). - -Delete this entire "Structuring This Skill" section when done - it's just guidance.] - -## [TODO: Replace with the first main section based on chosen structure] - -[TODO: Add content here. See examples in existing skills: -- Code samples for technical skills -- Decision trees for complex workflows -- Concrete examples with realistic user requests -- References to scripts/templates/references as needed] - -## Resources - -This skill includes example resource directories that demonstrate how to organize different types of bundled resources: - -### scripts/ -Executable code (Python/Bash/etc.) that can be run directly to perform specific operations. - -**Examples from other skills:** -- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation -- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing - -**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations. - -**Note:** Scripts may be executed without loading into context, but can still be read by Claude for patching or environment adjustments. - -### references/ -Documentation and reference material intended to be loaded into context to inform Claude's process and thinking. - -**Examples from other skills:** -- Product management: `communication.md`, `context_building.md` - detailed workflow guides -- BigQuery: API reference documentation and query examples -- Finance: Schema documentation, company policies - -**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Claude should reference while working. - -### assets/ -Files not intended to be loaded into context, but rather used within the output Claude produces. - -**Examples from other skills:** -- Brand styling: PowerPoint template files (.pptx), logo files -- Frontend builder: HTML/React boilerplate project directories -- Typography: Font files (.ttf, .woff2) - -**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output. - ---- - -**Any unneeded directories can be deleted.** Not every skill requires all three types of resources. -""" - -EXAMPLE_SCRIPT = '''#!/usr/bin/env python3 -""" -Example helper script for {skill_name} - -This is a placeholder script that can be executed directly. -Replace with actual implementation or delete if not needed. - -Example real scripts from other skills: -- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields -- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images -""" - -def main(): - print("This is an example script for {skill_name}") - # TODO: Add actual script logic here - # This could be data processing, file conversion, API calls, etc. - -if __name__ == "__main__": - main() -''' - -EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title} - -This is a placeholder for detailed reference documentation. -Replace with actual reference content or delete if not needed. - -Example real reference docs from other skills: -- product-management/references/communication.md - Comprehensive guide for status updates -- product-management/references/context_building.md - Deep-dive on gathering context -- bigquery/references/ - API references and query examples - -## When Reference Docs Are Useful - -Reference docs are ideal for: -- Comprehensive API documentation -- Detailed workflow guides -- Complex multi-step processes -- Information too lengthy for main SKILL.md -- Content that's only needed for specific use cases - -## Structure Suggestions - -### API Reference Example -- Overview -- Authentication -- Endpoints with examples -- Error codes -- Rate limits - -### Workflow Guide Example -- Prerequisites -- Step-by-step instructions -- Common patterns -- Troubleshooting -- Best practices -""" - -EXAMPLE_ASSET = """# Example Asset File - -This placeholder represents where asset files would be stored. -Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed. - -Asset files are NOT intended to be loaded into context, but rather used within -the output Claude produces. - -Example asset files from other skills: -- Brand guidelines: logo.png, slides_template.pptx -- Frontend builder: hello-world/ directory with HTML/React boilerplate -- Typography: custom-font.ttf, font-family.woff2 -- Data: sample_data.csv, test_dataset.json - -## Common Asset Types - -- Templates: .pptx, .docx, boilerplate directories -- Images: .png, .jpg, .svg, .gif -- Fonts: .ttf, .otf, .woff, .woff2 -- Boilerplate code: Project directories, starter files -- Icons: .ico, .svg -- Data files: .csv, .json, .xml, .yaml - -Note: This is a text placeholder. Actual assets can be any file type. -""" - - -def title_case_skill_name(skill_name): - """Convert hyphenated skill name to Title Case for display.""" - return ' '.join(word.capitalize() for word in skill_name.split('-')) - - -def init_skill(skill_name, path): - """ - Initialize a new skill directory with template SKILL.md. - - Args: - skill_name: Name of the skill - path: Path where the skill directory should be created - - Returns: - Path to created skill directory, or None if error - """ - # Determine skill directory path - skill_dir = Path(path).resolve() / skill_name - - # Check if directory already exists - if skill_dir.exists(): - print(f"❌ Error: Skill directory already exists: {skill_dir}") - return None - - # Create skill directory - try: - skill_dir.mkdir(parents=True, exist_ok=False) - print(f"✅ Created skill directory: {skill_dir}") - except Exception as e: - print(f"❌ Error creating directory: {e}") - return None - - # Create SKILL.md from template - skill_title = title_case_skill_name(skill_name) - skill_content = SKILL_TEMPLATE.format( - skill_name=skill_name, - skill_title=skill_title - ) - - skill_md_path = skill_dir / 'SKILL.md' - try: - skill_md_path.write_text(skill_content) - print("✅ Created SKILL.md") - except Exception as e: - print(f"❌ Error creating SKILL.md: {e}") - return None - - # Create resource directories with example files - try: - # Create scripts/ directory with example script - scripts_dir = skill_dir / 'scripts' - scripts_dir.mkdir(exist_ok=True) - example_script = scripts_dir / 'example.py' - example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name)) - example_script.chmod(0o755) - print("✅ Created scripts/example.py") - - # Create references/ directory with example reference doc - references_dir = skill_dir / 'references' - references_dir.mkdir(exist_ok=True) - example_reference = references_dir / 'api_reference.md' - example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title)) - print("✅ Created references/api_reference.md") - - # Create assets/ directory with example asset placeholder - assets_dir = skill_dir / 'assets' - assets_dir.mkdir(exist_ok=True) - example_asset = assets_dir / 'example_asset.txt' - example_asset.write_text(EXAMPLE_ASSET) - print("✅ Created assets/example_asset.txt") - except Exception as e: - print(f"❌ Error creating resource directories: {e}") - return None - - # Print next steps - print(f"\n✅ Skill '{skill_name}' initialized successfully at {skill_dir}") - print("\nNext steps:") - print("1. Edit SKILL.md to complete the TODO items and update the description") - print("2. Customize or delete the example files in scripts/, references/, and assets/") - print("3. Run the validator when ready to check the skill structure") - - return skill_dir - - -def main(): - if len(sys.argv) < 4 or sys.argv[2] != '--path': - print("Usage: init_skill.py --path ") - print("\nSkill name requirements:") - print(" - Hyphen-case identifier (e.g., 'data-analyzer')") - print(" - Lowercase letters, digits, and hyphens only") - print(" - Max 40 characters") - print(" - Must match directory name exactly") - print("\nExamples:") - print(" init_skill.py my-new-skill --path skills/public") - print(" init_skill.py my-api-helper --path skills/private") - print(" init_skill.py custom-skill --path /custom/location") - sys.exit(1) - - skill_name = sys.argv[1] - path = sys.argv[3] - - print(f"🚀 Initializing skill: {skill_name}") - print(f" Location: {path}") - print() - - result = init_skill(skill_name, path) - - if result: - sys.exit(0) - else: - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/.rulesync/skills/skill-creator/scripts/package_skill.py b/.rulesync/skills/skill-creator/scripts/package_skill.py deleted file mode 100755 index 5cd36cb16..000000000 --- a/.rulesync/skills/skill-creator/scripts/package_skill.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env python3 -""" -Skill Packager - Creates a distributable .skill file of a skill folder - -Usage: - python utils/package_skill.py [output-directory] - -Example: - python utils/package_skill.py skills/public/my-skill - python utils/package_skill.py skills/public/my-skill ./dist -""" - -import sys -import zipfile -from pathlib import Path -from quick_validate import validate_skill - - -def package_skill(skill_path, output_dir=None): - """ - Package a skill folder into a .skill file. - - Args: - skill_path: Path to the skill folder - output_dir: Optional output directory for the .skill file (defaults to current directory) - - Returns: - Path to the created .skill file, or None if error - """ - skill_path = Path(skill_path).resolve() - - # Validate skill folder exists - if not skill_path.exists(): - print(f"❌ Error: Skill folder not found: {skill_path}") - return None - - if not skill_path.is_dir(): - print(f"❌ Error: Path is not a directory: {skill_path}") - return None - - # Validate SKILL.md exists - skill_md = skill_path / "SKILL.md" - if not skill_md.exists(): - print(f"❌ Error: SKILL.md not found in {skill_path}") - return None - - # Run validation before packaging - print("🔍 Validating skill...") - valid, message = validate_skill(skill_path) - if not valid: - print(f"❌ Validation failed: {message}") - print(" Please fix the validation errors before packaging.") - return None - print(f"✅ {message}\n") - - # Determine output location - skill_name = skill_path.name - if output_dir: - output_path = Path(output_dir).resolve() - output_path.mkdir(parents=True, exist_ok=True) - else: - output_path = Path.cwd() - - skill_filename = output_path / f"{skill_name}.skill" - - # Create the .skill file (zip format) - try: - with zipfile.ZipFile(skill_filename, 'w', zipfile.ZIP_DEFLATED) as zipf: - # Walk through the skill directory - for file_path in skill_path.rglob('*'): - if file_path.is_file(): - # Calculate the relative path within the zip - arcname = file_path.relative_to(skill_path.parent) - zipf.write(file_path, arcname) - print(f" Added: {arcname}") - - print(f"\n✅ Successfully packaged skill to: {skill_filename}") - return skill_filename - - except Exception as e: - print(f"❌ Error creating .skill file: {e}") - return None - - -def main(): - if len(sys.argv) < 2: - print("Usage: python utils/package_skill.py [output-directory]") - print("\nExample:") - print(" python utils/package_skill.py skills/public/my-skill") - print(" python utils/package_skill.py skills/public/my-skill ./dist") - sys.exit(1) - - skill_path = sys.argv[1] - output_dir = sys.argv[2] if len(sys.argv) > 2 else None - - print(f"📦 Packaging skill: {skill_path}") - if output_dir: - print(f" Output directory: {output_dir}") - print() - - result = package_skill(skill_path, output_dir) - - if result: - sys.exit(0) - else: - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/.rulesync/skills/skill-creator/scripts/quick_validate.py b/.rulesync/skills/skill-creator/scripts/quick_validate.py deleted file mode 100755 index d9fbeb75e..000000000 --- a/.rulesync/skills/skill-creator/scripts/quick_validate.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python3 -""" -Quick validation script for skills - minimal version -""" - -import sys -import os -import re -import yaml -from pathlib import Path - -def validate_skill(skill_path): - """Basic validation of a skill""" - skill_path = Path(skill_path) - - # Check SKILL.md exists - skill_md = skill_path / 'SKILL.md' - if not skill_md.exists(): - return False, "SKILL.md not found" - - # Read and validate frontmatter - content = skill_md.read_text() - if not content.startswith('---'): - return False, "No YAML frontmatter found" - - # Extract frontmatter - match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) - if not match: - return False, "Invalid frontmatter format" - - frontmatter_text = match.group(1) - - # Parse YAML frontmatter - try: - frontmatter = yaml.safe_load(frontmatter_text) - if not isinstance(frontmatter, dict): - return False, "Frontmatter must be a YAML dictionary" - except yaml.YAMLError as e: - return False, f"Invalid YAML in frontmatter: {e}" - - # Define allowed properties - ALLOWED_PROPERTIES = {'name', 'description', 'license', 'allowed-tools', 'metadata'} - - # Check for unexpected properties (excluding nested keys under metadata) - unexpected_keys = set(frontmatter.keys()) - ALLOWED_PROPERTIES - if unexpected_keys: - return False, ( - f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}. " - f"Allowed properties are: {', '.join(sorted(ALLOWED_PROPERTIES))}" - ) - - # Check required fields - if 'name' not in frontmatter: - return False, "Missing 'name' in frontmatter" - if 'description' not in frontmatter: - return False, "Missing 'description' in frontmatter" - - # Extract name for validation - name = frontmatter.get('name', '') - if not isinstance(name, str): - return False, f"Name must be a string, got {type(name).__name__}" - name = name.strip() - if name: - # Check naming convention (hyphen-case: lowercase with hyphens) - if not re.match(r'^[a-z0-9-]+$', name): - return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)" - if name.startswith('-') or name.endswith('-') or '--' in name: - return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens" - # Check name length (max 64 characters per spec) - if len(name) > 64: - return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters." - - # Extract and validate description - description = frontmatter.get('description', '') - if not isinstance(description, str): - return False, f"Description must be a string, got {type(description).__name__}" - description = description.strip() - if description: - # Check for angle brackets - if '<' in description or '>' in description: - return False, "Description cannot contain angle brackets (< or >)" - # Check description length (max 1024 characters per spec) - if len(description) > 1024: - return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters." - - return True, "Skill is valid!" - -if __name__ == "__main__": - if len(sys.argv) != 2: - print("Usage: python quick_validate.py ") - sys.exit(1) - - valid, message = validate_skill(sys.argv[1]) - print(message) - sys.exit(0 if valid else 1) \ No newline at end of file diff --git a/cspell.json b/cspell.json index 7a2d7c0cd..1c324cba1 100644 --- a/cspell.json +++ b/cspell.json @@ -143,10 +143,7 @@ "kimuson", "kilo", "Kilo", - "kilocode", - "Kilocode", - "kilocodeignore", - "kilocoderules", + "kiloignore", "kilorules", "kiro", "Kiro", diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index 6296d5244..0782cc1e2 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -163,11 +163,11 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ { target: "junie", feature: "subagents", entry: "**/.junie/agents/" }, // Kilo Code - { target: "kilo", feature: "rules", entry: "**/.kilocode/rules/" }, - { target: "kilo", feature: "skills", entry: "**/.kilocode/skills/" }, - { target: "kilo", feature: "commands", entry: "**/.kilocode/workflows/" }, - { target: "kilo", feature: "mcp", entry: "**/.kilocode/mcp.json" }, - { target: "kilo", feature: "ignore", entry: "**/.kilocodeignore" }, + { target: "kilo", feature: "rules", entry: "**/.kilo/rules/" }, + { target: "kilo", feature: "skills", entry: "**/.kilo/skills/" }, + { target: "kilo", feature: "commands", entry: "**/.kilo/workflows/" }, + { target: "kilo", feature: "mcp", entry: "**/.kilo/mcp.json" }, + { target: "kilo", feature: "ignore", entry: "**/.kiloignore" }, // Kiro { target: "kiro", feature: "rules", entry: "**/.kiro/steering/" }, diff --git a/src/cli/commands/gitignore.test.ts b/src/cli/commands/gitignore.test.ts index a6fd47b1b..eb35744c7 100644 --- a/src/cli/commands/gitignore.test.ts +++ b/src/cli/commands/gitignore.test.ts @@ -69,9 +69,9 @@ describe("gitignoreCommand", () => { expect(content).toContain("**/.opencode/agent/"); expect(content).toContain("**/.gemini/memories/"); expect(content).toContain("**/.roo/rules/"); - expect(content).toContain("**/.kilocode/skills/"); - expect(content).toContain("**/.kilocode/rules/"); - expect(content).toContain("**/.kilocode/workflows/"); + expect(content).toContain("**/.kilo/skills/"); + expect(content).toContain("**/.kilo/rules/"); + expect(content).toContain("**/.kilo/workflows/"); expect(content).toContain("**/.roo/skills/"); expect(content).toContain("**/.aiignore"); expect(content).toContain("**/.mcp.json"); @@ -232,7 +232,7 @@ dist/`; expect(mockLogger.success).toHaveBeenCalledWith("Updated .gitignore with rulesync entries:"); expect(mockLogger.info).toHaveBeenCalledWith(" **/.cursor/"); - expect(mockLogger.info).toHaveBeenCalledWith(" **/.kilocode/rules/"); + expect(mockLogger.info).toHaveBeenCalledWith(" **/.kilo/rules/"); expect(mockLogger.info).toHaveBeenCalledWith(" **/.opencode/agent/"); expect(mockLogger.info).toHaveBeenCalledWith(" **/CLAUDE.md"); }); diff --git a/src/features/commands/commands-processor.test.ts b/src/features/commands/commands-processor.test.ts index fc150b77c..152531d75 100644 --- a/src/features/commands/commands-processor.test.ts +++ b/src/features/commands/commands-processor.test.ts @@ -147,7 +147,7 @@ vi.mocked(KiloCommand).fromRulesyncCommand = vi.fn(); vi.mocked(KiloCommand).isTargetedByRulesyncCommand = vi.fn().mockReturnValue(true); vi.mocked(KiloCommand).getSettablePaths = vi .fn() - .mockReturnValue({ relativeDirPath: join(".kilocode", "workflows") }); + .mockReturnValue({ relativeDirPath: join(".kilo", "workflows") }); vi.mocked(KiloCommand).forDeletion = vi.fn().mockImplementation((params) => ({ ...params, isDeletable: () => true, 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..154cee112 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 { 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: z.optional(z.string()), + subtask: z.optional(z.boolean()), + model: z.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,51 @@ export class KiloCommand extends ToolCommand { } validate(): ValidationResult { - 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 +170,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..dfff13cdc --- /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..f18cf3d02 --- /dev/null +++ b/src/features/hooks/kilo-hooks.ts @@ -0,0 +1,95 @@ +import { join } from "node:path"; + +import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { CANONICAL_TO_KILO_EVENT_NAMES, KILO_HOOK_EVENTS } from "../../types/hooks.js"; +import { readFileContent } from "../../utils/file.js"; +import { generateOpencodeStylePluginCode } from "./opencode-style-generator.js"; +import type { RulesyncHooks } from "./rulesync-hooks.js"; +import { + ToolHooks, + type ToolHooksForDeletionParams, + type ToolHooksFromFileParams, + type ToolHooksFromRulesyncHooksParams, + type ToolHooksSettablePaths, +} from "./tool-hooks.js"; + +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 = generateOpencodeStylePluginCode( + config, + KILO_HOOK_EVENTS, + "kilo", + CANONICAL_TO_KILO_EVENT_NAMES, + ); + 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/hooks/opencode-hooks.test.ts b/src/features/hooks/opencode-hooks.test.ts index a7b41b972..d5568ba6e 100644 --- a/src/features/hooks/opencode-hooks.test.ts +++ b/src/features/hooks/opencode-hooks.test.ts @@ -236,8 +236,8 @@ describe("OpencodeHooks", () => { [ "export const RulesyncHooksPlugin = async ({ $ }) => {", " return {", - " }", - "}", + " };", + "};", "", ].join("\n"), ); diff --git a/src/features/hooks/opencode-hooks.ts b/src/features/hooks/opencode-hooks.ts index ac6584c1b..c30238f7d 100644 --- a/src/features/hooks/opencode-hooks.ts +++ b/src/features/hooks/opencode-hooks.ts @@ -1,13 +1,9 @@ import { join } from "node:path"; import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; -import type { HooksConfig } from "../../types/hooks.js"; -import { - CANONICAL_TO_OPENCODE_EVENT_NAMES, - CONTROL_CHARS, - OPENCODE_HOOK_EVENTS, -} from "../../types/hooks.js"; +import { CANONICAL_TO_OPENCODE_EVENT_NAMES, OPENCODE_HOOK_EVENTS } from "../../types/hooks.js"; import { readFileContent } from "../../utils/file.js"; +import { generateOpencodeStylePluginCode } from "./opencode-style-generator.js"; import type { RulesyncHooks } from "./rulesync-hooks.js"; import { ToolHooks, @@ -17,153 +13,6 @@ import { type ToolHooksSettablePaths, } from "./tool-hooks.js"; -/** - * OpenCode 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 OpencodeHandler = { - command: string; - matcher?: string; -}; - -type OpencodeHandlerGroup = Record; - -/** - * Group canonical hook definitions by their OpenCode event name. - * Filters to command-type hooks and maps canonical events to OpenCode events. - */ -function groupByOpencodeEvent(config: HooksConfig): { - namedEventHandlers: OpencodeHandlerGroup; - genericEventHandlers: OpencodeHandlerGroup; -} { - const opencodeSupported: Set = new Set(OPENCODE_HOOK_EVENTS); - const configHooks = { ...config.hooks, ...config.opencode?.hooks }; - const effectiveHooks: HooksConfig["hooks"] = {}; - - for (const [event, defs] of Object.entries(configHooks)) { - if (opencodeSupported.has(event)) { - effectiveHooks[event] = defs; - } - } - - const namedEventHandlers: Record = {}; - const genericEventHandlers: Record = {}; - for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) { - const opencodeEvent = CANONICAL_TO_OPENCODE_EVENT_NAMES[canonicalEvent]; - if (!opencodeEvent) continue; - - const handlers: OpencodeHandler[] = []; - 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(opencodeEvent) ? namedEventHandlers : genericEventHandlers; - const existing = grouped[opencodeEvent]; - if (existing) { - existing.push(...handlers); - } else { - grouped[opencodeEvent] = handlers; - } - } - } - - return { namedEventHandlers, genericEventHandlers }; -} - -/** - * Generate the JavaScript plugin file content from canonical hooks config. - * - * OpenCode 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 } = groupByOpencodeEvent(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 OpencodeHooks extends ToolHooks { constructor(params: AiFileParams) { super({ @@ -206,7 +55,12 @@ export class OpencodeHooks extends ToolHooks { global = false, }: ToolHooksFromRulesyncHooksParams & { global?: boolean }): OpencodeHooks { const config = rulesyncHooks.getJson(); - const fileContent = generatePluginCode(config); + const fileContent = generateOpencodeStylePluginCode( + config, + OPENCODE_HOOK_EVENTS, + "opencode", + CANONICAL_TO_OPENCODE_EVENT_NAMES, + ); const paths = OpencodeHooks.getSettablePaths({ global }); return new OpencodeHooks({ baseDir, diff --git a/src/features/hooks/opencode-style-generator.ts b/src/features/hooks/opencode-style-generator.ts new file mode 100644 index 000000000..96fcc989d --- /dev/null +++ b/src/features/hooks/opencode-style-generator.ts @@ -0,0 +1,108 @@ +import { HooksConfig, CONTROL_CHARS } from "../../types/hooks.js"; + +const NAMED_HOOKS = new Set(["tool.execute.before", "tool.execute.after"]); + +function escapeForTemplateLiteral(command: string): string { + return command.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); +} + +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 Handler = { command: string; matcher?: string }; +type HandlerGroup = Record; + +export function generateOpencodeStylePluginCode( + config: HooksConfig, + supportedEvents: readonly string[], + toolConfigKey: "kilo" | "opencode", + eventMap: Record, +): string { + const supported: Set = new Set(supportedEvents); + const configHooks = { ...config.hooks, ...config[toolConfigKey]?.hooks }; + const effectiveHooks: HooksConfig["hooks"] = {}; + + for (const [event, defs] of Object.entries(configHooks)) { + if (supported.has(event)) effectiveHooks[event] = defs; + } + + const namedEventHandlers: HandlerGroup = {}; + const genericEventHandlers: HandlerGroup = {}; + + for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) { + const toolEvent = eventMap[canonicalEvent]; + if (!toolEvent) continue; + + const handlers: Handler[] = []; + for (const def of definitions) { + 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(toolEvent) ? namedEventHandlers : genericEventHandlers; + const existing = grouped[toolEvent]; + if (existing) { + existing.push(...handlers); + } else { + grouped[toolEvent] = handlers; + } + } + } + + const lines: string[] = []; + lines.push("export const RulesyncHooksPlugin = async ({ $ }) => {"); + lines.push(" return {"); + + if (Object.keys(genericEventHandlers).length > 0) { + lines.push(" event: async ({ event }) => {"); + let isFirst = true; + for (const [eventName, handlers] of Object.entries(genericEventHandlers)) { + lines.push(` ${isFirst ? "if" : "else if"} (event.type === "${eventName}") {`); + isFirst = false; + for (const handler of handlers) { + const escapedCommand = escapeForTemplateLiteral(handler.command); + lines.push(` await $\`${escapedCommand}\`;`); + } + lines.push(" }"); + } + lines.push(" },"); + } + + 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(` const __re = new RegExp("${safeMatcher}");`); + lines.push(` if (__re.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"); +} diff --git a/src/features/ignore/kilo-ignore.test.ts b/src/features/ignore/kilo-ignore.test.ts index acf25b2ef..8575729d5 100644 --- a/src/features/ignore/kilo-ignore.test.ts +++ b/src/features/ignore/kilo-ignore.test.ts @@ -29,13 +29,13 @@ describe("KiloIgnore", () => { it("should create instance with default parameters", () => { const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent: "*.log\nnode_modules/", }); expect(kiloIgnore).toBeInstanceOf(KiloIgnore); expect(kiloIgnore.getRelativeDirPath()).toBe("."); - expect(kiloIgnore.getRelativeFilePath()).toBe(".kilocodeignore"); + expect(kiloIgnore.getRelativeFilePath()).toBe(".kiloignore"); expect(kiloIgnore.getFileContent()).toBe("*.log\nnode_modules/"); }); @@ -43,18 +43,18 @@ describe("KiloIgnore", () => { const kiloIgnore = new KiloIgnore({ baseDir: "/custom/path", relativeDirPath: "subdir", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent: "*.tmp", }); - expect(kiloIgnore.getFilePath()).toBe("/custom/path/subdir/.kilocodeignore"); + expect(kiloIgnore.getFilePath()).toBe("/custom/path/subdir/.kiloignore"); }); it("should validate content by default", () => { expect(() => { const _instance = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent: "", // empty content should be valid }); }).not.toThrow(); @@ -64,7 +64,7 @@ describe("KiloIgnore", () => { expect(() => { const _instance = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent: "any content", validate: false, }); @@ -78,7 +78,7 @@ describe("KiloIgnore", () => { const kiloIgnore = new KiloIgnore({ baseDir: testDir, relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent, }); @@ -94,7 +94,7 @@ describe("KiloIgnore", () => { const kiloIgnore = new KiloIgnore({ baseDir: testDir, relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent: "", }); @@ -108,7 +108,7 @@ describe("KiloIgnore", () => { const kiloIgnore = new KiloIgnore({ baseDir: testDir, relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent, }); @@ -134,7 +134,7 @@ describe("KiloIgnore", () => { expect(kiloIgnore).toBeInstanceOf(KiloIgnore); expect(kiloIgnore.getBaseDir()).toBe(testDir); expect(kiloIgnore.getRelativeDirPath()).toBe("."); - expect(kiloIgnore.getRelativeFilePath()).toBe(".kilocodeignore"); + expect(kiloIgnore.getRelativeFilePath()).toBe(".kiloignore"); expect(kiloIgnore.getFileContent()).toBe(fileContent); }); @@ -152,7 +152,7 @@ describe("KiloIgnore", () => { }); expect(kiloIgnore.getBaseDir()).toBe("/custom/base"); - expect(kiloIgnore.getFilePath()).toBe("/custom/base/.kilocodeignore"); + expect(kiloIgnore.getFilePath()).toBe("/custom/base/.kiloignore"); expect(kiloIgnore.getFileContent()).toBe(fileContent); }); @@ -187,10 +187,10 @@ describe("KiloIgnore", () => { }); describe("fromFile", () => { - it("should read .kilocodeignore file from baseDir with default baseDir", async () => { + it("should read .kiloignore file from baseDir with default baseDir", async () => { const fileContent = "*.log\nnode_modules/\n.env"; - const kilocodeignorePath = join(testDir, ".kilocodeignore"); - await writeFileContent(kilocodeignorePath, fileContent); + const kiloignorePath = join(testDir, ".kiloignore"); + await writeFileContent(kiloignorePath, fileContent); const kiloIgnore = await KiloIgnore.fromFile({ baseDir: testDir, @@ -199,14 +199,14 @@ describe("KiloIgnore", () => { expect(kiloIgnore).toBeInstanceOf(KiloIgnore); expect(kiloIgnore.getBaseDir()).toBe(testDir); expect(kiloIgnore.getRelativeDirPath()).toBe("."); - expect(kiloIgnore.getRelativeFilePath()).toBe(".kilocodeignore"); + expect(kiloIgnore.getRelativeFilePath()).toBe(".kiloignore"); expect(kiloIgnore.getFileContent()).toBe(fileContent); }); - it("should read .kilocodeignore file with validation enabled by default", async () => { + it("should read .kiloignore file with validation enabled by default", async () => { const fileContent = "*.log\nnode_modules/"; - const kilocodeignorePath = join(testDir, ".kilocodeignore"); - await writeFileContent(kilocodeignorePath, fileContent); + const kiloignorePath = join(testDir, ".kiloignore"); + await writeFileContent(kiloignorePath, fileContent); const kiloIgnore = await KiloIgnore.fromFile({ baseDir: testDir, @@ -215,10 +215,10 @@ describe("KiloIgnore", () => { expect(kiloIgnore.getFileContent()).toBe(fileContent); }); - it("should read .kilocodeignore file with validation disabled", async () => { + it("should read .kiloignore file with validation disabled", async () => { const fileContent = "*.log\nnode_modules/"; - const kilocodeignorePath = join(testDir, ".kilocodeignore"); - await writeFileContent(kilocodeignorePath, fileContent); + const kiloignorePath = join(testDir, ".kiloignore"); + await writeFileContent(kiloignorePath, fileContent); const kiloIgnore = await KiloIgnore.fromFile({ baseDir: testDir, @@ -228,9 +228,9 @@ describe("KiloIgnore", () => { expect(kiloIgnore.getFileContent()).toBe(fileContent); }); - it("should handle empty .kilocodeignore file", async () => { - const kilocodeignorePath = join(testDir, ".kilocodeignore"); - await writeFileContent(kilocodeignorePath, ""); + it("should handle empty .kiloignore file", async () => { + const kiloignorePath = join(testDir, ".kiloignore"); + await writeFileContent(kiloignorePath, ""); const kiloIgnore = await KiloIgnore.fromFile({ baseDir: testDir, @@ -239,7 +239,7 @@ describe("KiloIgnore", () => { expect(kiloIgnore.getFileContent()).toBe(""); }); - it("should handle .kilocodeignore file with complex patterns", async () => { + it("should handle .kiloignore file with complex patterns", async () => { const fileContent = `# Build outputs build/ dist/ @@ -270,8 +270,8 @@ logs/ .DS_Store Thumbs.db`; - const kilocodeignorePath = join(testDir, ".kilocodeignore"); - await writeFileContent(kilocodeignorePath, fileContent); + const kiloignorePath = join(testDir, ".kiloignore"); + await writeFileContent(kiloignorePath, fileContent); const kiloIgnore = await KiloIgnore.fromFile({ baseDir: testDir, @@ -283,8 +283,8 @@ Thumbs.db`; it("should default baseDir to process.cwd() when not provided", async () => { // process.cwd() is already mocked to return testDir in beforeEach const fileContent = "*.log\nnode_modules/"; - const kilocodeignorePath = join(testDir, ".kilocodeignore"); - await writeFileContent(kilocodeignorePath, fileContent); + const kiloignorePath = join(testDir, ".kiloignore"); + await writeFileContent(kiloignorePath, fileContent); const kiloIgnore = await KiloIgnore.fromFile({}); @@ -292,7 +292,7 @@ Thumbs.db`; expect(kiloIgnore.getFileContent()).toBe(fileContent); }); - it("should throw error when .kilocodeignore file does not exist", async () => { + it("should throw error when .kiloignore file does not exist", async () => { await expect( KiloIgnore.fromFile({ baseDir: testDir, @@ -302,8 +302,8 @@ Thumbs.db`; it("should handle file with Windows line endings", async () => { const fileContent = "*.log\r\nnode_modules/\r\n.env"; - const kilocodeignorePath = join(testDir, ".kilocodeignore"); - await writeFileContent(kilocodeignorePath, fileContent); + const kiloignorePath = join(testDir, ".kiloignore"); + await writeFileContent(kiloignorePath, fileContent); const kiloIgnore = await KiloIgnore.fromFile({ baseDir: testDir, @@ -318,7 +318,7 @@ Thumbs.db`; const fileContent = "*.log\nnode_modules/\n.env"; const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent, }); @@ -331,7 +331,7 @@ Thumbs.db`; it("should inherit validation method", () => { const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent: "*.log\nnode_modules/", }); @@ -345,14 +345,14 @@ Thumbs.db`; const kiloIgnore = new KiloIgnore({ baseDir: "/test/base", relativeDirPath: "subdir", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent: "*.log", }); expect(kiloIgnore.getBaseDir()).toBe("/test/base"); expect(kiloIgnore.getRelativeDirPath()).toBe("subdir"); - expect(kiloIgnore.getRelativeFilePath()).toBe(".kilocodeignore"); - expect(kiloIgnore.getFilePath()).toBe("/test/base/subdir/.kilocodeignore"); + expect(kiloIgnore.getRelativeFilePath()).toBe(".kiloignore"); + expect(kiloIgnore.getFilePath()).toBe("/test/base/subdir/.kiloignore"); expect(kiloIgnore.getFileContent()).toBe("*.log"); }); }); @@ -371,7 +371,7 @@ dist/ const originalKiloIgnore = new KiloIgnore({ baseDir: testDir, relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent: originalContent, }); @@ -384,7 +384,7 @@ dist/ expect(roundTripKiloIgnore.getFileContent()).toBe(originalContent); expect(roundTripKiloIgnore.getBaseDir()).toBe(testDir); expect(roundTripKiloIgnore.getRelativeDirPath()).toBe("."); - expect(roundTripKiloIgnore.getRelativeFilePath()).toBe(".kilocodeignore"); + expect(roundTripKiloIgnore.getRelativeFilePath()).toBe(".kiloignore"); }); it("should maintain patterns in round-trip conversion", () => { @@ -393,7 +393,7 @@ dist/ const originalKiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent: originalContent, }); @@ -410,7 +410,7 @@ dist/ it("should handle file content with only whitespace", () => { const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent: " \n\t\n ", }); @@ -423,7 +423,7 @@ dist/ const fileContent = "*.log\r\nnode_modules/\n.env\r\nbuild/"; const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent, }); @@ -434,7 +434,7 @@ dist/ const longPattern = "a".repeat(1000); const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent: longPattern, }); @@ -446,7 +446,7 @@ dist/ const unicodeContent = "*.log\nnode_modules/\nenvironment.env\nbuild/"; const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent: unicodeContent, }); @@ -466,7 +466,7 @@ dist/ const kiloIgnore = new KiloIgnore({ baseDir: testDir, relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent, }); @@ -490,7 +490,7 @@ dist/ const kiloIgnore = new KiloIgnore({ baseDir: testDir, relativeDirPath: "project/config", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent, }); @@ -506,14 +506,14 @@ dist/ }); describe("Kilo Code-specific behavior", () => { - it("should use .kilocodeignore as the filename", () => { + it("should use .kiloignore as the filename", () => { const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent: "*.log", }); - expect(kiloIgnore.getRelativeFilePath()).toBe(".kilocodeignore"); + expect(kiloIgnore.getRelativeFilePath()).toBe(".kiloignore"); }); it("should work with gitignore syntax patterns", () => { @@ -530,7 +530,7 @@ temp*/ const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent, }); @@ -555,7 +555,7 @@ temp*/ const fileContent = "# This should reflect immediately\n*.log\ntemp/"; const kiloIgnore = new KiloIgnore({ relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", fileContent, }); @@ -573,10 +573,10 @@ temp*/ }), }); - // Should always place .kilocodeignore in root (relativeDirPath: ".") + // Should always place .kiloignore in root (relativeDirPath: ".") expect(kiloIgnore.getRelativeDirPath()).toBe("."); - expect(kiloIgnore.getRelativeFilePath()).toBe(".kilocodeignore"); - expect(kiloIgnore.getFilePath()).toBe("/workspace/root/.kilocodeignore"); + expect(kiloIgnore.getRelativeFilePath()).toBe(".kiloignore"); + expect(kiloIgnore.getFilePath()).toBe("/workspace/root/.kiloignore"); }); }); }); diff --git a/src/features/ignore/kilo-ignore.ts b/src/features/ignore/kilo-ignore.ts index 32e4e9bd2..e09edf5c5 100644 --- a/src/features/ignore/kilo-ignore.ts +++ b/src/features/ignore/kilo-ignore.ts @@ -14,7 +14,7 @@ import { * KiloIgnore represents ignore patterns for the Kilo Code VSCode extension. * * Based on the Kilo Code specification: - * - File location: Workspace root folder only (.kilocodeignore) + * - File location: Workspace root folder only (.kiloignore) * - Syntax: Same as .gitignore * - Immediate reflection when saved * - Complete blocking of file access for ignored patterns @@ -24,7 +24,7 @@ export class KiloIgnore extends ToolIgnore { static getSettablePaths(): ToolIgnoreSettablePaths { return { relativeDirPath: ".", - relativeFilePath: ".kilocodeignore", + relativeFilePath: ".kiloignore", }; } @@ -53,7 +53,7 @@ export class KiloIgnore extends ToolIgnore { } /** - * Load KiloIgnore from .kilocodeignore file + * Load KiloIgnore from .kiloignore file */ static async fromFile({ baseDir = process.cwd(), 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..9c2928aba --- /dev/null +++ b/src/features/subagents/kilo-subagent.ts @@ -0,0 +1,123 @@ +import { join } from "node:path"; + +import { ToolTarget } from "../../types/tool-targets.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContent } from "../../utils/file.js"; +import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter.js"; +import { + OpenCodeStyleSubagent, + OpenCodeStyleSubagentFrontmatter, + OpenCodeStyleSubagentFrontmatterSchema, + OpenCodeStyleSubagentParams, +} from "./opencode-style-subagent.js"; +import { RulesyncSubagent } from "./rulesync-subagent.js"; +import { + ToolSubagent, + ToolSubagentForDeletionParams, + ToolSubagentFromFileParams, + ToolSubagentFromRulesyncSubagentParams, + ToolSubagentSettablePaths, +} from "./tool-subagent.js"; + +export const KiloSubagentFrontmatterSchema = OpenCodeStyleSubagentFrontmatterSchema; +export type KiloSubagentFrontmatter = OpenCodeStyleSubagentFrontmatter; +export type KiloSubagentParams = OpenCodeStyleSubagentParams; + +export class KiloSubagent extends OpenCodeStyleSubagent { + protected getToolTarget(): Extract { + return "kilo"; + } + + static getSettablePaths({ + global = false, + }: { + global?: boolean; + } = {}): ToolSubagentSettablePaths { + return { + relativeDirPath: global ? join(".config", "kilo", "agent") : join(".kilo", "agent"), + }; + } + + 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, + }); + } + + 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/opencode-style-subagent.ts b/src/features/subagents/opencode-style-subagent.ts new file mode 100644 index 000000000..7dc32187a --- /dev/null +++ b/src/features/subagents/opencode-style-subagent.ts @@ -0,0 +1,91 @@ +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 { ToolTarget } from "../../types/tool-targets.js"; +import { formatError } from "../../utils/error.js"; +import { RulesyncSubagent, RulesyncSubagentFrontmatter } from "./rulesync-subagent.js"; +import { ToolSubagent } from "./tool-subagent.js"; + +export const OpenCodeStyleSubagentFrontmatterSchema = z.looseObject({ + description: z.optional(z.string()), + mode: z._default(z.string(), "subagent"), + name: z.optional(z.string()), +}); + +export type OpenCodeStyleSubagentFrontmatter = z.infer< + typeof OpenCodeStyleSubagentFrontmatterSchema +>; + +export type OpenCodeStyleSubagentParams = { + frontmatter: OpenCodeStyleSubagentFrontmatter; + body: string; +} & AiFileParams; + +export abstract class OpenCodeStyleSubagent extends ToolSubagent { + protected readonly frontmatter: OpenCodeStyleSubagentFrontmatter; + protected readonly body: string; + + constructor({ frontmatter, body, ...rest }: OpenCodeStyleSubagentParams) { + if (rest.validate !== false) { + const result = OpenCodeStyleSubagentFrontmatterSchema.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; + } + + protected abstract getToolTarget(): Extract; + + getFrontmatter(): OpenCodeStyleSubagentFrontmatter { + return this.frontmatter; + } + + getBody(): string { + return this.body; + } + + toRulesyncSubagent(): RulesyncSubagent { + const { description, mode, name, ...toolSection } = this.frontmatter; + const rulesyncFrontmatter: RulesyncSubagentFrontmatter = { + targets: ["*"] as const, + name: name ?? basename(this.getRelativeFilePath(), ".md"), + description, + [this.getToolTarget()]: { mode, ...toolSection }, + }; + + 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, + }); + } + + validate(): ValidationResult { + const result = OpenCodeStyleSubagentFrontmatterSchema.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)}`, + ), + }; + } +} diff --git a/src/features/subagents/opencode-subagent.ts b/src/features/subagents/opencode-subagent.ts index 9a3009102..e7cab1262 100644 --- a/src/features/subagents/opencode-subagent.ts +++ b/src/features/subagents/opencode-subagent.ts @@ -1,13 +1,16 @@ -import { basename, join } from "node:path"; +import { 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 { ToolTarget } from "../../types/tool-targets.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 { + OpenCodeStyleSubagent, + OpenCodeStyleSubagentFrontmatter, + OpenCodeStyleSubagentFrontmatterSchema, + OpenCodeStyleSubagentParams, +} from "./opencode-style-subagent.js"; +import { RulesyncSubagent } from "./rulesync-subagent.js"; import { ToolSubagent, ToolSubagentForDeletionParams, @@ -16,39 +19,13 @@ import { ToolSubagentSettablePaths, } from "./tool-subagent.js"; -export const OpenCodeSubagentFrontmatterSchema = z.looseObject({ - description: z.optional(z.string()), - mode: z._default(z.string(), "subagent"), - name: z.optional(z.string()), -}); - -export type OpenCodeSubagentFrontmatter = z.infer; - -export type OpenCodeSubagentParams = { - frontmatter: OpenCodeSubagentFrontmatter; - body: string; -} & AiFileParams; - -export class OpenCodeSubagent extends ToolSubagent { - private readonly frontmatter: OpenCodeSubagentFrontmatter; - private readonly body: string; - - constructor({ frontmatter, body, ...rest }: OpenCodeSubagentParams) { - if (rest.validate !== false) { - const result = OpenCodeSubagentFrontmatterSchema.safeParse(frontmatter); - if (!result.success) { - throw new Error( - `Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`, - ); - } - } +export const OpenCodeSubagentFrontmatterSchema = OpenCodeStyleSubagentFrontmatterSchema; +export type OpenCodeSubagentFrontmatter = OpenCodeStyleSubagentFrontmatter; +export type OpenCodeSubagentParams = OpenCodeStyleSubagentParams; - super({ - ...rest, - }); - - this.frontmatter = frontmatter; - this.body = body; +export class OpenCodeSubagent extends OpenCodeStyleSubagent { + protected getToolTarget(): Extract { + return "opencode"; } static getSettablePaths({ @@ -61,33 +38,6 @@ export class OpenCodeSubagent extends ToolSubagent { }; } - getFrontmatter(): OpenCodeSubagentFrontmatter { - return this.frontmatter; - } - - getBody(): string { - return this.body; - } - - toRulesyncSubagent(): RulesyncSubagent { - const { description, mode, name, ...opencodeSection } = this.frontmatter; - const rulesyncFrontmatter: RulesyncSubagentFrontmatter = { - targets: ["*"] as const, - name: name ?? basename(this.getRelativeFilePath(), ".md"), - description, - opencode: { mode, ...opencodeSection }, - }; - - 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, @@ -120,24 +70,6 @@ export class OpenCodeSubagent extends ToolSubagent { }); } - validate(): ValidationResult { - if (!this.frontmatter) { - return { success: true, error: null }; - } - - const result = OpenCodeSubagentFrontmatterSchema.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, 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 = { permissionRequest: "permission.asked", }; +/** + * Map canonical camelCase event names to Kilo dot-notation. + * (Currently identical to OpenCode) + */ +export const CANONICAL_TO_KILO_EVENT_NAMES: Record = + CANONICAL_TO_OPENCODE_EVENT_NAMES; + /** * Map canonical camelCase event names to Copilot camelCase. */