diff --git a/README.md b/README.md index ee617a4..f065d84 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ vibe-rules load my-rule-name cursor -t ./my-project/.cursor-rules/custom-rule.md Arguments: - ``: The name of the rule saved in the local store (`~/.vibe-rules/rules/`). -- ``: The target editor/tool type. Supported: `cursor`, `windsurf`, `claude-code`, `gemini`, `codex`, `clinerules`, `roo`, `vscode`. +- ``: The target editor/tool type. Supported: `cursor`, `windsurf`, `claude-code`, `gemini`, `codex`, `clinerules`, `roo`, `vscode`, `kiro`. Options: @@ -126,7 +126,7 @@ vibe-rules convert vscode unified .github/instructions --global Arguments: -- ``: Source editor format (cursor, windsurf, claude-code, codex, amp, clinerules, roo, zed, unified, vscode). +- ``: Source editor format (cursor, windsurf, claude-code, codex, amp, clinerules, roo, zed, unified, vscode, kiro). - ``: Target editor format (same options as source). - ``: Path to source directory (like `.cursor`) or file (like `CLAUDE.md`). @@ -163,7 +163,7 @@ vibe-rules convert cursor claude-code .cursor --target my-claude.md **Supported conversions:** -- **Directory-based**: `cursor` ↔ `clinerules` ↔ `vscode` (individual files) +- **Directory-based**: `cursor` ↔ `clinerules` ↔ `vscode` ↔ `kiro` (individual files) - **File-based**: `windsurf` ↔ `claude-code` ↔ `codex` ↔ `amp` ↔ `zed` ↔ `unified` (tagged blocks) - **Cross-format**: Any format to any other format with automatic metadata preservation @@ -193,7 +193,7 @@ Add the `--debug` global option to any `vibe-rules` command to enable detailed d Arguments: -- ``: The target editor/tool type (mandatory). Supported: `cursor`, `windsurf`, `claude-code`, `gemini`, `codex`, `clinerules`, `roo`, `vscode`. +- ``: The target editor/tool type (mandatory). Supported: `cursor`, `windsurf`, `claude-code`, `gemini`, `codex`, `clinerules`, `roo`, `vscode`, `kiro`. - `[packageName]` (Optional): The specific NPM package name to install rules from. If omitted, `vibe-rules` scans all dependencies and devDependencies in your project's `package.json`. Options: @@ -236,7 +236,7 @@ vibe-rules uninstall my-package_my-rule vscode Arguments: - ``: The fully-qualified rule name as applied (typically `_`) -- ``: Target editor type. Supported: `cursor`, `windsurf`, `claude-code`, `codex`, `amp`, `clinerules`, `roo`, `zed`, `unified`, `vscode`. +- ``: Target editor type. Supported: `cursor`, `windsurf`, `claude-code`, `codex`, `amp`, `clinerules`, `roo`, `zed`, `unified`, `vscode`, `kiro`. Options: @@ -272,6 +272,9 @@ Options: - Supports metadata formatting for `alwaysApply` and `globs` configurations. - **Cline/Roo (`clinerules`, `roo`)**: - Creates/updates individual `.md` files within `./.clinerules/` (local) or a target directory specified by `-t`. Global (`-g`) is not typically used. +- **Kiro (`kiro`)**: + - Creates/updates individual `.md` files in `./.kiro/steering/` (local) or `~/.kiro/steering/` (global). + - Uses plain markdown with no frontmatter or metadata formatting. - **ZED (`zed`)**: - Manages rules within a single `.rules` file in the project root using XML-like tagged blocks. - Each rule is encapsulated in tags like `...` without requiring wrapper blocks. diff --git a/examples/end-user-cjs-package/install.test.ts b/examples/end-user-cjs-package/install.test.ts index 03bf813..923ec18 100644 --- a/examples/end-user-cjs-package/install.test.ts +++ b/examples/end-user-cjs-package/install.test.ts @@ -811,6 +811,112 @@ test("install should create 8 rules files in .github/instructions", async () => await $`rm -rf .github`.quiet(); }); +test("install should create 8 rules files in .kiro/steering", async () => { + // Import the llms modules from our dependencies + const cjsRules = require("cjs-package/llms"); + const esmRules = await import("esm-package/llms"); + + // Clean up any existing .kiro directory + await $`rm -rf .kiro`.quiet(); + + // Run npm install + console.log("Running npm install..."); + await $`npm install`; + + // Run vibe-rules install kiro command + console.log("Running vibe-rules install kiro..."); + await $`npm run vibe-rules install kiro`; + + // Check that .kiro/steering directory exists + const kiroSteeringPath = join(process.cwd(), ".kiro", "steering"); + const kiroSteeringStat = await stat(kiroSteeringPath); + expect(kiroSteeringStat.isDirectory()).toBe(true); + + // Read the files in .kiro/steering directory + const rulesFiles = await readdir(kiroSteeringPath); + + // Filter only .md files + const mdFiles = rulesFiles.filter((file) => file.endsWith(".md")); + + console.log(`Found ${mdFiles.length} rules files:`, mdFiles); + + // Expect 8 rules files (4 from cjs-package + 4 from esm-package) + expect(mdFiles.length).toBe(cjsRules.length + esmRules.default.length); + + // Get expected rule names from the imported modules + const cjsRuleNames = cjsRules.map((r) => r.name); + const esmRuleNames = esmRules.default.map((r) => r.name); + const expectedRules = [...new Set([...cjsRuleNames, ...esmRuleNames])]; + + // Check that we have rules from both packages + for (const rule of expectedRules) { + const cjsRuleExists = mdFiles.some((file) => file.includes("cjs") && file.includes(rule)); + const esmRuleExists = mdFiles.some((file) => file.includes("esm") && file.includes(rule)); + + expect(cjsRuleExists).toBe(true); + expect(esmRuleExists).toBe(true); + } + + // Validate imported rules structure + console.log("Validating imported rules structure..."); + + expect(Array.isArray(cjsRules)).toBe(true); + expect(cjsRules.length).toBe(4); + + const esmRulesArray = esmRules.default; + expect(Array.isArray(esmRulesArray)).toBe(true); + expect(esmRulesArray.length).toBe(4); + + const allRules = [...cjsRules, ...esmRulesArray]; + for (const rule of allRules) { + expect(rule).toHaveProperty("name"); + expect(rule).toHaveProperty("rule"); + expect(rule).toHaveProperty("alwaysApply"); + + expect(typeof rule.name).toBe("string"); + expect(typeof rule.rule).toBe("string"); + expect(typeof rule.alwaysApply).toBe("boolean"); + + if (rule.description !== undefined) { + expect(typeof rule.description).toBe("string"); + } + if (rule.globs !== undefined) { + expect(Array.isArray(rule.globs) || typeof rule.globs === "string").toBe(true); + } + } + + // Read and validate actual file contents match the imported rules + console.log("Validating file content matches imported rules..."); + + for (const rule of cjsRules) { + const fileName = `cjs-package_${rule.name}.md`; + expect(mdFiles).toContain(fileName); + + const filePath = join(kiroSteeringPath, fileName); + const fileContent = await readFile(filePath, "utf-8"); + + // Kiro uses plain markdown — content should appear directly without tags or frontmatter + expect(fileContent).toContain(rule.rule); + } + + for (const rule of esmRulesArray) { + const fileName = `esm-package_${rule.name}.md`; + expect(mdFiles).toContain(fileName); + + const filePath = join(kiroSteeringPath, fileName); + const fileContent = await readFile(filePath, "utf-8"); + + expect(fileContent).toContain(rule.rule); + } + + console.log( + "✅ All Kiro assertions passed! Rules properly installed and match source modules." + ); + + // Clean up .kiro directory at the end of the test + await $`rm -rf .kiro`.quiet(); +}); + test("should convert rules from cursor to claude-code format", async () => { // First, install cursor rules to have something to convert await $`rm -rf .cursor CLAUDE.md`; diff --git a/examples/end-user-cjs-package/uninstall.test.ts b/examples/end-user-cjs-package/uninstall.test.ts index 6b15b0f..f4e55ec 100644 --- a/examples/end-user-cjs-package/uninstall.test.ts +++ b/examples/end-user-cjs-package/uninstall.test.ts @@ -96,5 +96,14 @@ export function registerUninstallTests() { await $`npm run vibe-rules uninstall ${fullName} clinerules`; expect(await pathExists(`.clinerules/${fullName}.md`)).toBe(false); await $`rm -rf .clinerules`.quiet(); + + // Kiro (multi-file .md in .kiro/steering/) + await $`rm -rf .kiro`.quiet(); + await $`npm install`; + await $`npm run vibe-rules install kiro`; + expect(await pathExists(`.kiro/steering/${fullName}.md`)).toBe(true); + await $`npm run vibe-rules uninstall ${fullName} kiro`; + expect(await pathExists(`.kiro/steering/${fullName}.md`)).toBe(false); + await $`rm -rf .kiro`.quiet(); }, 60000); } diff --git a/src/cli.ts b/src/cli.ts index 386eb6a..2a6f281 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -56,11 +56,11 @@ program .argument("", "Name of the rule to apply") .argument( "", - "Target editor type (cursor, windsurf, claude-code, gemini, codex, amp, clinerules, roo, zed, unified, vscode)" + "Target editor type (cursor, windsurf, claude-code, gemini, codex, amp, clinerules, roo, zed, unified, vscode, kiro)" ) .option( "-g, --global", - "Apply to global config path if supported (claude-code, gemini, codex)", + "Apply to global config path if supported (claude-code, gemini, codex, kiro)", false ) .option("-t, --target ", "Custom target path (overrides default and global)") @@ -73,12 +73,12 @@ program ) .argument( "", - "Target editor type (cursor, windsurf, claude-code, gemini, codex, amp, clinerules, roo, zed, unified, vscode)" + "Target editor type (cursor, windsurf, claude-code, gemini, codex, amp, clinerules, roo, zed, unified, vscode, kiro)" ) .argument("[packageName]", "Optional NPM package name to install rules from") .option( "-g, --global", - "Apply to global config path if supported (claude-code, gemini, codex)", + "Apply to global config path if supported (claude-code, gemini, codex, kiro)", false ) .option("-t, --target ", "Custom target path (overrides default and global)") @@ -89,16 +89,16 @@ program .description("Convert rules from one format to another (directory or file-based)") .argument( "", - "Source format (cursor, windsurf, claude-code, gemini, codex, amp, clinerules, roo, zed, unified, vscode)" + "Source format (cursor, windsurf, claude-code, gemini, codex, amp, clinerules, roo, zed, unified, vscode, kiro)" ) .argument( "", - "Target format (cursor, windsurf, claude-code, gemini, codex, amp, clinerules, roo, zed, unified, vscode)" + "Target format (cursor, windsurf, claude-code, gemini, codex, amp, clinerules, roo, zed, unified, vscode, kiro)" ) .argument("", "Source path (directory like .cursor or file like CLAUDE.md)") .option( "-g, --global", - "Apply to global config path if supported (claude-code, gemini, codex)", + "Apply to global config path if supported (claude-code, gemini, codex, kiro)", false ) .option("-t, --target ", "Custom target path (overrides default path)") @@ -110,9 +110,9 @@ program .argument("", "Name of the rule to remove") .argument( "", - "Target editor type (cursor, windsurf, claude-code, codex, amp, clinerules, roo, zed, unified, vscode)" + "Target editor type (cursor, windsurf, claude-code, codex, amp, clinerules, roo, zed, unified, vscode, kiro)" ) - .option("-g, --global", "Remove from global config path if supported (claude-code, codex)", false) + .option("-g, --global", "Remove from global config path if supported (claude-code, codex, kiro)", false) .option("-t, --target ", "Custom target path (overrides default and global)") .action(uninstallCommandAction); diff --git a/src/commands/convert.ts b/src/commands/convert.ts index a89c0ca..c5298f9 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -162,6 +162,9 @@ async function extractRulesFromDirectory( case RuleType.VSCODE: return await extractFromVSCodeDirectory(dirPath); + case RuleType.KIRO: + return await extractFromKiroDirectory(dirPath); + default: throw new Error(`Directory extraction not supported for ${sourceType}`); } @@ -327,6 +330,31 @@ async function extractFromVSCodeFile(filePath: string): Promise { + const rules: StoredRuleConfig[] = []; + const steeringDir = dirPath.endsWith("steering") ? dirPath : path.join(dirPath, "steering"); + + if (!(await fsExtra.pathExists(steeringDir))) { + throw new Error(`Kiro steering directory not found: ${steeringDir}`); + } + + const files = await fs.readdir(steeringDir); + const mdFiles = files.filter((file) => file.endsWith(".md")); + + for (const file of mdFiles) { + const filePath = path.join(steeringDir, file); + const content = await fs.readFile(filePath, "utf-8"); + const ruleName = path.basename(file, ".md"); + + rules.push({ name: ruleName, content }); + } + + return rules; +} + /** * Extract rules from Windsurf .windsurfrules file using tagged blocks */ @@ -487,6 +515,7 @@ function validateAndGetRuleType(format: string, type: "source" | "target"): Rule zed: RuleType.ZED, unified: RuleType.UNIFIED, vscode: RuleType.VSCODE, + kiro: RuleType.KIRO, }; const ruleType = formatMap[normalizedFormat]; @@ -510,6 +539,7 @@ function isMultiFileProvider(ruleType: RuleType): boolean { case RuleType.CLINERULES: case RuleType.ROO: case RuleType.VSCODE: + case RuleType.KIRO: return true; default: return false; diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index 5fa5879..220aae8 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -19,7 +19,7 @@ export async function uninstallCommandAction( console.error(chalk.red(`Unsupported editor: ${editor}`)); console.log( chalk.gray( - "Supported editors: cursor, windsurf, claude-code, codex, amp, clinerules, roo, zed, unified, vscode" + "Supported editors: cursor, windsurf, claude-code, codex, amp, clinerules, roo, zed, unified, vscode, kiro" ) ); process.exit(1); @@ -59,6 +59,7 @@ function getRuleTypeFromString(editor: string): RuleType | null { zed: RuleType.ZED, unified: RuleType.UNIFIED, vscode: RuleType.VSCODE, + kiro: RuleType.KIRO, }; return editorMap[editor.toLowerCase()] || null; diff --git a/src/providers/index.ts b/src/providers/index.ts index 8be008b..4e20abe 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -6,6 +6,7 @@ import { GeminiRuleProvider } from "./gemini-provider.js"; import { CodexRuleProvider } from "./codex-provider.js"; import { AmpRuleProvider } from "./amp-provider.js"; import { ClinerulesRuleProvider } from "./clinerules-provider.js"; +import { KiroRuleProvider } from "./kiro-provider.js"; import { ZedRuleProvider } from "./zed-provider.js"; import { UnifiedRuleProvider } from "./unified-provider.js"; import { VSCodeRuleProvider } from "./vscode-provider.js"; @@ -30,6 +31,8 @@ export function getRuleProvider(ruleType: RuleType): RuleProvider { case RuleType.CLINERULES: case RuleType.ROO: return new ClinerulesRuleProvider(); + case RuleType.KIRO: + return new KiroRuleProvider(); case RuleType.ZED: return new ZedRuleProvider(); case RuleType.UNIFIED: diff --git a/src/providers/kiro-provider.ts b/src/providers/kiro-provider.ts new file mode 100644 index 0000000..b50b8f2 --- /dev/null +++ b/src/providers/kiro-provider.ts @@ -0,0 +1,77 @@ +import * as path from "path"; +import { writeFile } from "fs/promises"; +import * as fs from "fs-extra/esm"; +import { RuleConfig, RuleProvider, RuleGeneratorOptions, RuleType } from "../types.js"; +import { getRulePath, ensureDirectoryExists, getDefaultTargetPath } from "../utils/path.js"; +import { saveInternalRule, loadInternalRule, listInternalRules } from "../utils/rule-storage.js"; +import { debugLog } from "../utils/debug.js"; + +export class KiroRuleProvider implements RuleProvider { + private readonly ruleType = RuleType.KIRO; + + generateRuleContent(config: RuleConfig, options?: RuleGeneratorOptions): string { + // Kiro steering files are plain markdown — no frontmatter + return config.content; + } + + async saveRule(config: RuleConfig): Promise { + return saveInternalRule(this.ruleType, config); + } + + async loadRule(name: string): Promise { + return loadInternalRule(this.ruleType, name); + } + + async listRules(): Promise { + return listInternalRules(this.ruleType); + } + + async appendRule(name: string, targetPath?: string, isGlobal?: boolean): Promise { + const ruleConfig = await this.loadRule(name); + if (!ruleConfig) { + console.error(`Rule "${name}" not found in internal Kiro storage.`); + return false; + } + const finalTargetPath = targetPath || getRulePath(this.ruleType, name, isGlobal); + return this.appendFormattedRule(ruleConfig, finalTargetPath, isGlobal); + } + + async appendFormattedRule( + config: RuleConfig, + targetPath: string, + isGlobal?: boolean, + options?: RuleGeneratorOptions + ): Promise { + const dir = path.dirname(targetPath); + try { + ensureDirectoryExists(dir); + const content = this.generateRuleContent(config, options); + await writeFile(targetPath, content, "utf-8"); + return true; + } catch (error) { + console.error(`Error applying Kiro rule "${config.name}" to ${targetPath}:`, error); + return false; + } + } + + async removeRule(name: string, targetPath?: string, isGlobal?: boolean): Promise { + try { + const finalTargetPath = targetPath || getDefaultTargetPath(this.ruleType, isGlobal); + const ruleFilePath = targetPath?.endsWith(".md") + ? targetPath + : path.join(finalTargetPath, `${name}.md`); + + if (!(await fs.pathExists(ruleFilePath))) { + debugLog(`Rule file does not exist: ${ruleFilePath}`, "red"); + return false; + } + + await fs.remove(ruleFilePath); + debugLog(`Removed kiro rule file: ${ruleFilePath}`); + return true; + } catch (error) { + debugLog(`Error removing kiro rule "${name}":`, "red", error); + return false; + } + } +} diff --git a/src/providers/metadata-application.test.ts b/src/providers/metadata-application.test.ts index b1bb6f6..a4800b8 100644 --- a/src/providers/metadata-application.test.ts +++ b/src/providers/metadata-application.test.ts @@ -214,6 +214,35 @@ describe("Metadata Application Patterns", () => { }); }); + describe("Kiro Provider Format", () => { + test("should output plain markdown without frontmatter or metadata", () => { + // Kiro steering files are plain markdown + const generateKiroFormat = (rule: any, _metadata: any) => { + return rule.content; + }; + + const result = generateKiroFormat(testRule, testMetadata); + + expect(result).toBe(testRule.content); + expect(result).not.toContain("---"); + expect(result).not.toContain("Always Apply:"); + expect(result).not.toContain("Globs:"); + expect(result).toContain("# Test Rule"); + expect(result).toContain("This is test content for metadata application."); + }); + + test("should ignore metadata and return content unchanged", () => { + const generateKiroFormat = (rule: any, _metadata: any) => { + return rule.content; + }; + + const result = generateKiroFormat(testRule, alwaysApplyMetadata); + + expect(result).toBe(testRule.content); + expect(result).not.toContain("alwaysApply"); + }); + }); + describe("Single-File Provider Patterns", () => { test("should generate content with metadata lines for Zed/Claude/Codex", () => { // Simulate what single-file providers should produce @@ -266,6 +295,10 @@ describe("Metadata Application Patterns", () => { (rule: any, _metadata: any) => { return rule.content; }, + // Kiro format (plain markdown) + (rule: any, _metadata: any) => { + return rule.content; + }, ]; formats.forEach((format) => { @@ -291,6 +324,8 @@ describe("Metadata Application Patterns", () => { (rule: any) => rule.content, (rule: any) => rule.content, (rule: any) => rule.content, + // Kiro (plain markdown) + (rule: any) => rule.content, ]; formats.forEach((format) => { diff --git a/src/types.ts b/src/types.ts index 5e7fb6e..116d812 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,7 @@ export const RuleType = { AMP: "amp", CLINERULES: "clinerules", ROO: "roo", + KIRO: "kiro", ZED: "zed", UNIFIED: "unified", VSCODE: "vscode", diff --git a/src/utils/path.ts b/src/utils/path.ts index 45a95f4..dad1f01 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -11,6 +11,7 @@ export const RULES_BASE_DIR = path.join(os.homedir(), ".vibe-rules"); export const CLAUDE_HOME_DIR = path.join(os.homedir(), ".claude"); export const GEMINI_HOME_DIR = path.join(os.homedir(), ".gemini"); export const CODEX_HOME_DIR = path.join(os.homedir(), ".codex"); +export const KIRO_HOME_DIR = path.join(os.homedir(), ".kiro"); export const ZED_RULES_FILE = ".rules"; // Added for Zed /** @@ -79,6 +80,11 @@ export function getRulePath( ".clinerules", slugifyRuleName(ruleName) + ".md" // Use .md extension ); + case RuleType.KIRO: + // Kiro rules are .md files in .kiro/steering/ (local) or ~/.kiro/steering/ (global) + return isGlobal + ? path.join(KIRO_HOME_DIR, "steering", slugifyRuleName(ruleName) + ".md") + : path.join(projectRoot, ".kiro", "steering", slugifyRuleName(ruleName) + ".md"); case RuleType.ZED: // Added for Zed return path.join(projectRoot, ZED_RULES_FILE); case RuleType.UNIFIED: @@ -138,6 +144,11 @@ export function getDefaultTargetPath( case RuleType.ROO: // Default target is the .clinerules directory return path.join(process.cwd(), ".clinerules"); + case RuleType.KIRO: + // Default target is the .kiro/steering directory + return isGlobalHint + ? path.join(KIRO_HOME_DIR, "steering") + : path.join(process.cwd(), ".kiro", "steering"); case RuleType.ZED: // Added for Zed return path.join(process.cwd(), ZED_RULES_FILE); case RuleType.UNIFIED: @@ -194,6 +205,11 @@ export async function editorConfigExists( case RuleType.ROO: checkPath = path.join(projectRoot, ".clinerules"); break; + case RuleType.KIRO: + checkPath = isGlobal + ? path.join(KIRO_HOME_DIR, "steering") + : path.join(projectRoot, ".kiro", "steering"); + break; case RuleType.ZED: case RuleType.UNIFIED: checkPath = path.join(projectRoot, ".rules");