Eyal Toledano c59bd8c9fc fix: Improve CLI flag validation for single-word flags
Fix issue with kebab-case validator incorrectly flagging single-word flags like --prompt. Refactor detectCamelCaseFlags to properly handle all single-word flags. Update tests to verify correct behavior with single-word and camelCase flags. Add support for alternative flag formats in init command (e.g., -my_name). This fixes a bug where users couldn't use the --prompt flag directly and had to use -p instead.
2025-03-26 15:54:51 -04:00

341 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* utils.js
* Utility functions for the Task Master CLI
*/
import fs from 'fs';
import path from 'path';
import chalk from 'chalk';
// Configuration and constants
const CONFIG = {
model: process.env.MODEL || 'claude-3-7-sonnet-20250219',
maxTokens: parseInt(process.env.MAX_TOKENS || '4000'),
temperature: parseFloat(process.env.TEMPERATURE || '0.7'),
debug: process.env.DEBUG === "true",
logLevel: process.env.LOG_LEVEL || "info",
defaultSubtasks: parseInt(process.env.DEFAULT_SUBTASKS || "3"),
defaultPriority: process.env.DEFAULT_PRIORITY || "medium",
projectName: process.env.PROJECT_NAME || "Task Master",
projectVersion: "1.5.0" // Hardcoded version - ALWAYS use this value, ignore environment variable
};
// Set up logging based on log level
const LOG_LEVELS = {
debug: 0,
info: 1,
warn: 2,
error: 3
};
/**
* Logs a message at the specified level
* @param {string} level - The log level (debug, info, warn, error)
* @param {...any} args - Arguments to log
*/
function log(level, ...args) {
const icons = {
debug: chalk.gray('🔍'),
info: chalk.blue(''),
warn: chalk.yellow('⚠️'),
error: chalk.red('❌'),
success: chalk.green('✅')
};
if (LOG_LEVELS[level] >= LOG_LEVELS[CONFIG.logLevel]) {
const icon = icons[level] || '';
console.log(`${icon} ${args.join(' ')}`);
}
}
/**
* Reads and parses a JSON file
* @param {string} filepath - Path to the JSON file
* @returns {Object} Parsed JSON data
*/
function readJSON(filepath) {
try {
const rawData = fs.readFileSync(filepath, 'utf8');
return JSON.parse(rawData);
} catch (error) {
log('error', `Error reading JSON file ${filepath}:`, error.message);
if (CONFIG.debug) {
console.error(error);
}
return null;
}
}
/**
* Writes data to a JSON file
* @param {string} filepath - Path to the JSON file
* @param {Object} data - Data to write
*/
function writeJSON(filepath, data) {
try {
fs.writeFileSync(filepath, JSON.stringify(data, null, 2));
} catch (error) {
log('error', `Error writing JSON file ${filepath}:`, error.message);
if (CONFIG.debug) {
console.error(error);
}
}
}
/**
* Sanitizes a prompt string for use in a shell command
* @param {string} prompt The prompt to sanitize
* @returns {string} Sanitized prompt
*/
function sanitizePrompt(prompt) {
// Replace double quotes with escaped double quotes
return prompt.replace(/"/g, '\\"');
}
/**
* Reads and parses the complexity report if it exists
* @param {string} customPath - Optional custom path to the report
* @returns {Object|null} The parsed complexity report or null if not found
*/
function readComplexityReport(customPath = null) {
try {
const reportPath = customPath || path.join(process.cwd(), 'scripts', 'task-complexity-report.json');
if (!fs.existsSync(reportPath)) {
return null;
}
const reportData = fs.readFileSync(reportPath, 'utf8');
return JSON.parse(reportData);
} catch (error) {
log('warn', `Could not read complexity report: ${error.message}`);
return null;
}
}
/**
* Finds a task analysis in the complexity report
* @param {Object} report - The complexity report
* @param {number} taskId - The task ID to find
* @returns {Object|null} The task analysis or null if not found
*/
function findTaskInComplexityReport(report, taskId) {
if (!report || !report.complexityAnalysis || !Array.isArray(report.complexityAnalysis)) {
return null;
}
return report.complexityAnalysis.find(task => task.taskId === taskId);
}
/**
* Checks if a task exists in the tasks array
* @param {Array} tasks - The tasks array
* @param {string|number} taskId - The task ID to check
* @returns {boolean} True if the task exists, false otherwise
*/
function taskExists(tasks, taskId) {
if (!taskId || !tasks || !Array.isArray(tasks)) {
return false;
}
// Handle both regular task IDs and subtask IDs (e.g., "1.2")
if (typeof taskId === 'string' && taskId.includes('.')) {
const [parentId, subtaskId] = taskId.split('.').map(id => parseInt(id, 10));
const parentTask = tasks.find(t => t.id === parentId);
if (!parentTask || !parentTask.subtasks) {
return false;
}
return parentTask.subtasks.some(st => st.id === subtaskId);
}
const id = parseInt(taskId, 10);
return tasks.some(t => t.id === id);
}
/**
* Formats a task ID as a string
* @param {string|number} id - The task ID to format
* @returns {string} The formatted task ID
*/
function formatTaskId(id) {
if (typeof id === 'string' && id.includes('.')) {
return id; // Already formatted as a string with a dot (e.g., "1.2")
}
if (typeof id === 'number') {
return id.toString();
}
return id;
}
/**
* Finds a task by ID in the tasks array
* @param {Array} tasks - The tasks array
* @param {string|number} taskId - The task ID to find
* @returns {Object|null} The task object or null if not found
*/
function findTaskById(tasks, taskId) {
if (!taskId || !tasks || !Array.isArray(tasks)) {
return null;
}
// Check if it's a subtask ID (e.g., "1.2")
if (typeof taskId === 'string' && taskId.includes('.')) {
const [parentId, subtaskId] = taskId.split('.').map(id => parseInt(id, 10));
const parentTask = tasks.find(t => t.id === parentId);
if (!parentTask || !parentTask.subtasks) {
return null;
}
const subtask = parentTask.subtasks.find(st => st.id === subtaskId);
if (subtask) {
// Add reference to parent task for context
subtask.parentTask = {
id: parentTask.id,
title: parentTask.title,
status: parentTask.status
};
subtask.isSubtask = true;
}
return subtask || null;
}
const id = parseInt(taskId, 10);
return tasks.find(t => t.id === id) || null;
}
/**
* Truncates text to a specified length
* @param {string} text - The text to truncate
* @param {number} maxLength - The maximum length
* @returns {string} The truncated text
*/
function truncate(text, maxLength) {
if (!text || text.length <= maxLength) {
return text;
}
return text.slice(0, maxLength - 3) + '...';
}
/**
* Find cycles in a dependency graph using DFS
* @param {string} subtaskId - Current subtask ID
* @param {Map} dependencyMap - Map of subtask IDs to their dependencies
* @param {Set} visited - Set of visited nodes
* @param {Set} recursionStack - Set of nodes in current recursion stack
* @returns {Array} - List of dependency edges that need to be removed to break cycles
*/
function findCycles(subtaskId, dependencyMap, visited = new Set(), recursionStack = new Set(), path = []) {
// Mark the current node as visited and part of recursion stack
visited.add(subtaskId);
recursionStack.add(subtaskId);
path.push(subtaskId);
const cyclesToBreak = [];
// Get all dependencies of the current subtask
const dependencies = dependencyMap.get(subtaskId) || [];
// For each dependency
for (const depId of dependencies) {
// If not visited, recursively check for cycles
if (!visited.has(depId)) {
const cycles = findCycles(depId, dependencyMap, visited, recursionStack, [...path]);
cyclesToBreak.push(...cycles);
}
// If the dependency is in the recursion stack, we found a cycle
else if (recursionStack.has(depId)) {
// Find the position of the dependency in the path
const cycleStartIndex = path.indexOf(depId);
// The last edge in the cycle is what we want to remove
const cycleEdges = path.slice(cycleStartIndex);
// We'll remove the last edge in the cycle (the one that points back)
cyclesToBreak.push(depId);
}
}
// Remove the node from recursion stack before returning
recursionStack.delete(subtaskId);
return cyclesToBreak;
}
/**
* Convert a string from camelCase to kebab-case
* @param {string} str - The string to convert
* @returns {string} The kebab-case version of the string
*/
const toKebabCase = (str) => {
// Special handling for common acronyms
const withReplacedAcronyms = str
.replace(/ID/g, 'Id')
.replace(/API/g, 'Api')
.replace(/UI/g, 'Ui')
.replace(/URL/g, 'Url')
.replace(/URI/g, 'Uri')
.replace(/JSON/g, 'Json')
.replace(/XML/g, 'Xml')
.replace(/HTML/g, 'Html')
.replace(/CSS/g, 'Css');
// Insert hyphens before capital letters and convert to lowercase
return withReplacedAcronyms
.replace(/([A-Z])/g, '-$1')
.toLowerCase()
.replace(/^-/, ''); // Remove leading hyphen if present
};
/**
* Detect camelCase flags in command arguments
* @param {string[]} args - Command line arguments to check
* @returns {Array<{original: string, kebabCase: string}>} - List of flags that should be converted
*/
function detectCamelCaseFlags(args) {
const camelCaseFlags = [];
for (const arg of args) {
if (arg.startsWith('--')) {
const flagName = arg.split('=')[0].slice(2); // Remove -- and anything after =
// Skip single-word flags - they can't be camelCase
if (!flagName.includes('-') && !/[A-Z]/.test(flagName)) {
continue;
}
// Check for camelCase pattern (lowercase followed by uppercase)
if (/[a-z][A-Z]/.test(flagName)) {
const kebabVersion = toKebabCase(flagName);
if (kebabVersion !== flagName) {
camelCaseFlags.push({
original: flagName,
kebabCase: kebabVersion
});
}
}
}
}
return camelCaseFlags;
}
// Export all utility functions and configuration
export {
CONFIG,
LOG_LEVELS,
log,
readJSON,
writeJSON,
sanitizePrompt,
readComplexityReport,
findTaskInComplexityReport,
taskExists,
formatTaskId,
findTaskById,
truncate,
findCycles,
toKebabCase,
detectCamelCaseFlags
};