vibe-rules
Version:
A utility for managing Cursor rules, Windsurf rules, and other AI prompts
404 lines • 14.9 kB
JavaScript
import { RuleType } from "../types.js";
import { getRuleProvider } from "../providers/index.js";
import { getDefaultTargetPath, getRulePath } from "../utils/path.js";
import { debugLog } from "../utils/debug.js";
import { parseFrontmatter } from "../utils/frontmatter.js";
import * as fs from "fs/promises";
import * as fsExtra from "fs-extra";
import * as path from "path";
import chalk from "chalk";
/**
* Main handler for the convert command
*/
export async function convertCommandAction(sourceFormat, targetFormat, sourcePath, options) {
debugLog(`Converting from ${sourceFormat} to ${targetFormat}, source: ${sourcePath}`);
try {
// Validate source and target formats
const sourceType = validateAndGetRuleType(sourceFormat, "source");
if (!sourceType) {
process.exit(1);
}
const targetType = validateAndGetRuleType(targetFormat, "target");
if (!targetType) {
process.exit(1);
}
if (sourceType === targetType) {
console.error(chalk.red("❌ Source and target formats cannot be the same"));
process.exit(1);
}
// Get target provider
const targetProvider = getRuleProvider(targetType);
// Determine if source is a directory or file
let sourceStats;
try {
debugLog(`Checking source path: ${sourcePath}`);
debugLog(`Current working directory: ${process.cwd()}`);
const absolutePath = path.resolve(sourcePath);
debugLog(`Absolute path: ${absolutePath}`);
sourceStats = await fs.stat(sourcePath);
debugLog(`Source stats: isDirectory=${sourceStats.isDirectory()}, isFile=${sourceStats.isFile()}`);
}
catch (error) {
console.error(chalk.red(`❌ Source path not found: ${sourcePath}`));
debugLog(`Error details: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
let rules = [];
try {
if (sourceStats.isDirectory()) {
rules = await extractRulesFromDirectory(sourceType, sourcePath);
}
else {
rules = await extractRulesFromFile(sourceType, sourcePath);
}
}
catch (error) {
console.error(chalk.red(`❌ Error extracting rules: ${error instanceof Error ? error.message : String(error)}`));
process.exit(1);
}
if (rules.length === 0) {
console.error(chalk.yellow("⚠️ No rules found in source"));
process.exit(1);
}
// Determine target path
const targetPath = options.target || getDefaultTargetPath(targetType, options.global);
console.log(chalk.blue(`📋 Found ${rules.length} rule(s) to convert`));
console.log(chalk.blue(`🎯 Target: ${targetPath}`));
// Convert each rule
let successCount = 0;
for (const rule of rules) {
try {
const ruleConfig = {
name: rule.name,
content: rule.content,
description: rule.description,
};
// For multi-file providers (Cursor, Clinerules, VSCode), we need to construct individual file paths
let finalTargetPath = targetPath;
if (isMultiFileProvider(targetType)) {
finalTargetPath = getRulePath(targetType, rule.name, options.global);
}
const success = await targetProvider.appendFormattedRule(ruleConfig, finalTargetPath, options.global, rule.metadata);
if (success) {
successCount++;
console.log(chalk.green(`✅ Converted rule "${rule.name}"`));
}
else {
console.error(chalk.red(`❌ Failed to convert rule "${rule.name}"`));
}
}
catch (error) {
console.error(chalk.red(`❌ Error converting rule "${rule.name}": ${error instanceof Error ? error.message : String(error)}`));
}
}
console.log(chalk.green(`\n🎉 Successfully converted ${successCount}/${rules.length} rules from ${sourceFormat} to ${targetFormat}`));
}
catch (error) {
console.error(chalk.red(`❌ Convert command failed: ${error instanceof Error ? error.message : String(error)}`));
process.exit(1);
}
}
/**
* Extract rules from a directory based on the source format
*/
async function extractRulesFromDirectory(sourceType, dirPath) {
switch (sourceType) {
case RuleType.CURSOR:
return await extractFromCursorDirectory(dirPath);
case RuleType.CLINERULES:
case RuleType.ROO:
return await extractFromClinerulesDireatory(dirPath);
case RuleType.VSCODE:
return await extractFromVSCodeDirectory(dirPath);
default:
throw new Error(`Directory extraction not supported for ${sourceType}`);
}
}
/**
* Extracts rules from a single file based on the source format
*/
async function extractRulesFromFile(sourceType, filePath) {
switch (sourceType) {
case RuleType.CURSOR:
return await extractFromCursorFile(filePath);
case RuleType.WINDSURF:
return await extractFromWindsurfFile(filePath);
case RuleType.CLAUDE_CODE:
return await extractFromClaudeCodeFile(filePath);
case RuleType.CODEX:
return await extractFromCodexFile(filePath);
case RuleType.AMP:
return await extractFromAmpFile(filePath);
case RuleType.ZED:
case RuleType.UNIFIED:
return await extractFromTaggedFile(filePath);
case RuleType.VSCODE:
return await extractFromVSCodeFile(filePath);
default:
throw new Error(`File extraction not supported for ${sourceType} format`);
}
}
/**
* Extract rules from Cursor .mdc files in a directory
*/
async function extractFromCursorDirectory(dirPath) {
const rules = [];
const rulesDir = path.join(dirPath, "rules");
if (!(await fsExtra.pathExists(rulesDir))) {
throw new Error(`Cursor rules directory not found: ${rulesDir}`);
}
const files = await fs.readdir(rulesDir);
const mdcFiles = files.filter((file) => file.endsWith(".mdc"));
for (const file of mdcFiles) {
const filePath = path.join(rulesDir, file);
const fileRules = await extractFromCursorFile(filePath);
rules.push(...fileRules);
}
return rules;
}
/**
* Extract rules from a single Cursor .mdc file
*/
async function extractFromCursorFile(filePath) {
const content = await fs.readFile(filePath, "utf-8");
const parsed = parseFrontmatter(content);
const ruleName = path.basename(filePath, ".mdc");
const metadata = {};
if (parsed.frontmatter.description)
metadata.description = parsed.frontmatter.description;
if (parsed.frontmatter.alwaysApply !== undefined)
metadata.alwaysApply = parsed.frontmatter.alwaysApply;
if (parsed.frontmatter.globs)
metadata.globs = parsed.frontmatter.globs;
return [
{
name: ruleName,
content: parsed.content,
description: parsed.frontmatter.description,
metadata,
},
];
}
/**
* Extract rules from Clinerules .md files in a directory
*/
async function extractFromClinerulesDireatory(dirPath) {
const rules = [];
if (!(await fsExtra.pathExists(dirPath))) {
throw new Error(`Clinerules directory not found: ${dirPath}`);
}
const files = await fs.readdir(dirPath);
const mdFiles = files.filter((file) => file.endsWith(".md"));
for (const file of mdFiles) {
const filePath = path.join(dirPath, file);
const content = await fs.readFile(filePath, "utf-8");
const ruleName = path.basename(file, ".md");
// Parse metadata from content if it exists
const metadata = parseMetadataFromContent(content);
rules.push({
name: ruleName,
content: content,
metadata,
});
}
return rules;
}
/**
* Extract rules from VSCode .instructions.md files in a directory
*/
async function extractFromVSCodeDirectory(dirPath) {
const rules = [];
if (!(await fsExtra.pathExists(dirPath))) {
throw new Error(`VSCode instructions directory not found: ${dirPath}`);
}
const files = await fs.readdir(dirPath);
const instructionFiles = files.filter((file) => file.endsWith(".instructions.md"));
for (const file of instructionFiles) {
const filePath = path.join(dirPath, file);
const fileRules = await extractFromVSCodeFile(filePath);
rules.push(...fileRules);
}
return rules;
}
/**
* Extract rules from a single VSCode .instructions.md file
*/
async function extractFromVSCodeFile(filePath) {
const content = await fs.readFile(filePath, "utf-8");
const parsed = parseFrontmatter(content);
const ruleName = path.basename(filePath, ".instructions.md");
const metadata = {};
if (parsed.frontmatter.applyTo) {
// Convert VSCode applyTo to globs
metadata.globs = parsed.frontmatter.applyTo;
}
return [
{
name: ruleName,
content: parsed.content,
metadata,
},
];
}
/**
* Extract rules from Windsurf .windsurfrules file using tagged blocks
*/
async function extractFromWindsurfFile(filePath) {
return await extractFromTaggedFile(filePath);
}
/**
* Extract rules from Claude Code CLAUDE.md file
*/
async function extractFromClaudeCodeFile(filePath) {
return await extractFromTaggedFileWithWrapper(filePath, "<!-- vibe-rules Integration -->");
}
/**
* Extract rules from Codex AGENTS.md file
*/
async function extractFromCodexFile(filePath) {
return await extractFromTaggedFileWithWrapper(filePath, "<!-- vibe-rules Integration -->");
}
/**
* Extract rules from Amp AGENT.md file
*/
async function extractFromAmpFile(filePath) {
return await extractFromTaggedFile(filePath);
}
/**
* Extract rules from files with XML-like tagged blocks (Windsurf, ZED, Unified, Amp)
*/
async function extractFromTaggedFile(filePath) {
const content = await fs.readFile(filePath, "utf-8");
const rules = [];
// Regex to match tagged blocks: <rule-name>content</rule-name>
const tagRegex = /<([^>]+)>([\s\S]*?)<\/\1>/g;
let match;
while ((match = tagRegex.exec(content)) !== null) {
const ruleName = match[1];
const ruleContent = match[2].trim();
// Parse metadata from content if it exists
const metadata = parseMetadataFromContent(ruleContent);
const cleanContent = removeMetadataFromContent(ruleContent);
rules.push({
name: ruleName,
content: cleanContent,
metadata,
});
}
return rules;
}
/**
* Extract rules from files with XML-like tagged blocks within wrapper comments (Claude Code, Codex)
*/
async function extractFromTaggedFileWithWrapper(filePath, wrapperStart) {
const content = await fs.readFile(filePath, "utf-8");
const rules = [];
// Find the wrapper block
const wrapperEnd = wrapperStart.replace("vibe-rules Integration", "/vibe-rules Integration");
const wrapperRegex = new RegExp(`${escapeRegex(wrapperStart)}([\\s\\S]*?)${escapeRegex(wrapperEnd)}`);
const wrapperMatch = content.match(wrapperRegex);
if (!wrapperMatch) {
debugLog(`No wrapper block found in ${filePath}`);
return rules;
}
const wrapperContent = wrapperMatch[1];
// Extract tagged blocks within the wrapper
const tagRegex = /<([^>]+)>([\s\S]*?)<\/\1>/g;
let match;
while ((match = tagRegex.exec(wrapperContent)) !== null) {
const ruleName = match[1];
const ruleContent = match[2].trim();
// Parse metadata from content if it exists
const metadata = parseMetadataFromContent(ruleContent);
const cleanContent = removeMetadataFromContent(ruleContent);
rules.push({
name: ruleName,
content: cleanContent,
metadata,
});
}
return rules;
}
/**
* Parse metadata from rule content (for formats that include metadata as text)
*/
function parseMetadataFromContent(content) {
const metadata = {};
const lines = content.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("Always Apply:")) {
metadata.alwaysApply = trimmed.includes("true");
}
else if (trimmed.startsWith("Globs:")) {
const globsText = trimmed.replace("Globs:", "").trim();
if (globsText && globsText !== "None") {
// Handle both single glob and array format
if (globsText.includes(",")) {
metadata.globs = globsText.split(",").map((g) => g.trim());
}
else {
metadata.globs = globsText;
}
}
}
}
return metadata;
}
/**
* Remove metadata lines from content
*/
function removeMetadataFromContent(content) {
const lines = content.split("\n");
const cleanLines = lines.filter((line) => {
const trimmed = line.trim();
return !trimmed.startsWith("Always Apply:") && !trimmed.startsWith("Globs:");
});
return cleanLines.join("\n").trim();
}
/**
* Validate and convert string format to RuleType enum
*/
function validateAndGetRuleType(format, type) {
const normalizedFormat = format.toLowerCase();
const formatMap = {
cursor: RuleType.CURSOR,
windsurf: RuleType.WINDSURF,
"claude-code": RuleType.CLAUDE_CODE,
codex: RuleType.CODEX,
amp: RuleType.AMP,
clinerules: RuleType.CLINERULES,
roo: RuleType.ROO,
zed: RuleType.ZED,
unified: RuleType.UNIFIED,
vscode: RuleType.VSCODE,
};
const ruleType = formatMap[normalizedFormat];
if (!ruleType) {
const available = Object.keys(formatMap).join(", ");
console.error(chalk.red(`❌ Invalid ${type} format: ${format}. Available formats: ${available}`));
return null;
}
return ruleType;
}
/**
* Check if a rule type uses multiple files (vs single file)
*/
function isMultiFileProvider(ruleType) {
switch (ruleType) {
case RuleType.CURSOR:
case RuleType.CLINERULES:
case RuleType.ROO:
case RuleType.VSCODE:
return true;
default:
return false;
}
}
/**
* Escape special regex characters
*/
function escapeRegex(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
//# sourceMappingURL=convert.js.map