/** * commands.js * Command-line interface for the Task Master CLI */ import { program } from 'commander'; import path from 'path'; import chalk from 'chalk'; import boxen from 'boxen'; import fs from 'fs'; import https from 'https'; import inquirer from 'inquirer'; import Table from 'cli-table3'; import { log, readJSON } from './utils.js'; import { parsePRD, updateTasks, generateTaskFiles, setTaskStatus, listTasks, expandTask, expandAllTasks, clearSubtasks, addTask, addSubtask, removeSubtask, analyzeTaskComplexity, updateTaskById, updateSubtaskById, removeTask, findTaskById, taskExists } from './task-manager.js'; import { addDependency, removeDependency, validateDependenciesCommand, fixDependenciesCommand } from './dependency-manager.js'; import { isApiKeySet, getDebugFlag, getConfig, writeConfig, ConfigurationError // Import the custom error } from './config-manager.js'; import { displayBanner, displayHelp, displayNextTask, displayTaskById, displayComplexityReport, getStatusWithColor, confirmTaskOverwrite, startLoadingIndicator, stopLoadingIndicator } from './ui.js'; import { initializeProject } from '../init.js'; import { getModelConfiguration, getAvailableModelsList, setModel } from './task-manager/models.js'; // Import new core functions import { findProjectRoot } from './utils.js'; /** * Configure and register CLI commands * @param {Object} program - Commander program instance */ function registerCommands(programInstance) { // Add global error handler for unknown options programInstance.on('option:unknown', function (unknownOption) { const commandName = this._name || 'unknown'; console.error(chalk.red(`Error: Unknown option '${unknownOption}'`)); console.error( chalk.yellow( `Run 'task-master ${commandName} --help' to see available options` ) ); process.exit(1); }); // Default help programInstance.on('--help', function () { displayHelp(); }); // parse-prd command programInstance .command('parse-prd') .description('Parse a PRD file and generate tasks') .argument('[file]', 'Path to the PRD file') .option( '-i, --input ', 'Path to the PRD file (alternative to positional argument)' ) .option('-o, --output ', 'Output file path', 'tasks/tasks.json') .option('-n, --num-tasks ', 'Number of tasks to generate', '10') .option('-f, --force', 'Skip confirmation when overwriting existing tasks') .action(async (file, options) => { // Use input option if file argument not provided const inputFile = file || options.input; const defaultPrdPath = 'scripts/prd.txt'; const numTasks = parseInt(options.numTasks, 10); const outputPath = options.output; const force = options.force || false; // Helper function to check if tasks.json exists and confirm overwrite async function confirmOverwriteIfNeeded() { if (fs.existsSync(outputPath) && !force) { const shouldContinue = await confirmTaskOverwrite(outputPath); if (!shouldContinue) { console.log(chalk.yellow('Operation cancelled by user.')); return false; } } return true; } // If no input file specified, check for default PRD location if (!inputFile) { if (fs.existsSync(defaultPrdPath)) { console.log(chalk.blue(`Using default PRD file: ${defaultPrdPath}`)); // Check for existing tasks.json before proceeding if (!(await confirmOverwriteIfNeeded())) return; console.log(chalk.blue(`Generating ${numTasks} tasks...`)); await parsePRD(defaultPrdPath, outputPath, numTasks); return; } console.log( chalk.yellow( 'No PRD file specified and default PRD file not found at scripts/prd.txt.' ) ); console.log( boxen( chalk.white.bold('Parse PRD Help') + '\n\n' + chalk.cyan('Usage:') + '\n' + ` task-master parse-prd [options]\n\n` + chalk.cyan('Options:') + '\n' + ' -i, --input Path to the PRD file (alternative to positional argument)\n' + ' -o, --output Output file path (default: "tasks/tasks.json")\n' + ' -n, --num-tasks Number of tasks to generate (default: 10)\n' + ' -f, --force Skip confirmation when overwriting existing tasks\n\n' + chalk.cyan('Example:') + '\n' + ' task-master parse-prd requirements.txt --num-tasks 15\n' + ' task-master parse-prd --input=requirements.txt\n' + ' task-master parse-prd --force\n\n' + chalk.yellow('Note: This command will:') + '\n' + ' 1. Look for a PRD file at scripts/prd.txt by default\n' + ' 2. Use the file specified by --input or positional argument if provided\n' + ' 3. Generate tasks from the PRD and overwrite any existing tasks.json file', { padding: 1, borderColor: 'blue', borderStyle: 'round' } ) ); return; } // Check for existing tasks.json before proceeding with specified input file if (!(await confirmOverwriteIfNeeded())) return; console.log(chalk.blue(`Parsing PRD file: ${inputFile}`)); console.log(chalk.blue(`Generating ${numTasks} tasks...`)); await parsePRD(inputFile, outputPath, numTasks); }); // update command programInstance .command('update') .description( 'Update multiple tasks with ID >= "from" based on new information or implementation changes' ) .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') .option( '--from ', 'Task ID to start updating from (tasks with ID >= this value will be updated)', '1' ) .option( '-p, --prompt ', 'Prompt explaining the changes or new context (required)' ) .option( '-r, --research', 'Use Perplexity AI for research-backed task updates' ) .action(async (options) => { const tasksPath = options.file; const fromId = parseInt(options.from, 10); // Validation happens here const prompt = options.prompt; const useResearch = options.research || false; // Check if there's an 'id' option which is a common mistake (instead of 'from') if ( process.argv.includes('--id') || process.argv.some((arg) => arg.startsWith('--id=')) ) { console.error( chalk.red('Error: The update command uses --from=, not --id=') ); console.log(chalk.yellow('\nTo update multiple tasks:')); console.log( ` task-master update --from=${fromId} --prompt="Your prompt here"` ); console.log( chalk.yellow( '\nTo update a single specific task, use the update-task command instead:' ) ); console.log( ` task-master update-task --id= --prompt="Your prompt here"` ); process.exit(1); } if (!prompt) { console.error( chalk.red( 'Error: --prompt parameter is required. Please provide information about the changes.' ) ); process.exit(1); } console.log( chalk.blue( `Updating tasks from ID >= ${fromId} with prompt: "${prompt}"` ) ); console.log(chalk.blue(`Tasks file: ${tasksPath}`)); if (useResearch) { console.log( chalk.blue('Using Perplexity AI for research-backed task updates') ); } // Call core updateTasks, passing empty context for CLI await updateTasks( tasksPath, fromId, prompt, useResearch, {} // Pass empty context ); }); // update-task command programInstance .command('update-task') .description( 'Update a single specific task by ID with new information (use --id parameter)' ) .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') .option('-i, --id ', 'Task ID to update (required)') .option( '-p, --prompt ', 'Prompt explaining the changes or new context (required)' ) .option( '-r, --research', 'Use Perplexity AI for research-backed task updates' ) .action(async (options) => { try { const tasksPath = options.file; // Validate required parameters if (!options.id) { console.error(chalk.red('Error: --id parameter is required')); console.log( chalk.yellow( 'Usage example: task-master update-task --id=23 --prompt="Update with new information"' ) ); process.exit(1); } // Parse the task ID and validate it's a number const taskId = parseInt(options.id, 10); if (isNaN(taskId) || taskId <= 0) { console.error( chalk.red( `Error: Invalid task ID: ${options.id}. Task ID must be a positive integer.` ) ); console.log( chalk.yellow( 'Usage example: task-master update-task --id=23 --prompt="Update with new information"' ) ); process.exit(1); } if (!options.prompt) { console.error( chalk.red( 'Error: --prompt parameter is required. Please provide information about the changes.' ) ); console.log( chalk.yellow( 'Usage example: task-master update-task --id=23 --prompt="Update with new information"' ) ); process.exit(1); } const prompt = options.prompt; const useResearch = options.research || false; // Validate tasks file exists if (!fs.existsSync(tasksPath)) { console.error( chalk.red(`Error: Tasks file not found at path: ${tasksPath}`) ); if (tasksPath === 'tasks/tasks.json') { console.log( chalk.yellow( 'Hint: Run task-master init or task-master parse-prd to create tasks.json first' ) ); } else { console.log( chalk.yellow( `Hint: Check if the file path is correct: ${tasksPath}` ) ); } process.exit(1); } console.log( chalk.blue(`Updating task ${taskId} with prompt: "${prompt}"`) ); console.log(chalk.blue(`Tasks file: ${tasksPath}`)); if (useResearch) { // Verify Perplexity API key exists if using research if (!isApiKeySet('perplexity')) { console.log( chalk.yellow( 'Warning: PERPLEXITY_API_KEY environment variable is missing. Research-backed updates will not be available.' ) ); console.log( chalk.yellow('Falling back to Claude AI for task update.') ); } else { console.log( chalk.blue('Using Perplexity AI for research-backed task update') ); } } const result = await updateTaskById( tasksPath, taskId, prompt, useResearch ); // If the task wasn't updated (e.g., if it was already marked as done) if (!result) { console.log( chalk.yellow( '\nTask update was not completed. Review the messages above for details.' ) ); } } catch (error) { console.error(chalk.red(`Error: ${error.message}`)); // Provide more helpful error messages for common issues if ( error.message.includes('task') && error.message.includes('not found') ) { console.log(chalk.yellow('\nTo fix this issue:')); console.log( ' 1. Run task-master list to see all available task IDs' ); console.log(' 2. Use a valid task ID with the --id parameter'); } else if (error.message.includes('API key')) { console.log( chalk.yellow( '\nThis error is related to API keys. Check your environment variables.' ) ); } // Use getDebugFlag getter instead of CONFIG.debug if (getDebugFlag()) { console.error(error); } process.exit(1); } }); // update-subtask command programInstance .command('update-subtask') .description( 'Update a subtask by appending additional timestamped information' ) .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') .option( '-i, --id ', 'Subtask ID to update in format "parentId.subtaskId" (required)' ) .option( '-p, --prompt ', 'Prompt explaining what information to add (required)' ) .option('-r, --research', 'Use Perplexity AI for research-backed updates') .action(async (options) => { try { const tasksPath = options.file; // Validate required parameters if (!options.id) { console.error(chalk.red('Error: --id parameter is required')); console.log( chalk.yellow( 'Usage example: task-master update-subtask --id=5.2 --prompt="Add more details about the API endpoint"' ) ); process.exit(1); } // Validate subtask ID format (should contain a dot) const subtaskId = options.id; if (!subtaskId.includes('.')) { console.error( chalk.red( `Error: Invalid subtask ID format: ${subtaskId}. Subtask ID must be in format "parentId.subtaskId"` ) ); console.log( chalk.yellow( 'Usage example: task-master update-subtask --id=5.2 --prompt="Add more details about the API endpoint"' ) ); process.exit(1); } if (!options.prompt) { console.error( chalk.red( 'Error: --prompt parameter is required. Please provide information to add to the subtask.' ) ); console.log( chalk.yellow( 'Usage example: task-master update-subtask --id=5.2 --prompt="Add more details about the API endpoint"' ) ); process.exit(1); } const prompt = options.prompt; const useResearch = options.research || false; // Validate tasks file exists if (!fs.existsSync(tasksPath)) { console.error( chalk.red(`Error: Tasks file not found at path: ${tasksPath}`) ); if (tasksPath === 'tasks/tasks.json') { console.log( chalk.yellow( 'Hint: Run task-master init or task-master parse-prd to create tasks.json first' ) ); } else { console.log( chalk.yellow( `Hint: Check if the file path is correct: ${tasksPath}` ) ); } process.exit(1); } console.log( chalk.blue(`Updating subtask ${subtaskId} with prompt: "${prompt}"`) ); console.log(chalk.blue(`Tasks file: ${tasksPath}`)); if (useResearch) { // Verify Perplexity API key exists if using research if (!isApiKeySet('perplexity')) { console.log( chalk.yellow( 'Warning: PERPLEXITY_API_KEY environment variable is missing. Research-backed updates will not be available.' ) ); console.log( chalk.yellow('Falling back to Claude AI for subtask update.') ); } else { console.log( chalk.blue( 'Using Perplexity AI for research-backed subtask update' ) ); } } const result = await updateSubtaskById( tasksPath, subtaskId, prompt, useResearch ); if (!result) { console.log( chalk.yellow( '\nSubtask update was not completed. Review the messages above for details.' ) ); } } catch (error) { console.error(chalk.red(`Error: ${error.message}`)); // Provide more helpful error messages for common issues if ( error.message.includes('subtask') && error.message.includes('not found') ) { console.log(chalk.yellow('\nTo fix this issue:')); console.log( ' 1. Run task-master list --with-subtasks to see all available subtask IDs' ); console.log( ' 2. Use a valid subtask ID with the --id parameter in format "parentId.subtaskId"' ); } else if (error.message.includes('API key')) { console.log( chalk.yellow( '\nThis error is related to API keys. Check your environment variables.' ) ); } // Use getDebugFlag getter instead of CONFIG.debug if (getDebugFlag()) { console.error(error); } process.exit(1); } }); // generate command programInstance .command('generate') .description('Generate task files from tasks.json') .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') .option('-o, --output ', 'Output directory', 'tasks') .action(async (options) => { const tasksPath = options.file; const outputDir = options.output; console.log(chalk.blue(`Generating task files from: ${tasksPath}`)); console.log(chalk.blue(`Output directory: ${outputDir}`)); await generateTaskFiles(tasksPath, outputDir); }); // set-status command programInstance .command('set-status') .description('Set the status of a task') .option( '-i, --id ', 'Task ID (can be comma-separated for multiple tasks)' ) .option( '-s, --status ', 'New status (todo, in-progress, review, done)' ) .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') .action(async (options) => { const tasksPath = options.file; const taskId = options.id; const status = options.status; if (!taskId || !status) { console.error(chalk.red('Error: Both --id and --status are required')); process.exit(1); } console.log( chalk.blue(`Setting status of task(s) ${taskId} to: ${status}`) ); await setTaskStatus(tasksPath, taskId, status); }); // list command programInstance .command('list') .description('List all tasks') .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') .option('-s, --status ', 'Filter by status') .option('--with-subtasks', 'Show subtasks for each task') .action(async (options) => { const tasksPath = options.file; const statusFilter = options.status; const withSubtasks = options.withSubtasks || false; console.log(chalk.blue(`Listing tasks from: ${tasksPath}`)); if (statusFilter) { console.log(chalk.blue(`Filtering by status: ${statusFilter}`)); } if (withSubtasks) { console.log(chalk.blue('Including subtasks in listing')); } await listTasks(tasksPath, statusFilter, withSubtasks); }); // expand command programInstance .command('expand') .description('Expand a task into subtasks using AI') .option('-i, --id ', 'ID of the task to expand') .option( '-a, --all', 'Expand all pending tasks based on complexity analysis' ) .option( '-n, --num ', 'Number of subtasks to generate (uses complexity analysis by default if available)' ) .option( '-r, --research', 'Enable research-backed generation (e.g., using Perplexity)', false ) .option('-p, --prompt ', 'Additional context for subtask generation') .option('-f, --force', 'Force expansion even if subtasks exist', false) // Ensure force option exists .option( '--file ', 'Path to the tasks file (relative to project root)', 'tasks/tasks.json' ) // Allow file override .action(async (options) => { const projectRoot = findProjectRoot(); if (!projectRoot) { console.error(chalk.red('Error: Could not find project root.')); process.exit(1); } const tasksPath = path.resolve(projectRoot, options.file); // Resolve tasks path if (options.all) { // --- Handle expand --all --- console.log(chalk.blue('Expanding all pending tasks...')); // Updated call to the refactored expandAllTasks try { const result = await expandAllTasks( tasksPath, options.num, // Pass num options.research, // Pass research flag options.prompt, // Pass additional context options.force, // Pass force flag {} // Pass empty context for CLI calls // outputFormat defaults to 'text' in expandAllTasks for CLI ); // Optional: Display summary from result console.log(chalk.green(`Expansion Summary:`)); console.log(chalk.green(` - Attempted: ${result.tasksToExpand}`)); console.log(chalk.green(` - Expanded: ${result.expandedCount}`)); console.log(chalk.yellow(` - Skipped: ${result.skippedCount}`)); console.log(chalk.red(` - Failed: ${result.failedCount}`)); } catch (error) { console.error( chalk.red(`Error expanding all tasks: ${error.message}`) ); process.exit(1); } } else if (options.id) { // --- Handle expand --id (Should be correct from previous refactor) --- if (!options.id) { console.error( chalk.red('Error: Task ID is required unless using --all.') ); process.exit(1); } console.log(chalk.blue(`Expanding task ${options.id}...`)); try { // Call the refactored expandTask function await expandTask( tasksPath, options.id, options.num, options.research, options.prompt, {}, // Pass empty context for CLI calls options.force // Pass the force flag down ); // expandTask logs its own success/failure for single task } catch (error) { console.error( chalk.red(`Error expanding task ${options.id}: ${error.message}`) ); process.exit(1); } } else { console.error( chalk.red('Error: You must specify either a task ID (--id) or --all.') ); programInstance.help(); // Show help } }); // analyze-complexity command programInstance .command('analyze-complexity') .description( `Analyze tasks and generate expansion recommendations${chalk.reset('')}` ) .option( '-o, --output ', 'Output file path for the report', 'scripts/task-complexity-report.json' ) .option( '-m, --model ', 'LLM model to use for analysis (defaults to configured model)' ) .option( '-t, --threshold ', 'Minimum complexity score to recommend expansion (1-10)', '5' ) .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') .option( '-r, --research', 'Use Perplexity AI for research-backed complexity analysis' ) .action(async (options) => { const tasksPath = options.file || 'tasks/tasks.json'; const outputPath = options.output; const modelOverride = options.model; const thresholdScore = parseFloat(options.threshold); const useResearch = options.research || false; console.log(chalk.blue(`Analyzing task complexity from: ${tasksPath}`)); console.log(chalk.blue(`Output report will be saved to: ${outputPath}`)); if (useResearch) { console.log( chalk.blue( 'Using Perplexity AI for research-backed complexity analysis' ) ); } await analyzeTaskComplexity(options); }); // clear-subtasks command programInstance .command('clear-subtasks') .description('Clear subtasks from specified tasks') .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') .option( '-i, --id ', 'Task IDs (comma-separated) to clear subtasks from' ) .option('--all', 'Clear subtasks from all tasks') .action(async (options) => { const tasksPath = options.file; const taskIds = options.id; const all = options.all; if (!taskIds && !all) { console.error( chalk.red( 'Error: Please specify task IDs with --id= or use --all to clear all tasks' ) ); process.exit(1); } if (all) { // If --all is specified, get all task IDs const data = readJSON(tasksPath); if (!data || !data.tasks) { console.error(chalk.red('Error: No valid tasks found')); process.exit(1); } const allIds = data.tasks.map((t) => t.id).join(','); clearSubtasks(tasksPath, allIds); } else { clearSubtasks(tasksPath, taskIds); } }); // add-task command programInstance .command('add-task') .description('Add a new task using AI or manual input') .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') .option( '-p, --prompt ', 'Description of the task to add (required if not using manual fields)' ) .option('-t, --title ', 'Task title (for manual task creation)') .option( '-d, --description <description>', 'Task description (for manual task creation)' ) .option( '--details <details>', 'Implementation details (for manual task creation)' ) .option( '--test-strategy <testStrategy>', 'Test strategy (for manual task creation)' ) .option( '--dependencies <dependencies>', 'Comma-separated list of task IDs this task depends on' ) .option( '--priority <priority>', 'Task priority (high, medium, low)', 'medium' ) .option( '-r, --research', 'Whether to use research capabilities for task creation' ) .action(async (options) => { const isManualCreation = options.title && options.description; // Validate that either prompt or title+description are provided if (!options.prompt && !isManualCreation) { console.error( chalk.red( 'Error: Either --prompt or both --title and --description must be provided' ) ); process.exit(1); } try { // Prepare dependencies if provided let dependencies = []; if (options.dependencies) { dependencies = options.dependencies .split(',') .map((id) => parseInt(id.trim(), 10)); } // Create manual task data if title and description are provided let manualTaskData = null; if (isManualCreation) { manualTaskData = { title: options.title, description: options.description, details: options.details || '', testStrategy: options.testStrategy || '' }; console.log( chalk.blue(`Creating task manually with title: "${options.title}"`) ); if (dependencies.length > 0) { console.log( chalk.blue(`Dependencies: [${dependencies.join(', ')}]`) ); } if (options.priority) { console.log(chalk.blue(`Priority: ${options.priority}`)); } } else { console.log( chalk.blue( `Creating task with AI using prompt: "${options.prompt}"` ) ); if (dependencies.length > 0) { console.log( chalk.blue(`Dependencies: [${dependencies.join(', ')}]`) ); } if (options.priority) { console.log(chalk.blue(`Priority: ${options.priority}`)); } } // Pass mcpLog and session for MCP mode const newTaskId = await addTask( options.file, options.prompt, dependencies, options.priority, { session: process.env // Pass environment as session for CLI }, 'text', // outputFormat null, // manualTaskData options.research || false // Pass the research flag value ); console.log(chalk.green(`✓ Added new task #${newTaskId}`)); console.log(chalk.gray('Next: Complete this task or add more tasks')); } catch (error) { console.error(chalk.red(`Error adding task: ${error.message}`)); if (error.stack && getDebugFlag()) { console.error(error.stack); } process.exit(1); } }); // next command programInstance .command('next') .description( `Show the next task to work on based on dependencies and status${chalk.reset('')}` ) .option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json') .action(async (options) => { const tasksPath = options.file; await displayNextTask(tasksPath); }); // show command programInstance .command('show') .description( `Display detailed information about a specific task${chalk.reset('')}` ) .argument('[id]', 'Task ID to show') .option('-i, --id <id>', 'Task ID to show') .option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json') .action(async (taskId, options) => { const idArg = taskId || options.id; if (!idArg) { console.error(chalk.red('Error: Please provide a task ID')); process.exit(1); } const tasksPath = options.file; await displayTaskById(tasksPath, idArg); }); // add-dependency command programInstance .command('add-dependency') .description('Add a dependency to a task') .option('-i, --id <id>', 'Task ID to add dependency to') .option('-d, --depends-on <id>', 'Task ID that will become a dependency') .option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json') .action(async (options) => { const tasksPath = options.file; const taskId = options.id; const dependencyId = options.dependsOn; if (!taskId || !dependencyId) { console.error( chalk.red('Error: Both --id and --depends-on are required') ); process.exit(1); } // Handle subtask IDs correctly by preserving the string format for IDs containing dots // Only use parseInt for simple numeric IDs const formattedTaskId = taskId.includes('.') ? taskId : parseInt(taskId, 10); const formattedDependencyId = dependencyId.includes('.') ? dependencyId : parseInt(dependencyId, 10); await addDependency(tasksPath, formattedTaskId, formattedDependencyId); }); // remove-dependency command programInstance .command('remove-dependency') .description('Remove a dependency from a task') .option('-i, --id <id>', 'Task ID to remove dependency from') .option('-d, --depends-on <id>', 'Task ID to remove as a dependency') .option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json') .action(async (options) => { const tasksPath = options.file; const taskId = options.id; const dependencyId = options.dependsOn; if (!taskId || !dependencyId) { console.error( chalk.red('Error: Both --id and --depends-on are required') ); process.exit(1); } // Handle subtask IDs correctly by preserving the string format for IDs containing dots // Only use parseInt for simple numeric IDs const formattedTaskId = taskId.includes('.') ? taskId : parseInt(taskId, 10); const formattedDependencyId = dependencyId.includes('.') ? dependencyId : parseInt(dependencyId, 10); await removeDependency(tasksPath, formattedTaskId, formattedDependencyId); }); // validate-dependencies command programInstance .command('validate-dependencies') .description( `Identify invalid dependencies without fixing them${chalk.reset('')}` ) .option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json') .action(async (options) => { await validateDependenciesCommand(options.file); }); // fix-dependencies command programInstance .command('fix-dependencies') .description(`Fix invalid dependencies automatically${chalk.reset('')}`) .option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json') .action(async (options) => { await fixDependenciesCommand(options.file); }); // complexity-report command programInstance .command('complexity-report') .description(`Display the complexity analysis report${chalk.reset('')}`) .option( '-f, --file <file>', 'Path to the report file', 'scripts/task-complexity-report.json' ) .action(async (options) => { await displayComplexityReport(options.file); }); // add-subtask command programInstance .command('add-subtask') .description('Add a subtask to an existing task') .option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json') .option('-p, --parent <id>', 'Parent task ID (required)') .option('-i, --task-id <id>', 'Existing task ID to convert to subtask') .option( '-t, --title <title>', 'Title for the new subtask (when creating a new subtask)' ) .option('-d, --description <text>', 'Description for the new subtask') .option('--details <text>', 'Implementation details for the new subtask') .option( '--dependencies <ids>', 'Comma-separated list of dependency IDs for the new subtask' ) .option('-s, --status <status>', 'Status for the new subtask', 'pending') .option('--skip-generate', 'Skip regenerating task files') .action(async (options) => { const tasksPath = options.file; const parentId = options.parent; const existingTaskId = options.taskId; const generateFiles = !options.skipGenerate; if (!parentId) { console.error( chalk.red( 'Error: --parent parameter is required. Please provide a parent task ID.' ) ); showAddSubtaskHelp(); process.exit(1); } // Parse dependencies if provided let dependencies = []; if (options.dependencies) { dependencies = options.dependencies.split(',').map((id) => { // Handle both regular IDs and dot notation return id.includes('.') ? id.trim() : parseInt(id.trim(), 10); }); } try { if (existingTaskId) { // Convert existing task to subtask console.log( chalk.blue( `Converting task ${existingTaskId} to a subtask of ${parentId}...` ) ); await addSubtask( tasksPath, parentId, existingTaskId, null, generateFiles ); console.log( chalk.green( `✓ Task ${existingTaskId} successfully converted to a subtask of task ${parentId}` ) ); } else if (options.title) { // Create new subtask with provided data console.log( chalk.blue(`Creating new subtask for parent task ${parentId}...`) ); const newSubtaskData = { title: options.title, description: options.description || '', details: options.details || '', status: options.status || 'pending', dependencies: dependencies }; const subtask = await addSubtask( tasksPath, parentId, null, newSubtaskData, generateFiles ); console.log( chalk.green( `✓ New subtask ${parentId}.${subtask.id} successfully created` ) ); // Display success message and suggested next steps console.log( boxen( chalk.white.bold( `Subtask ${parentId}.${subtask.id} Added Successfully` ) + '\n\n' + chalk.white(`Title: ${subtask.title}`) + '\n' + chalk.white(`Status: ${getStatusWithColor(subtask.status)}`) + '\n' + (dependencies.length > 0 ? chalk.white(`Dependencies: ${dependencies.join(', ')}`) + '\n' : '') + '\n' + chalk.white.bold('Next Steps:') + '\n' + chalk.cyan( `1. Run ${chalk.yellow(`task-master show ${parentId}`)} to see the parent task with all subtasks` ) + '\n' + chalk.cyan( `2. Run ${chalk.yellow(`task-master set-status --id=${parentId}.${subtask.id} --status=in-progress`)} to start working on it` ), { padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1 } } ) ); } else { console.error( chalk.red('Error: Either --task-id or --title must be provided.') ); console.log( boxen( chalk.white.bold('Usage Examples:') + '\n\n' + chalk.white('Convert existing task to subtask:') + '\n' + chalk.yellow( ` task-master add-subtask --parent=5 --task-id=8` ) + '\n\n' + chalk.white('Create new subtask:') + '\n' + chalk.yellow( ` task-master add-subtask --parent=5 --title="Implement login UI" --description="Create the login form"` ) + '\n\n', { padding: 1, borderColor: 'blue', borderStyle: 'round' } ) ); process.exit(1); } } catch (error) { console.error(chalk.red(`Error: ${error.message}`)); process.exit(1); } }) .on('error', function (err) { console.error(chalk.red(`Error: ${err.message}`)); showAddSubtaskHelp(); process.exit(1); }); // Helper function to show add-subtask command help function showAddSubtaskHelp() { console.log( boxen( chalk.white.bold('Add Subtask Command Help') + '\n\n' + chalk.cyan('Usage:') + '\n' + ` task-master add-subtask --parent=<id> [options]\n\n` + chalk.cyan('Options:') + '\n' + ' -p, --parent <id> Parent task ID (required)\n' + ' -i, --task-id <id> Existing task ID to convert to subtask\n' + ' -t, --title <title> Title for the new subtask\n' + ' -d, --description <text> Description for the new subtask\n' + ' --details <text> Implementation details for the new subtask\n' + ' --dependencies <ids> Comma-separated list of dependency IDs\n' + ' -s, --status <status> Status for the new subtask (default: "pending")\n' + ' -f, --file <file> Path to the tasks file (default: "tasks/tasks.json")\n' + ' --skip-generate Skip regenerating task files\n\n' + chalk.cyan('Examples:') + '\n' + ' task-master add-subtask --parent=5 --task-id=8\n' + ' task-master add-subtask -p 5 -t "Implement login UI" -d "Create the login form"', { padding: 1, borderColor: 'blue', borderStyle: 'round' } ) ); } // remove-subtask command programInstance .command('remove-subtask') .description('Remove a subtask from its parent task') .option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json') .option( '-i, --id <id>', 'Subtask ID(s) to remove in format "parentId.subtaskId" (can be comma-separated for multiple subtasks)' ) .option( '-c, --convert', 'Convert the subtask to a standalone task instead of deleting it' ) .option('--skip-generate', 'Skip regenerating task files') .action(async (options) => { const tasksPath = options.file; const subtaskIds = options.id; const convertToTask = options.convert || false; const generateFiles = !options.skipGenerate; if (!subtaskIds) { console.error( chalk.red( 'Error: --id parameter is required. Please provide subtask ID(s) in format "parentId.subtaskId".' ) ); showRemoveSubtaskHelp(); process.exit(1); } try { // Split by comma to support multiple subtask IDs const subtaskIdArray = subtaskIds.split(',').map((id) => id.trim()); for (const subtaskId of subtaskIdArray) { // Validate subtask ID format if (!subtaskId.includes('.')) { console.error( chalk.red( `Error: Subtask ID "${subtaskId}" must be in format "parentId.subtaskId"` ) ); showRemoveSubtaskHelp(); process.exit(1); } console.log(chalk.blue(`Removing subtask ${subtaskId}...`)); if (convertToTask) { console.log( chalk.blue('The subtask will be converted to a standalone task') ); } const result = await removeSubtask( tasksPath, subtaskId, convertToTask, generateFiles ); if (convertToTask && result) { // Display success message and next steps for converted task console.log( boxen( chalk.white.bold( `Subtask ${subtaskId} Converted to Task #${result.id}` ) + '\n\n' + chalk.white(`Title: ${result.title}`) + '\n' + chalk.white(`Status: ${getStatusWithColor(result.status)}`) + '\n' + chalk.white( `Dependencies: ${result.dependencies.join(', ')}` ) + '\n\n' + chalk.white.bold('Next Steps:') + '\n' + chalk.cyan( `1. Run ${chalk.yellow(`task-master show ${result.id}`)} to see details of the new task` ) + '\n' + chalk.cyan( `2. Run ${chalk.yellow(`task-master set-status --id=${result.id} --status=in-progress`)} to start working on it` ), { padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1 } } ) ); } else { // Display success message for deleted subtask console.log( boxen( chalk.white.bold(`Subtask ${subtaskId} Removed`) + '\n\n' + chalk.white('The subtask has been successfully deleted.'), { padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1 } } ) ); } } } catch (error) { console.error(chalk.red(`Error: ${error.message}`)); showRemoveSubtaskHelp(); process.exit(1); } }) .on('error', function (err) { console.error(chalk.red(`Error: ${err.message}`)); showRemoveSubtaskHelp(); process.exit(1); }); // Helper function to show remove-subtask command help function showRemoveSubtaskHelp() { console.log( boxen( chalk.white.bold('Remove Subtask Command Help') + '\n\n' + chalk.cyan('Usage:') + '\n' + ` task-master remove-subtask --id=<parentId.subtaskId> [options]\n\n` + chalk.cyan('Options:') + '\n' + ' -i, --id <id> Subtask ID(s) to remove in format "parentId.subtaskId" (can be comma-separated, required)\n' + ' -c, --convert Convert the subtask to a standalone task instead of deleting it\n' + ' -f, --file <file> Path to the tasks file (default: "tasks/tasks.json")\n' + ' --skip-generate Skip regenerating task files\n\n' + chalk.cyan('Examples:') + '\n' + ' task-master remove-subtask --id=5.2\n' + ' task-master remove-subtask --id=5.2,6.3,7.1\n' + ' task-master remove-subtask --id=5.2 --convert', { padding: 1, borderColor: 'blue', borderStyle: 'round' } ) ); } // remove-task command programInstance .command('remove-task') .description('Remove a task or subtask permanently') .option( '-i, --id <id>', 'ID of the task or subtask to remove (e.g., "5" or "5.2")' ) .option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json') .option('-y, --yes', 'Skip confirmation prompt', false) .action(async (options) => { const tasksPath = options.file; const taskId = options.id; if (!taskId) { console.error(chalk.red('Error: Task ID is required')); console.error( chalk.yellow('Usage: task-master remove-task --id=<taskId>') ); process.exit(1); } try { // Check if the task exists const data = readJSON(tasksPath); if (!data || !data.tasks) { console.error( chalk.red(`Error: No valid tasks found in ${tasksPath}`) ); process.exit(1); } if (!taskExists(data.tasks, taskId)) { console.error(chalk.red(`Error: Task with ID ${taskId} not found`)); process.exit(1); } // Load task for display const task = findTaskById(data.tasks, taskId); // Skip confirmation if --yes flag is provided if (!options.yes) { // Display task information console.log(); console.log( chalk.red.bold( '⚠️ WARNING: This will permanently delete the following task:' ) ); console.log(); if (typeof taskId === 'string' && taskId.includes('.')) { // It's a subtask const [parentId, subtaskId] = taskId.split('.'); console.log(chalk.white.bold(`Subtask ${taskId}: ${task.title}`)); console.log( chalk.gray( `Parent Task: ${task.parentTask.id} - ${task.parentTask.title}` ) ); } else { // It's a main task console.log(chalk.white.bold(`Task ${taskId}: ${task.title}`)); // Show if it has subtasks if (task.subtasks && task.subtasks.length > 0) { console.log( chalk.yellow( `⚠️ This task has ${task.subtasks.length} subtasks that will also be deleted!` ) ); } // Show if other tasks depend on it const dependentTasks = data.tasks.filter( (t) => t.dependencies && t.dependencies.includes(parseInt(taskId, 10)) ); if (dependentTasks.length > 0) { console.log( chalk.yellow( `⚠️ Warning: ${dependentTasks.length} other tasks depend on this task!` ) ); console.log(chalk.yellow('These dependencies will be removed:')); dependentTasks.forEach((t) => { console.log(chalk.yellow(` - Task ${t.id}: ${t.title}`)); }); } } console.log(); // Prompt for confirmation const { confirm } = await inquirer.prompt([ { type: 'confirm', name: 'confirm', message: chalk.red.bold( 'Are you sure you want to permanently delete this task?' ), default: false } ]); if (!confirm) { console.log(chalk.blue('Task deletion cancelled.')); process.exit(0); } } const indicator = startLoadingIndicator('Removing task...'); // Remove the task const result = await removeTask(tasksPath, taskId); stopLoadingIndicator(indicator); // Display success message with appropriate color based on task or subtask if (typeof taskId === 'string' && taskId.includes('.')) { // It was a subtask console.log( boxen( chalk.green(`Subtask ${taskId} has been successfully removed`), { padding: 1, borderColor: 'green', borderStyle: 'round' } ) ); } else { // It was a main task console.log( boxen(chalk.green(`Task ${taskId} has been successfully removed`), { padding: 1, borderColor: 'green', borderStyle: 'round' }) ); } } catch (error) { console.error( chalk.red(`Error: ${error.message || 'An unknown error occurred'}`) ); process.exit(1); } }); // init command (Directly calls the implementation from init.js) programInstance .command('init') .description('Initialize a new project with Task Master structure') .option('-y, --yes', 'Skip prompts and use default values') .option('-n, --name <name>', 'Project name') .option('-d, --description <description>', 'Project description') .option('-v, --version <version>', 'Project version', '0.1.0') // Set default here .option('-a, --author <author>', 'Author name') .option('--skip-install', 'Skip installing dependencies') .option('--dry-run', 'Show what would be done without making changes') .option('--aliases', 'Add shell aliases (tm, taskmaster)') .action(async (cmdOptions) => { // cmdOptions contains parsed arguments try { console.log('DEBUG: Running init command action in commands.js'); console.log( 'DEBUG: Options received by action:', JSON.stringify(cmdOptions) ); // Directly call the initializeProject function, passing the parsed options await initializeProject(cmdOptions); // initializeProject handles its own flow, including potential process.exit() } catch (error) { console.error( chalk.red(`Error during initialization: ${error.message}`) ); process.exit(1); } }); // models command programInstance .command('models') .description('Manage AI model configurations') .option( '--set-main <model_id>', 'Set the primary model for task generation/updates' ) .option( '--set-research <model_id>', 'Set the model for research-backed operations' ) .option( '--set-fallback <model_id>', 'Set the model to use if the primary fails' ) .option('--setup', 'Run interactive setup to configure models') .action(async (options) => { try { // --- Set Operations --- if (options.setMain || options.setResearch || options.setFallback) { let resultSet = null; if (options.setMain) { resultSet = await setModel('main', options.setMain); } else if (options.setResearch) { resultSet = await setModel('research', options.setResearch); } else if (options.setFallback) { resultSet = await setModel('fallback', options.setFallback); } if (resultSet?.success) { console.log(chalk.green(resultSet.data.message)); } else { console.error( chalk.red( `Error setting model: ${resultSet?.error?.message || 'Unknown error'}` ) ); if (resultSet?.error?.code === 'MODEL_NOT_FOUND') { console.log( chalk.yellow( '\nRun `task-master models` to see available models.' ) ); } process.exit(1); } return; // Exit after successful set operation } // --- Interactive Setup --- if (options.setup) { // Get available models for interactive setup const availableModelsResult = await getAvailableModelsList(); if (!availableModelsResult.success) { console.error( chalk.red( `Error fetching available models: ${availableModelsResult.error?.message || 'Unknown error'}` ) ); process.exit(1); } const availableModelsForSetup = availableModelsResult.data.models; const currentConfigResult = await getModelConfiguration(); if (!currentConfigResult.success) { console.error( chalk.red( `Error fetching current configuration: ${currentConfigResult.error?.message || 'Unknown error'}` ) ); // Allow setup even if current config fails (might be first time run) } const currentModels = currentConfigResult.data?.activeModels || { main: {}, research: {}, fallback: {} }; console.log(chalk.cyan.bold('\nInteractive Model Setup:')); const getMainChoicesAndDefault = () => { const mainChoices = allModelsForSetup.filter((modelChoice) => availableModelsForSetup .find((m) => m.modelId === modelChoice.value.id) ?.allowedRoles?.includes('main') ); const defaultIndex = mainChoices.findIndex( (m) => m.value.id === currentModels.main?.modelId ); return { choices: mainChoices, default: defaultIndex }; }; // Get all available models, including active ones const allModelsForSetup = availableModelsForSetup.map((model) => ({ name: `${model.provider} / ${model.modelId}`, value: { provider: model.provider, id: model.modelId } // Use id here for comparison })); if (allModelsForSetup.length === 0) { console.error( chalk.red('Error: No selectable models found in configuration.') ); process.exit(1); } // Function to find the index of the currently selected model ID // Ensure it correctly searches the unfiltered selectableModels list const findDefaultIndex = (roleModelId) => { if (!roleModelId) return -1; // Handle cases where a role isn't set return allModelsForSetup.findIndex( (m) => m.value.id === roleModelId // Compare using the 'id' from the value object ); }; // Helper to get research choices and default index const getResearchChoicesAndDefault = () => { const researchChoices = allModelsForSetup.filter((modelChoice) => availableModelsForSetup .find((m) => m.modelId === modelChoice.value.id) ?.allowedRoles?.includes('research') ); const defaultIndex = researchChoices.findIndex( (m) => m.value.id === currentModels.research?.modelId ); return { choices: researchChoices, default: defaultIndex }; }; // Helper to get fallback choices and default index const getFallbackChoicesAndDefault = () => { const choices = [ { name: 'None (disable fallback)', value: null }, new inquirer.Separator(), ...allModelsForSetup ]; const currentFallbackId = currentModels.fallback?.modelId; let defaultIndex = 0; // Default to 'None' if (currentFallbackId) { const foundIndex = allModelsForSetup.findIndex( (m) => m.value.id === currentFallbackId ); if (foundIndex !== -1) { defaultIndex = foundIndex + 2; // +2 because of 'None' and Separator } } return { choices, default: defaultIndex }; }; const researchPromptData = getResearchChoicesAndDefault(); const fallbackPromptData = getFallbackChoicesAndDefault(); // Call the helper function for main model choices const mainPromptData = getMainChoicesAndDefault(); // Add cancel option for all prompts const cancelOption = { name: 'Cancel setup (q)', value: '__CANCEL__' }; const mainModelChoices = [ cancelOption, new inquirer.Separator(), ...mainPromptData.choices ]; const researchModelChoices = [ cancelOption, new inquirer.Separator(), ...researchPromptData.choices ]; const fallbackModelChoices = [ cancelOption, new inquirer.Separator(), ...fallbackPromptData.choices ]; // Add key press handler for 'q' to cancel process.stdin.on('keypress', (str, key) => { if (key.name === 'q') { process.stdin.pause(); console.log(chalk.yellow('\nSetup canceled. No changes made.')); process.exit(0); } }); console.log(chalk.gray('Press "q" at any time to cancel the setup.')); const answers = await inquirer.prompt([ { type: 'list', name: 'mainModel', message: 'Select the main model for generation/updates:', choices: mainModelChoices, default: mainPromptData.default + 2 // +2 for cancel option and separator }, { type: 'list', name: 'researchModel', message: 'Select the research model:', choices: researchModelChoices, default: researchPromptData.default + 2, // +2 for cancel option and separator when: (answers) => answers.mainModel !== '__CANCEL__' }, { type: 'list', name: 'fallbackModel', message: 'Select the fallback model (optional):', choices: fallbackModelChoices, default: fallbackPromptData.default + 2, // +2 for cancel option and separator when: (answers) => answers.mainModel !== '__CANCEL__' && answers.researchModel !== '__CANCEL__' } ]); // Clean up the keypress handler process.stdin.removeAllListeners('keypress'); // Check if user canceled at any point if ( answers.mainModel === '__CANCEL__' || answers.researchModel === '__CANCEL__' || answers.fallbackModel === '__CANCEL__' ) { console.log(chalk.yellow('\nSetup canceled. No changes made.')); return; } // Apply changes using setModel let setupSuccess = true; let setupConfigModified = false; if ( answers.mainModel && answers.mainModel.id !== currentModels.main?.modelId ) { const result = await setModel('main', answers.mainModel.id); if (result.success) { console.log( chalk.blue( `Selected main model: ${result.data.provider} / ${result.data.modelId}` ) ); setupConfigModified = true; } else { console.error( chalk.red( `Error setting main model: ${result.error?.message || 'Unknown'}` ) ); setupSuccess = false; } } if ( answers.researchModel && answers.researchModel.id !== currentModels.research?.modelId ) { const result = await setModel('research', answers.researchModel.id); if (result.success) { console.log( chalk.blue( `Selected research model: ${result.data.provider} / ${result.data.modelId}` ) ); setupConfigModified = true; } else { console.error( chalk.red( `Error setting research model: ${result.error?.message || 'Unknown'}` ) ); setupSuccess = false; } } // Set Fallback Model - Handle 'None' selection const currentFallbackId = currentModels.fallback?.modelId; const selectedFallbackId = answers.fallbackModel?.id; // Will be null if 'None' selected if (selectedFallbackId !== currentFallbackId) { if (selectedFallbackId) { // User selected a specific fallback model const result = await setModel('fallback', selectedFallbackId); if (result.success) { console.log( chalk.blue( `Selected fallback model: ${result.data.provider} / ${result.data.modelId}` ) ); setupConfigModified = true; } else { console.error( chalk.red( `Error setting fallback model: ${result.error?.message || 'Unknown'}` ) ); setupSuccess = false; } } else if (currentFallbackId) { // User selected 'None' but a fallback was previously set // Need to explicitly clear it in the config file const currentCfg = getConfig(); currentCfg.models.fallback = { ...currentCfg.models.fallback, provider: undefined, modelId: undefined }; if (writeConfig(currentCfg)) { console.log(chalk.blue('Fallback model disabled.')); setupConfigModified = true; } else { console.error( chalk.red('Failed to disable fallback model in config file.') ); setupSuccess = false; } } // No action needed if fallback was already null/undefined and user selected None } if (setupSuccess && setupConfigModified) { console.log(chalk.green.bold('\nModel setup complete!')); } else if (setupSuccess && !setupConfigModified) { console.log( chalk.yellow('\nNo changes made to model configuration.') ); } else if (!setupSuccess) { console.error( chalk.red( '\nErrors occurred during model selection. Please review and try again.' ) ); } return; // Exit after setup attempt } // --- Default: Display Current Configuration --- // No longer need to check configModified here, as the set/setup logic returns early // Fetch configuration using the core function const result = await getModelConfiguration(); if (!result.success) { // Handle specific CONFIG_MISSING error gracefully if (result.error?.code === 'CONFIG_MISSING') { console.error( boxen( chalk.red.bold('Configuration File Missing!') + '\n\n' + chalk.white( 'The .taskmasterconfig file was not found in your project root.\n\n' + 'Run the interactive setup to create and configure it:' ) + '\n' + chalk.green(' task-master models --setup'), { padding: 1, margin: { top: 1 }, borderColor: 'red', borderStyle: 'round' } ) ); process.exit(0); // Exit gracefully, user needs to run setup } else { console.error( chalk.red( `Error fetching model configuration: ${result.error?.message || 'Unknown error'}` ) ); process.exit(1); } } const configData = result.data; const active = configData.activeModels; const warnings = configData.warnings || []; // Warnings now come from core function // --- Display Warning Banner (if any) --- if (warnings.length > 0) { console.log( boxen( chalk.red.bold('API Key Warnings:') + '\n\n' + warnings.join('\n'), { padding: 1, margin: { top: 1, bottom: 1 }, borderColor: 'red', borderStyle: 'round' } ) ); } // --- Active Configuration Section --- console.log(chalk.cyan.bold('\nActive Model Configuration:')); const activeTable = new Table({ head: [ 'Role', 'Provider', 'Model ID', 'SWE Score', 'Cost ($/1M tkns)', 'API Key Status' ].map((h) => chalk.cyan.bold(h)), colWidths: [10, 14, 30, 18, 20, 28], style: { head: ['cyan', 'bold'] } }); // --- Helper functions for formatting (can be moved to ui.js if complex) --- const formatSweScoreWithTertileStars = (score, allModels) => { if (score === null || score === undefined || score <= 0) return 'N/A'; const formattedPercentage = `${(score * 100).toFixed(1)}%`; const validScores = allModels .map((m) => m.sweScore) .filter((s) => s !== null && s !== undefined && s > 0); const sortedScores = [...validScores].sort((a, b) => b - a); const n = sortedScores.length; let stars = chalk.gray('☆☆☆'); if (n > 0) { const topThirdIndex = Math.max(0, Math.floor(n / 3) - 1); const midThirdIndex = Math.max(0, Math.floor((2 * n) / 3) - 1); if (score >= sortedScores[topThirdIndex]) stars = chalk.yellow('★★★'); else if (score >= sortedScores[midThirdIndex]) stars = chalk.yellow('★★') + chalk.gray('☆'); else stars = chalk.yellow('★') + chalk.gray('☆☆'); } return `${formattedPercentage} ${stars}`; }; const formatCost = (costObj) => { if (!costObj) return 'N/A'; // Check if both input and output costs are 0 and return "Free" if (costObj.input === 0 && costObj.output === 0) { return chalk.green('Free'); } const formatSingleCost = (costValue) => { if (costValue === null || costValue === undefined) return 'N/A'; const isInteger = Number.isInteger(costValue); return `$${costValue.toFixed(isInteger ? 0 : 2)}`; }; return `${formatSingleCost(costObj.input)} in, ${formatSingleCost( costObj.output )} out`; }; const getCombinedStatus = (keyStatus) => { const cliOk = keyStatus?.cli; const mcpOk = keyStatus?.mcp; const cliSymbol = cliOk ? chalk.green('✓') : chalk.red('✗'); const mcpSymbol = mcpOk ? chalk.green('✓') : chalk.red('✗'); if (cliOk && mcpOk) return `${cliSymbol} CLI & ${mcpSymbol} MCP OK`; if (cliOk && !mcpOk) return `${cliSymbol} CLI OK / ${mcpSymbol} MCP Missing`; if (!cliOk && mcpOk) return `${cliSymbol} CLI Missing / ${mcpSymbol} MCP OK`; return chalk.gray(`${cliSymbol} CLI & MCP Both Missing`); }; // Get all available models data once for SWE Score calculation const availableModelsResultForScore = await getAvailableModelsList(); const allAvailModelsForScore = availableModelsResultForScore.data?.models || []; // Populate Active Table activeTable.push([ chalk.white('Main'), active.main.provider, active.main.modelId, formatSweScoreWithTertileStars( active.main.sweScore, allAvailModelsForScore ), formatCost(active.main.cost), getCombinedStatus(active.main.keyStatus) ]); activeTable.push([ chalk.white('Research'), active.research.provider, active.research.modelId, formatSweScoreWithTertileStars( active.research.sweScore, allAvailModelsForScore ), formatCost(active.research.cost), getCombinedStatus(active.research.keyStatus) ]); if (active.fallback) { activeTable.push([ chalk.white('Fallback'), active.fallback.provider, active.fallback.modelId, formatSweScoreWithTertileStars( active.fallback.sweScore, allAvailModelsForScore ), formatCost(active.fallback.cost), getCombinedStatus(active.fallback.keyStatus) ]); } console.log(activeTable.toString()); // --- Available Models Section --- const availableResult = await getAvailableModelsList(); if (availableResult.success && availableResult.data.models.length > 0) { console.log(chalk.cyan.bold('\nOther Available Models:')); const availableTable = new Table({ head: ['Provider', 'Model ID', 'SWE Score', 'Cost ($/1M tkns)'].map( (h) => chalk.cyan.bold(h) ), colWidths: [15, 40, 18, 25], style: { head: ['cyan', 'bold'] } }); availableResult.data.models.forEach((model) => { availableTable.push([ model.provider, model.modelId, formatSweScoreWithTertileStars( model.sweScore, allAvailModelsForScore ), formatCost(model.cost) ]); }); console.log(availableTable.toString()); } else if (availableResult.success) { console.log( chalk.gray('\n(All available models are currently configured)') ); } else { console.warn( chalk.yellow( `Could not fetch available models list: ${availableResult.error?.message}` ) ); } // --- Suggested Actions Section --- console.log( boxen( chalk.white.bold('Next Steps:') + '\n' + chalk.cyan( `1. Set main model: ${chalk.yellow('task-master models --set-main <model_id>')}` ) + '\n' + chalk.cyan( `2. Set research model: ${chalk.yellow('task-master models --set-research <model_id>')}` ) + '\n' + chalk.cyan( `3. Set fallback model: ${chalk.yellow('task-master models --set-fallback <model_id>')}` ) + '\n' + chalk.cyan( `4. Run interactive setup: ${chalk.yellow('task-master models --setup')}` ), { padding: 1, borderColor: 'yellow', borderStyle: 'round', margin: { top: 1 } } ) ); } catch (error) { // Catch errors specifically from the core model functions console.error( chalk.red(`Error processing models command: ${error.message}`) ); if (error instanceof ConfigurationError) { // Provide specific guidance if it's a config error console.error( chalk.yellow( 'This might be a configuration file issue. Try running `task-master models --setup`.' ) ); } if (getDebugFlag()) { console.error(error.stack); } process.exit(1); } }); return programInstance; } /** * Setup the CLI application * @returns {Object} Configured Commander program */ function setupCLI() { // Create a new program instance const programInstance = program .name('dev') .description('AI-driven development task management') .version(() => { // Read version directly from package.json ONLY try { const packageJsonPath = path.join(process.cwd(), 'package.json'); if (fs.existsSync(packageJsonPath)) { const packageJson = JSON.parse( fs.readFileSync(packageJsonPath, 'utf8') ); return packageJson.version; } } catch (error) { // Silently fall back to 'unknown' log( 'warn', 'Could not read package.json for version info in .version()' ); } return 'unknown'; // Default fallback if package.json fails }) .helpOption('-h, --help', 'Display help') .addHelpCommand(false) // Disable default help command .on('--help', () => { displayHelp(); // Use your custom help display instead }) .on('-h', () => { displayHelp(); process.exit(0); }); // Modify the help option to use your custom display programInstance.helpInformation = () => { displayHelp(); return ''; }; // Register commands registerCommands(programInstance); return programInstance; } /** * Check for newer version of task-master-ai * @returns {Promise<{currentVersion: string, latestVersion: string, needsUpdate: boolean}>} */ async function checkForUpdate() { // Get current version from package.json ONLY let currentVersion = 'unknown'; // Initialize with a default try { // Try to get the version from the installed package (if applicable) or current dir let packageJsonPath = path.join( process.cwd(), 'node_modules', 'task-master-ai', 'package.json' ); // Fallback to current directory package.json if not found in node_modules if (!fs.existsSync(packageJsonPath)) { packageJsonPath = path.join(process.cwd(), 'package.json'); } if (fs.existsSync(packageJsonPath)) { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); currentVersion = packageJson.version; } } catch (error) { // Silently fail and use default log('debug', `Error reading current package version: ${error.message}`); } return new Promise((resolve) => { // Get the latest version from npm registry const options = { hostname: 'registry.npmjs.org', path: '/task-master-ai', method: 'GET', headers: { Accept: 'application/vnd.npm.install-v1+json' // Lightweight response } }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const npmData = JSON.parse(data); const latestVersion = npmData['dist-tags']?.latest || currentVersion; // Compare versions const needsUpdate = compareVersions(currentVersion, latestVersion) < 0; resolve({ currentVersion, latestVersion, needsUpdate }); } catch (error) { log('debug', `Error parsing npm response: ${error.message}`); resolve({ currentVersion, latestVersion: currentVersion, needsUpdate: false }); } }); }); req.on('error', (error) => { log('debug', `Error checking for updates: ${error.message}`); resolve({ currentVersion, latestVersion: currentVersion, needsUpdate: false }); }); // Set a timeout to avoid hanging if npm is slow req.setTimeout(3000, () => { req.abort(); log('debug', 'Update check timed out'); resolve({ currentVersion, latestVersion: currentVersion, needsUpdate: false }); }); req.end(); }); } /** * Compare semantic versions * @param {string} v1 - First version * @param {string} v2 - Second version * @returns {number} -1 if v1 < v2, 0 if v1 = v2, 1 if v1 > v2 */ function compareVersions(v1, v2) { const v1Parts = v1.split('.').map((p) => parseInt(p, 10)); const v2Parts = v2.split('.').map((p) => parseInt(p, 10)); for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) { const v1Part = v1Parts[i] || 0; const v2Part = v2Parts[i] || 0; if (v1Part < v2Part) return -1; if (v1Part > v2Part) return 1; } return 0; } /** * Display upgrade notification message * @param {string} currentVersion - Current version * @param {string} latestVersion - Latest version */ function displayUpgradeNotification(currentVersion, latestVersion) { const message = boxen( `${chalk.blue.bold('Update Available!')} ${chalk.dim(currentVersion)} → ${chalk.green(latestVersion)}\n\n` + `Run ${chalk.cyan('npm i task-master-ai@latest -g')} to update to the latest version with new features and bug fixes.`, { padding: 1, margin: { top: 1, bottom: 1 }, borderColor: 'yellow', borderStyle: 'round' } ); console.log(message); } /** * Parse arguments and run the CLI * @param {Array} argv - Command-line arguments */ async function runCLI(argv = process.argv) { try { // Display banner if not in a pipe if (process.stdout.isTTY) { displayBanner(); } // If no arguments provided, show help if (argv.length <= 2) { displayHelp(); process.exit(0); } // Start the update check in the background - don't await yet const updateCheckPromise = checkForUpdate(); // Setup and parse // NOTE: getConfig() might be called during setupCLI->registerCommands if commands need config // This means the ConfigurationError might be thrown here if .taskmasterconfig is missing. const programInstance = setupCLI(); await programInstance.parseAsync(argv); // After command execution, check if an update is available const updateInfo = await updateCheckPromise; if (updateInfo.needsUpdate) { displayUpgradeNotification( updateInfo.currentVersion, updateInfo.latestVersion ); } } catch (error) { // ** Specific catch block for missing configuration file ** if (error instanceof ConfigurationError) { console.error( boxen( chalk.red.bold('Configuration Update Required!') + '\n\n' + chalk.white('Taskmaster now uses the ') + chalk.yellow.bold('.taskmasterconfig') + chalk.white( ' file in your project root for AI model choices and settings.\n\n' + 'This file appears to be ' ) + chalk.red.bold('missing') + chalk.white('. No worries though.\n\n') + chalk.cyan.bold('To create this file, run the interactive setup:') + '\n' + chalk.green(' task-master models --setup') + '\n\n' + chalk.white.bold('Key Points:') + '\n' + chalk.white('* ') + chalk.yellow.bold('.taskmasterconfig') + chalk.white( ': Stores your AI model settings (do not manually edit)\n' ) + chalk.white('* ') + chalk.yellow.bold('.env & .mcp.json') + chalk.white(': Still used ') + chalk.red.bold('only') + chalk.white(' for your AI provider API keys.\n\n') + chalk.cyan( '`task-master models` to check your config & available models\n' ) + chalk.cyan( '`task-master models --setup` to adjust the AI models used by Taskmaster' ), { padding: 1, margin: { top: 1 }, borderColor: 'red', borderStyle: 'round' } ) ); } else { // Generic error handling for other errors console.error(chalk.red(`Error: ${error.message}`)); if (getDebugFlag()) { console.error(error); } } process.exit(1); } } export { registerCommands, setupCLI, runCLI, checkForUpdate, compareVersions, displayUpgradeNotification };