UNPKG

vibe-rules

Version:

A utility for managing Cursor rules, Windsurf rules, and other AI prompts

401 lines 20.8 kB
import fs, { pathExists } from "fs-extra/esm"; import { stat, readFile, writeFile, readdir } from "fs/promises"; import path from "path"; import chalk from "chalk"; import { createRequire } from "module"; // This is required until --experimental-import-meta-resolve is supported by Node.js by default // https://nodejs.org/api/esm.html#importmetaresolvespecifier import { resolve as importMetaResolve } from "import-meta-resolve"; import { RuleType, } from "../types.js"; import { getRuleProvider } from "../providers/index.js"; import { getDefaultTargetPath, getRulePath, slugifyRuleName } from "../utils/path.js"; import { VibePackageRulesSchema } from "../schemas.js"; import { isDebugEnabled, debugLog } from "../cli.js"; // Assuming these will be exported from cli.ts // Helper function to clear existing rules installed from a package async function clearExistingRules(pkgName, editorType, options) { debugLog(`Clearing existing rules for package "${pkgName}" and editor "${editorType}" with options ${JSON.stringify(options)}...`); let targetDir; const singleFileProviders = [ RuleType.WINDSURF, RuleType.CLAUDE_CODE, RuleType.GEMINI, RuleType.CODEX, ]; let isSingleFileProvider = singleFileProviders.includes(editorType); // Get default path from provider logic const defaultPath = getDefaultTargetPath(editorType, options.global); try { // Check if path exists first if (await fs.pathExists(defaultPath)) { const stats = await stat(defaultPath); if (stats.isDirectory()) { targetDir = defaultPath; } else { targetDir = path.dirname(defaultPath); } } else { // Default path doesn't exist. Create it. await fs.ensureDir(defaultPath); targetDir = defaultPath; } } catch (error) { console.error(chalk.red(`Error checking default path ${defaultPath}: ${error.message}`)); return; // Cannot determine target directory } debugLog(`Determined target directory: ${targetDir}. Single File Provider Mode: ${isSingleFileProvider} for ${editorType}.`); // If it's a known single file provider, remove matching XML blocks instead of deleting files if (isSingleFileProvider) { const potentialTargetFile = getRulePath(editorType, "", options.global); if (!(await fs.pathExists(potentialTargetFile))) { debugLog(`Cannot clear rules for single file provider ${editorType} as target file path could not be determined.`); return; } if (!potentialTargetFile) { debugLog(`Cannot clear rules for single file provider ${editorType} as target file path could not be determined.`); return; } try { if (!(await fs.pathExists(potentialTargetFile))) { debugLog(`Target file ${potentialTargetFile} does not exist. No rules to clear.`); return; } const content = await readFile(potentialTargetFile, "utf-8"); // Regex to match <pkgName_ruleName ...>...</pkgName_ruleName> blocks const removalRegex = new RegExp(`<(${pkgName}_[^\\s>]+)[^>]*>.*?<\\/\\1>\\s*\n?`, "gs"); let removedCount = 0; const newContent = content.replace(removalRegex, (match) => { removedCount++; debugLog(`Removing block matching pattern: ${match.substring(0, 100)}...`); return ""; }); if (removedCount > 0) { await writeFile(potentialTargetFile, newContent, "utf-8"); debugLog(chalk.blue(`Removed ${removedCount} existing rule block(s) matching prefix "${pkgName}_" from ${potentialTargetFile}.`)); } else { debugLog(`No rule blocks found with prefix "${pkgName}_" in ${potentialTargetFile}.`); } } catch (error) { console.error(chalk.red(`Error clearing rule blocks from ${potentialTargetFile}: ${error.message}`)); } return; } try { if (!(await fs.pathExists(targetDir))) { debugLog(`Target directory ${targetDir} does not exist. No rules to clear.`); return; } const files = await readdir(targetDir); const prefix = `${pkgName}_`; let deletedCount = 0; debugLog(`Scanning ${targetDir} for files starting with prefix "${prefix}"...`); for (const file of files) { if (file.startsWith(prefix)) { const filePath = path.join(targetDir, file); try { await fs.remove(filePath); debugLog(chalk.grey(`Deleted existing rule file: ${filePath}`)); deletedCount++; } catch (deleteError) { console.error(chalk.red(`Failed to delete rule file ${filePath}: ${deleteError.message}`)); } } } if (deletedCount > 0) { debugLog(chalk.blue(`Cleared ${deletedCount} existing rule file(s) matching prefix "${prefix}" in ${targetDir}.`)); } else { debugLog(`No rule files found with prefix "${prefix}" in ${targetDir}.`); } } catch (error) { console.error(chalk.red(`Error clearing existing rules in ${targetDir}: ${error.message}`)); } } async function importModuleFromCwd(ruleModulePath) { debugLog(`Attempting to import module: ${ruleModulePath}`); let module; try { debugLog(`Trying require for ${ruleModulePath}...`); module = createRequire(path.join(process.cwd(), "package.json"))(ruleModulePath); debugLog(`Successfully imported using require.`); } catch (error) { debugLog(`Require failed (${error.code || "ESM-related error"}). Falling back to dynamic import()...`); try { const fileUrlString = `file://${process.cwd()}/package.json`; debugLog(`trying to resolve ${ruleModulePath} with importMetaResolve`, fileUrlString); const importPath = importMetaResolve(ruleModulePath, fileUrlString); debugLog(`Falling back to dynamic import() path: ${importPath}`); module = await import(importPath); debugLog(`Successfully imported using dynamic import().`); } catch (importError) { debugLog(`Dynamic import() failed for ${ruleModulePath}: ${importError.message}`); throw importError; } } const defaultExport = module?.default || module; debugLog(`Module imported. Type: ${typeof defaultExport}`); return defaultExport; } async function installSinglePackage(pkgName, editorType, provider, installOptions) { // Return count of successfully applied rules if (isDebugEnabled) { console.log(chalk.blue(`Attempting to install rules from ${pkgName}...`)); } let rulesSuccessfullyAppliedCount = 0; try { // Check if package exports ./llms and if the file actually exists try { const pkgJsonPath = `${pkgName}/package.json`; const pkgJson = await importModuleFromCwd(pkgJsonPath); const exports = pkgJson?.default?.exports || pkgJson?.exports; debugLog(`Package ${pkgName} exports: ${JSON.stringify(exports, null, 2)}`); if (exports && !exports["./llms"]) { debugLog(`Package ${pkgName} does not export ./llms in package.json. Skipping.`); return 0; } if (exports && exports["./llms"]) { debugLog(`Package ${pkgName} exports ./llms: ${JSON.stringify(exports["./llms"], null, 2)}`); debugLog(`Package ${pkgName} has ./llms export, proceeding with import attempt.`); } } catch (pkgError) { debugLog(`Could not check package.json for ${pkgName}: ${pkgError.message}. Proceeding anyway.`); } const ruleModulePath = `${pkgName}/llms`; debugLog(`Importing module ${ruleModulePath}`); const defaultExport = await importModuleFromCwd(ruleModulePath); if (!defaultExport) { debugLog(chalk.yellow(`No default export or module found in ${ruleModulePath} after import attempt. Skipping ${pkgName}.`)); return 0; } await clearExistingRules(pkgName, editorType, installOptions); let rulesToInstall = []; if (typeof defaultExport === "string") { debugLog(`Found rule content as string in ${pkgName}. Preparing to install...`); let ruleName = slugifyRuleName(pkgName); if (!ruleName.startsWith(`${pkgName}_`)) { ruleName = `${pkgName}_${ruleName}`; } const ruleContent = defaultExport; rulesToInstall.push({ name: ruleName, content: ruleContent }); } else { debugLog(`Found array export in ${pkgName}. Validating...`); const validationResult = VibePackageRulesSchema.safeParse(defaultExport); if (!validationResult.success) { console.error(chalk.red(`Validation failed for rules from ${pkgName}:`), validationResult.error.errors); debugLog(chalk.yellow(`Skipping installation from ${pkgName} due to validation failure.`)); return 0; } const items = validationResult.data; debugLog(`Found ${items.length} valid items in ${pkgName}`); for (const [index, item] of items.entries()) { if (typeof item === "string") { const ruleName = slugifyRuleName(`${pkgName}_${index}`); rulesToInstall.push({ name: ruleName, content: item, description: `Rule from ${pkgName}`, }); } else { let ruleName = item.name; if (!ruleName.startsWith(`${pkgName}_`)) { ruleName = `${pkgName}_${ruleName}`; } debugLog(`Processing object rule: ${item.name} with properties: alwaysApply: ${item.alwaysApply !== undefined ? item.alwaysApply : "undefined"} globs: ${item.globs ? Array.isArray(item.globs) ? item.globs.join(",") : item.globs : "undefined"} `); rulesToInstall.push({ name: ruleName, content: item.rule, description: item.description, }); } } } if (rulesToInstall.length > 0) { if (isDebugEnabled) { console.log(chalk.blue(`Applying ${rulesToInstall.length} rule(s) from ${pkgName} to ${editorType}...`)); } for (const ruleConfig of rulesToInstall) { try { let finalTargetPath; if (installOptions.target) { finalTargetPath = installOptions.target; } else { finalTargetPath = getRulePath(editorType, ruleConfig.name, installOptions.global); } fs.ensureDirSync(path.dirname(finalTargetPath)); const generatorOptions = { description: ruleConfig.description, isGlobal: installOptions.global, debug: installOptions.debug, }; let originalName = ruleConfig.name; if (originalName.startsWith(`${pkgName}_`)) { originalName = originalName.substring(pkgName.length + 1); } debugLog(`Looking for metadata with original name: ${originalName} (from ${ruleConfig.name})`); const originalItem = typeof defaultExport === "string" ? null : Array.isArray(defaultExport) ? defaultExport.find((item) => typeof item === "object" && (item.name === originalName || item.name === ruleConfig.name)) : null; if (originalItem && typeof originalItem === "object") { debugLog(`Found additional metadata for rule ${ruleConfig.name}. Keys: ${Object.keys(originalItem).join(", ")}`); if ("alwaysApply" in originalItem) { generatorOptions.alwaysApply = originalItem.alwaysApply; debugLog(`Setting alwaysApply: ${originalItem.alwaysApply}`); } if ("globs" in originalItem) { generatorOptions.globs = originalItem.globs; debugLog(`Setting globs: ${JSON.stringify(originalItem.globs)}`); } } debugLog(`Applying rule ${ruleConfig.name} with options: ${JSON.stringify(generatorOptions)}`); if (isDebugEnabled) { if (generatorOptions.globs) { console.log(chalk.blue(`Rule "${ruleConfig.name}" has globs: ${Array.isArray(generatorOptions.globs) ? generatorOptions.globs.join(", ") : generatorOptions.globs}`)); } if (generatorOptions.alwaysApply !== undefined) { console.log(chalk.blue(`Rule "${ruleConfig.name}" has alwaysApply: ${generatorOptions.alwaysApply}`)); } } try { const success = await provider.appendFormattedRule(ruleConfig, finalTargetPath, installOptions.global, generatorOptions); if (success) { rulesSuccessfullyAppliedCount++; if (isDebugEnabled) { console.log(chalk.green(`Rule "${ruleConfig.name}" from ${pkgName} applied successfully for ${editorType} at ${finalTargetPath}`)); } else { // Concise output for normal (non-debug) mode console.log(chalk.green(`[vibe-rules] Installed rule "${ruleConfig.name}" from package "${pkgName}".`)); } } else { console.error(chalk.red(`Failed to apply rule "${ruleConfig.name}" from ${pkgName} for ${editorType}.`)); } } catch (ruleApplyError) { console.error(chalk.red(`Error applying rule "${ruleConfig.name}" from ${pkgName}: ${ruleApplyError instanceof Error ? ruleApplyError.message : ruleApplyError}`)); } } catch (ruleError) { console.error(chalk.red(`Error applying rule "${ruleConfig.name}" from ${pkgName}: ${ruleError instanceof Error ? ruleError.message : ruleError}`)); } } } else { debugLog(`No valid rules found or processed from ${pkgName}.`); } } catch (error) { if (error.code === "MODULE_NOT_FOUND" || error.code === "ERR_MODULE_NOT_FOUND" || error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") { const msg = `Skipping package ${pkgName}: Rules module ('${pkgName}/llms') not found or not exported.`; debugLog(`${msg} Error: ${error.message}`); debugLog(`Stack: ${error.stack}`); } else if (error instanceof SyntaxError) { console.error(chalk.red(`Error in package '${pkgName}': Syntax error found in its rule module ('${pkgName}/llms').`)); console.error(chalk.red("Please check the syntax of the rules module in this package.")); debugLog(`SyntaxError details: ${error.message}`); debugLog(`Stack: ${error.stack}`); } else { console.error(chalk.red(`Error from package '${pkgName}': Its rule module ('${pkgName}/llms') was found but failed during its own initialization.`)); console.error(chalk.yellow(`This often indicates an issue within the '${pkgName}' package itself (e.g., trying to access files with incorrect paths post-build, or other internal errors).`)); console.error(chalk.red(`Original error from '${pkgName}': ${error.message}`)); debugLog(`Full error trace from '${pkgName}' to help its developers:`); debugLog(error.stack); } } return rulesSuccessfullyAppliedCount; } /** * Handles the 'install' command logic. * Installs rules from an NPM package or all dependencies into an editor configuration. * @param editor Target editor type (cursor, windsurf, claude-code, codex, clinerules, roo) * @param packageName Optional NPM package name to install rules from * @param options Command options including global, target, and debug */ export async function installCommandAction(editor, packageName, options) { const editorType = editor.toLowerCase(); let provider; const combinedOptions = { ...options, debug: isDebugEnabled }; // Use isDebugEnabled from cli.ts try { provider = getRuleProvider(editorType); } catch { console.error(chalk.red(`Invalid editor type specified: ${editor}`)); process.exit(1); } if (packageName) { // VSCode-specific warning about glob limitations if (editorType === RuleType.VSCODE) { console.log(chalk.yellow(`[vibe-rules] Note: Due to VSCode's applyTo field limitations with multiple globs, all rules will be applied universally (**) for better reliability.`)); } const count = await installSinglePackage(packageName, editorType, provider, combinedOptions); if (!isDebugEnabled && count > 0) { console.log(chalk.green(`[vibe-rules] Successfully installed ${count} rule(s) from package '${packageName}'.`)); } } else { console.log(chalk.blue(`[vibe-rules] Installing rules from all dependencies in package.json for ${editor}...`)); // VSCode-specific warning about glob limitations if (editorType === RuleType.VSCODE) { console.log(chalk.yellow(`[vibe-rules] Note: Due to VSCode's applyTo field limitations with multiple globs, all rules will be applied universally (**) for better reliability.`)); } try { const pkgJsonPath = path.join(process.cwd(), "package.json"); if (!(await pathExists(pkgJsonPath))) { console.error(chalk.red("package.json not found in the current directory.")); process.exit(1); } const pkgJsonContent = await readFile(pkgJsonPath, "utf-8"); const { dependencies = {}, devDependencies = {} } = JSON.parse(pkgJsonContent); const allDeps = [...Object.keys(dependencies), ...Object.keys(devDependencies)]; if (allDeps.length === 0) { console.log(chalk.yellow("No dependencies found in package.json.")); return; } debugLog(chalk.blue(`Found ${allDeps.length} dependencies. Checking for rules to install for ${editor}...`)); let totalRulesInstalled = 0; for (const depName of allDeps) { totalRulesInstalled += await installSinglePackage(depName, editorType, provider, combinedOptions); } if (!isDebugEnabled && totalRulesInstalled > 0) { console.log(chalk.green(`[vibe-rules] Finished installing rules from dependencies. Total rules installed: ${totalRulesInstalled}.`)); } else if (!isDebugEnabled && totalRulesInstalled === 0 && allDeps.length > 0) { console.log(chalk.yellow("[vibe-rules] No rules found to install from dependencies.")); } debugLog(chalk.green("Finished checking all dependencies.")); } catch (error) { console.error(chalk.red(`Error processing package.json: ${error instanceof Error ? error.message : error}`)); process.exit(1); } } } //# sourceMappingURL=install.js.map