@usrrname/cursorrules
Version:
A wicked npx-able lib of cursor rules with Otaku AI agents
260 lines (245 loc) ⢠9.79 kB
JavaScript
import { readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
// `styleText` is only available in Node 21+. To stay compatible with Node 18/20
// we safely import the entire util namespace and fall back to a no-op when
// `styleText` is not present.
import * as util from 'node:util';
const styleText = util.styleText ?? ((_, text) => text);
/**
* Finds all rules in category and prepares them for display in menu
* @param {Record<string, Array<{name: string, path: string, fullPath: string}>>} rules
*/
export const prepareMenu = (rules) => {
/** @type {Array<{category: "standards" | "test" | "utils", displayName: string, selected: boolean, name: string, path: string, fullPath: string}>} */
let allRules = [];
// Flatten all rules for display in interactive mode
for (const [category, categoryRules] of Object.entries(rules)) {
for (const rule of categoryRules) {
allRules.push({
name: rule.name,
path: rule.path,
fullPath: rule.fullPath,
category: /** @type {"standards" | "test" | "utils"} */ (category),
displayName: `${rule.name}`,
selected: false
});
}
}
return allRules;
}
/**
* Utility to render a menu with highlighting and indicators
* @param {Object} opts
* @param {string} opts.title - Menu title
* @param {string[]} opts.items - Array of item display strings
* @param {number} opts.currentIndex - Index of currently highlighted item
* @param {string[]} [opts.footerLines] - Array of footer lines
*/
export const createMenu = ({ title, items, currentIndex, footerLines = [] }) => {
process.stdout.write('\x1B[2J\x1B[0f');
if (title) {
console.info(title + '\n');
}
items.forEach((item, idx) => {
const isCurrent = idx === currentIndex;
const indicator = isCurrent ? 'ā¶ ' : ' ';
const highlight = isCurrent ? styleText(['bold', 'black', 'bgWhite'], '\x1B[7m') : '';
const reset = '\x1B[0m';
console.info(`${highlight}${indicator}${item}${reset}`);
});
if (footerLines.length) {
footerLines.forEach(line => console.info(line));
}
}
/**
* Utility to render a menu with highlighting and indicators
* @param {string[]} categories
* @param {number} currentIndex
*/
const renderCategoryMenu = (categories, currentIndex) => {
const items = categories.concat(['š Save Rules']);
createMenu({
title: 'Select rules by category',
items,
currentIndex,
footerLines: [
'\nāā - Navigate | ā Enter - Select | Esc - Cancel'
]
});
}
/**
* Utility to set up interactive input for a menu
* @param {function(string):void} handleKeyPress
*/
export const setupInput = (handleKeyPress) => {
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
process.stdin.on('data', handleKeyPress);
}
/**
* Utility to unmount interactive input
* @param {function(string):void} handleKeyPress
*/
const unmountInput = (handleKeyPress) => {
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdin.removeListener('data', handleKeyPress);
}
/**
* Interactive category selection
* @param {Record<string, Array<{name: string, path: string, fullPath: string}>>} rules
* @returns {Promise<string|null>} Selected category or null if cancelled
*/
export const interactiveCategorySelection = async (rules) => {
const categories = Object.keys(rules).filter(cat => rules[cat].length > 0);
if (categories.length === 0) {
console.log('ā No rule categories found');
return null;
}
let currentIndex = 0;
return new Promise((resolve) => {
/**
* @param {*} key
*/
const handleKeyPress = (key /** @type {string} */) => {
switch (key) {
case '\u0003': // Ctrl+C
case '\u001b': // Escape
case '\u001b[D': // Left Arrow
unmountInput(handleKeyPress);
process.stdin.removeListener('data', handleKeyPress);
console.log('\nā Category selection cancelled');
currentIndex = currentIndex;
resolve(null);
break;
case '\r': // Enter
case '\n':
unmountInput(handleKeyPress);
process.stdin.removeListener('data', handleKeyPress);
if (currentIndex === categories.length) {
// Finish selection
resolve('FINISH');
} else {
resolve(categories[currentIndex]);
}
break;
case '\u001b[A': // Up arrow
if (currentIndex > 0) {
currentIndex--;
renderCategoryMenu(categories, currentIndex);
}
break;
case '\u001b[B': // Down arrow
if (currentIndex < categories.length) {
currentIndex++;
renderCategoryMenu(categories, currentIndex);
}
break;
}
};
setupInput(handleKeyPress);
renderCategoryMenu(categories, currentIndex);
});
};
/**
* Render rule selection menu for a category
* @param {Array<{category: string, displayName: string, selected: boolean, name: string, path: string, fullPath: string}>} allRules
* @param {number} currentIndex
* @param {number} selectedCount
*/
const renderMenu = (allRules, currentIndex, selectedCount) => {
const items = allRules.map(rule => {
const checkbox = rule.selected ? 'ā' : 'ā';
return `${checkbox} ${rule.displayName}`;
});
createMenu({
title: `šÆ ${allRules[0]?.category} Rule Selection Mode āØ`,
items,
currentIndex,
footerLines: [
`\nāļø Selected: ${selectedCount}/${allRules.length} rules`,
'\nāā - Navigate | Space - Toggle selection | ā Enter - Confirm | Esc - Go Back'
]
});
};
/**
* Enable rule selection inside a category of rules
* @param {Array<{category: string, displayName: string, selected: boolean, name: string, path: string, fullPath: string}>} rulesInCategory
* @returns {Promise<Array<{category: string, displayName: string, selected: boolean, name: string, path: string, fullPath: string}>>}
*/
export const selectRules = async (rulesInCategory) => {
let allRules = rulesInCategory;
let currentIndex = 0;
let selectedCount = allRules.filter(r => r.selected).length;
let skipMenu = false;
/** @param {function(Array<{category: string, displayName: string, selected: boolean, name: string, path: string, fullPath: string}>?): void} resolve */
return new Promise((resolve) => {
/** @param key {string} */
const handleKeyPress = (key) => {
if (skipMenu) return;
switch (key) {
case '\u0003': // Ctrl+C
case '\u001b': // Escape
unmountInput(handleKeyPress);
// Return current state (persist selections)
resolve(allRules);
break;
case '\r': // Enter
case '\n':
skipMenu = true;
unmountInput(handleKeyPress);
resolve(allRules);
break;
case ' ':
const currentRule = allRules[currentIndex];
if (currentRule) {
currentRule.selected = !currentRule.selected;
selectedCount += currentRule.selected ? 1 : -1;
renderMenu(allRules, currentIndex, selectedCount);
}
break;
case '\u001b[A': // Up arrow
if (currentIndex > 0) {
currentIndex--;
renderMenu(allRules, currentIndex, selectedCount);
}
break;
case '\u001b[B': // Down arrow
if (currentIndex < allRules.length - 1) {
currentIndex++;
renderMenu(allRules, currentIndex, selectedCount);
}
break;
}
};
setupInput(handleKeyPress);
renderMenu(allRules, currentIndex, selectedCount);
});
};
/**
* Scan available rules from standards and test directories
* @param {string} rulesBasePath
* @returns {Promise<Record<string, Array<{name: string, path: string, fullPath: string}>>>} Object with categorized rules
*/
export const scanAvailableRules = async (rulesBasePath) => {
const categories = ['standards', 'test', 'utils'];
/** @type {Record<string, Array<{name: string, path: string, fullPath: string}>>} */
let rules = {}
for (const category of categories) {
const categoryPath = resolve(rulesBasePath, category);
try {
const files = await readdirSync(categoryPath, { withFileTypes: true });
rules[category] = files
.filter(file => file.isFile() && file.name.endsWith('.mdc'))
.map(file => ({
name: file.name.replace('.mdc', ''),
path: join(category, file.name),
fullPath: resolve(categoryPath, file.name)
}));
} catch (err) {
rules[category] = [];
}
}
return rules;
};