UNPKG

agent-rules-generator

Version:

Interactive CLI tool to generate .agent.md and .windsurfrules files for AI-assisted development

639 lines (563 loc) 17.9 kB
const fs = require('fs').promises; const path = require('path'); const https = require('https'); const yaml = require('js-yaml'); const os = require('os'); // Configuration for remote recipes const REMOTE_RECIPES_CONFIG = { // GitHub API endpoint for recipes directory githubApiUrl: 'https://api.github.com/repos/ubuntupunk/agent-rules-recipes/contents/recipes', // Raw GitHub URL for downloading recipe files githubRawUrl: 'https://raw.githubusercontent.com/ubuntupunk/agent-rules-recipes/main/recipes', // Local cache directory cacheDir: path.join(os.homedir(), '.agent-rules-cache'), // Cache expiration time (in milliseconds) - 24 hours cacheExpiration: 24 * 60 * 60 * 1000, // Fallback to local recipes if remote fails fallbackToLocal: true }; /** * Ensures the cache directory exists */ async function ensureCacheDir() { try { await fs.mkdir(REMOTE_RECIPES_CONFIG.cacheDir, { recursive: true }); } catch (error) { console.warn('Warning: Could not create cache directory'); } } /** * Makes an HTTPS request and returns the response data * @param {string} url - URL to fetch * @param {Object} options - Request options * @returns {Promise<string>} Response data */ async function httpsRequest(url, options = {}) { return new Promise((resolve, reject) => { const headers = { 'User-Agent': 'Agent-Rules-Generator/1.0.0', ...(options.headers || {}) }; // Add GitHub token if available const githubToken = process.env.GITHUB_TOKEN; if (githubToken && url.includes('api.github.com')) { headers['Authorization'] = `token ${githubToken}`; } const req = https.request(url, { method: options.method || 'GET', headers }, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { // For HEAD requests, we don't expect a body if (options.method === 'HEAD') { resolve({ statusCode: res.statusCode, headers: res.headers }); } else { try { // Try to parse JSON, fall back to raw data if not JSON const result = url.includes('api.github.com') ? JSON.parse(data) : data; resolve(result); } catch (e) { resolve(data); } } } else { let errorMessage = `Request failed with status ${res.statusCode}`; try { const errorData = JSON.parse(data); errorMessage = errorData.message || errorMessage; if (errorData.documentation_url) { errorMessage += `\nDocumentation: ${errorData.documentation_url}`; } } catch (e) {} const error = new Error(errorMessage); error.statusCode = res.statusCode; error.response = data; reject(error); } }); }); req.on('error', (error) => { reject(error); }); // Set timeout req.setTimeout(10000, () => { req.destroy(); reject(new Error('Request timeout after 10 seconds')); }); if (options.body) { req.write(JSON.stringify(options.body)); } req.end(); }); } /** * Downloads a recipe file from the remote repository * @param {string} fileName - Name of the recipe file * @returns {Promise<string>} Recipe file content */ async function downloadRecipeFile(fileName) { // Construct the raw content URL const rawUrl = `${REMOTE_RECIPES_CONFIG.githubRawUrl}/${fileName}`; try { // Make a direct request to the raw content URL const response = await httpsRequest(rawUrl, { headers: { 'Accept': 'application/vnd.github.v3.raw', 'User-Agent': 'Agent-Rules-Generator/1.0.0' } }); if (!response) { throw new Error('Received empty response from server'); } return response; } catch (error) { const enhancedError = new Error(`Failed to download file ${fileName}: ${error.message}`); enhancedError.originalError = error; enhancedError.fileName = fileName; console.error(`Failed to download file ${fileName}:`, error.message); throw enhancedError; } } /** * Gets the cache file path for a recipe * @param {string} fileName - Recipe file name * @returns {string} Cache file path */ function getCacheFilePath(fileName) { return path.join(REMOTE_RECIPES_CONFIG.cacheDir, fileName); } /** * Gets the cache metadata file path * @returns {string} Cache metadata file path */ function getCacheMetadataPath() { return path.join(REMOTE_RECIPES_CONFIG.cacheDir, 'cache-metadata.json'); } /** * Checks if the cache is still valid * @returns {Promise<boolean>} True if cache is valid */ async function isCacheValid() { try { const metadataPath = getCacheMetadataPath(); const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8')); const now = Date.now(); return (now - metadata.lastUpdate) < REMOTE_RECIPES_CONFIG.cacheExpiration; } catch (error) { return false; } } /** * Updates the cache metadata * @param {Array} recipeFiles - List of recipe files */ async function updateCacheMetadata(recipeFiles) { try { const metadata = { lastUpdate: Date.now(), recipeFiles: recipeFiles.map(file => ({ name: file.name, sha: file.sha })) }; const metadataPath = getCacheMetadataPath(); await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2)); } catch (error) { console.warn('Warning: Could not update cache metadata'); } } /** * Loads recipes from cache * @returns {Promise<Object>} Cached recipes */ async function loadCachedRecipes() { try { const cacheDir = REMOTE_RECIPES_CONFIG.cacheDir; const files = await fs.readdir(cacheDir); const recipes = {}; for (const file of files) { if (file.endsWith('.yml') || file.endsWith('.yaml')) { const filePath = path.join(cacheDir, file); const content = await fs.readFile(filePath, 'utf8'); const recipe = yaml.load(content); const recipeName = path.basename(file, path.extname(file)); recipes[recipeName] = recipe; } } return recipes; } catch (error) { return {}; } } /** * Loads local recipes as fallback * @returns {Promise<Object>} Local recipes */ async function loadLocalRecipes() { try { const recipesDir = path.join(__dirname, '..', 'recipes'); const files = await fs.readdir(recipesDir); const recipes = {}; for (const file of files) { if (file.endsWith('.yml') || file.endsWith('.yaml')) { const filePath = path.join(recipesDir, file); const content = await fs.readFile(filePath, 'utf8'); const recipe = yaml.load(content); const recipeName = path.basename(file, path.extname(file)); recipes[recipeName] = recipe; } } return recipes; } catch (error) { return {}; } } /** * Fetches and caches recipes from the remote repository * @returns {Promise<Object>} Remote recipes */ async function fetchAndCacheRemoteRecipes() { try { console.log('Fetching recipes from remote repository...'); await ensureCacheDir(); const recipeFiles = await fetchRecipeFileList(); if (recipeFiles.length === 0) { throw new Error('No recipe files found in remote repository'); } const recipes = {}; for (const fileInfo of recipeFiles) { try { const content = await downloadRecipeFile(fileInfo.name); const recipe = yaml.load(content); const recipeName = path.basename(fileInfo.name, path.extname(fileInfo.name)); // Validate recipe structure if (validateRecipe(recipe)) { recipes[recipeName] = recipe; // Cache the recipe file const cacheFilePath = getCacheFilePath(fileInfo.name); await fs.writeFile(cacheFilePath, content); } else { console.warn(`Warning: Invalid recipe structure in ${fileInfo.name}`); } } catch (error) { console.warn(`Warning: Could not load recipe ${fileInfo.name}: ${error.message}`); } } // Update cache metadata await updateCacheMetadata(recipeFiles); console.log(`Successfully loaded ${Object.keys(recipes).length} recipes from remote repository`); return recipes; } catch (error) { console.warn(`Warning: Could not fetch remote recipes: ${error.message}`); return {}; } } /** * Loads all available recipes with intelligent caching and fallback * @param {boolean} forceRefresh - Force refresh from remote repository * @returns {Promise<Object>} Object containing all loaded recipes */ async function loadRecipes(forceRefresh = false) { let recipes = {}; // Try to load from cache first if not forcing refresh if (!forceRefresh && await isCacheValid()) { console.log('Loading recipes from cache...'); recipes = await loadCachedRecipes(); if (Object.keys(recipes).length > 0) { return recipes; } } // Try to fetch from remote repository recipes = await fetchAndCacheRemoteRecipes(); // If remote fetch failed or returned no recipes, try cache if (Object.keys(recipes).length === 0) { console.log('Falling back to cached recipes...'); recipes = await loadCachedRecipes(); } // If still no recipes and fallback is enabled, try local recipes if (Object.keys(recipes).length === 0 && REMOTE_RECIPES_CONFIG.fallbackToLocal) { console.log('Falling back to local recipes...'); recipes = await loadLocalRecipes(); } if (Object.keys(recipes).length === 0) { console.warn('Warning: No recipes could be loaded from any source'); } return recipes; } /** * Forces a refresh of recipes from the remote repository * @returns {Promise<Object>} Fresh recipes from remote repository */ async function refreshRecipes() { return await loadRecipes(true); } /** * Clears the local recipe cache */ async function clearCache() { try { const cacheDir = REMOTE_RECIPES_CONFIG.cacheDir; const files = await fs.readdir(cacheDir); for (const file of files) { await fs.unlink(path.join(cacheDir, file)); } console.log('Recipe cache cleared successfully'); } catch (error) { console.warn('Warning: Could not clear recipe cache'); } } /** * Gets cache information * @returns {Promise<Object>} Cache information */ async function getCacheInfo() { try { const metadataPath = getCacheMetadataPath(); const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8')); const cacheAge = Date.now() - metadata.lastUpdate; return { lastUpdate: new Date(metadata.lastUpdate), cacheAge: cacheAge, isValid: cacheAge < REMOTE_RECIPES_CONFIG.cacheExpiration, recipeCount: metadata.recipeFiles.length, cacheDir: REMOTE_RECIPES_CONFIG.cacheDir }; } catch (error) { return { lastUpdate: null, cacheAge: null, isValid: false, recipeCount: 0, cacheDir: REMOTE_RECIPES_CONFIG.cacheDir }; } } /** * Searches for recipes matching given criteria * @param {string} query - Search query * @param {Object} recipes - Object containing all recipes * @returns {Array} Array of matching recipe keys */ function searchRecipes(query, recipes) { const searchTerm = query.toLowerCase(); const matches = []; for (const [key, recipe] of Object.entries(recipes)) { const searchableText = [ recipe.name, recipe.description, recipe.category, JSON.stringify(recipe.techStack), JSON.stringify(recipe.tags || []) ].join(' ').toLowerCase(); if (searchableText.includes(searchTerm)) { matches.push(key); } } return matches; } /** * Gets a specific recipe by name * @param {string} recipeName - Name of the recipe to get * @returns {Promise<Object|null>} Recipe object or null if not found */ async function getRecipe(recipeName) { const recipes = await loadRecipes(); return recipes[recipeName] || null; } /** * Lists all available recipes with basic info * @returns {Promise<Array>} Array of recipe summaries */ async function listRecipes() { const recipes = await loadRecipes(); return Object.entries(recipes).map(([key, recipe]) => ({ key, name: recipe.name, description: recipe.description, category: recipe.category, tags: recipe.tags || [] })); } /** * Validates a recipe structure * @param {Object} recipe - Recipe object to validate * @returns {boolean} True if valid, false otherwise */ function validateRecipe(recipe) { const requiredFields = ['name', 'description', 'category', 'techStack']; for (const field of requiredFields) { if (!recipe[field]) { return false; } } return true; } /** * Updates the remote recipes configuration * @param {Object} config - New configuration options */ function updateRemoteConfig(config) { Object.assign(REMOTE_RECIPES_CONFIG, config); } /** * Tests the connection to the remote repository with detailed validation * @returns {Promise<Object>} Test results with status and details */ async function testRepositoryConnection() { const results = { apiEndpoint: REMOTE_RECIPES_CONFIG.githubApiUrl, rawEndpoint: REMOTE_RECIPES_CONFIG.githubRawUrl, tests: {}, success: false, error: null }; try { // Test 1: Check if API endpoint is reachable results.tests.apiReachable = await testEndpointReachability(REMOTE_RECIPES_CONFIG.githubApiUrl); // Test 2: Check if raw endpoint is reachable by testing a known file const testRawFile = 'react_recipe.yaml'; // Replace with a known file in your repository const testRawUrl = `${REMOTE_RECIPES_CONFIG.githubRawUrl}/${testRawFile}`; results.tests.rawReachable = await testEndpointReachability(testRawUrl); // Test 3: Check GitHub rate limits const rateLimit = await checkRateLimit(); results.rateLimit = rateLimit; results.tests.rateLimit = rateLimit.remaining > 0; // Test 4: Try to fetch recipe list try { const startTime = Date.now(); const files = await fetchRecipeFileList(); results.tests.fetchRecipeList = { success: true, fileCount: files.length, duration: Date.now() - startTime }; // If we have files, try to download one as a test if (files.length > 0) { const testFile = files[0]; try { const content = await downloadRecipeFile(testFile.name); results.tests.downloadTest = { success: true, file: testFile.name, size: content.length }; } catch (error) { results.tests.downloadTest = { success: false, error: error.message, file: testFile.name }; throw new Error(`Failed to download test file: ${error.message}`); } } results.success = true; } catch (error) { results.tests.fetchRecipeList = { success: false, error: error.message }; throw error; } } catch (error) { results.error = error.message; results.success = false; } return results; } /** * Tests if an endpoint is reachable * @private * @param {string} url - URL to test * @returns {Promise<Object>} Reachability test results */ async function testEndpointReachability(url) { const startTime = Date.now(); try { const response = await httpsRequest(url, { method: 'HEAD' }); return { success: true, statusCode: response.statusCode, duration: Date.now() - startTime }; } catch (error) { return { success: false, error: error.message, duration: Date.now() - startTime }; } } /** * Checks GitHub API rate limits * @private * @returns {Promise<Object>} Rate limit information */ async function checkRateLimit() { try { const response = await httpsRequest('https://api.github.com/rate_limit'); const data = JSON.parse(response); return { limit: data.resources.core.limit, remaining: data.resources.core.remaining, reset: new Date(data.resources.core.reset * 1000).toISOString(), used: data.resources.core.used }; } catch (error) { return { error: error.message, remaining: 0 }; } } /** * Fetches the list of recipe files from GitHub API * @returns {Promise<Array>} Array of recipe file information */ async function fetchRecipeFileList() { try { const files = await httpsRequest(REMOTE_RECIPES_CONFIG.githubApiUrl); if (!Array.isArray(files)) { console.warn('Warning: Unexpected response format from GitHub API'); return []; } const recipeFiles = files.filter(file => { // Check if file has the required properties and is a YAML file return file && file.type === 'file' && file.name && (file.name.endsWith('.yaml') || file.name.endsWith('.yml')); }); console.log(`Found ${recipeFiles.length} recipe files`); return recipeFiles; } catch (error) { console.warn('Warning: Could not fetch recipe list from remote repository'); console.error('Error details:', error.message); return []; } } module.exports = { loadRecipes, refreshRecipes, clearCache, getCacheInfo, searchRecipes, getRecipe, listRecipes, validateRecipe, updateRemoteConfig, testRepositoryConnection, REMOTE_RECIPES_CONFIG, // Export internal functions for testing and debugging downloadRecipeFile, httpsRequest, fetchRecipeFileList, ensureCacheDir, loadCachedRecipes, loadLocalRecipes, fetchAndCacheRemoteRecipes };