diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 6b838029..d401d82f 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,10 +1,8 @@ { - "mcpServers": { - "taskmaster-ai": { - "command": "node", - "args": [ - "./mcp-server/server.js" - ] - } - } -} \ No newline at end of file + "mcpServers": { + "taskmaster-ai": { + "command": "node", + "args": ["./mcp-server/server.js"] + } + } +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e2a5186..176e0ccc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 20 - cache: "npm" + cache: 'npm' - name: Cache node_modules uses: actions/cache@v4 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..39981020 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +# Ignore artifacts: +build +coverage +.changeset +tasks +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..936afdfb --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": true, + "semi": true, + "singleQuote": true, + "trailingComma": "none", + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/assets/scripts_README.md b/assets/scripts_README.md index 01fdd03c..46c14a67 100644 --- a/assets/scripts_README.md +++ b/assets/scripts_README.md @@ -21,9 +21,11 @@ In an AI-driven development process—particularly with tools like [Cursor](http The script can be configured through environment variables in a `.env` file at the root of the project: ### Required Configuration + - `ANTHROPIC_API_KEY`: Your Anthropic API key for Claude ### Optional Configuration + - `MODEL`: Specify which Claude model to use (default: "claude-3-7-sonnet-20250219") - `MAX_TOKENS`: Maximum tokens for model responses (default: 4000) - `TEMPERATURE`: Temperature for model responses (default: 0.7) @@ -38,9 +40,10 @@ The script can be configured through environment variables in a `.env` file at t ## How It Works -1. **`tasks.json`**: - - A JSON file at the project root containing an array of tasks (each with `id`, `title`, `description`, `status`, etc.). - - The `meta` field can store additional info like the project's name, version, or reference to the PRD. +1. **`tasks.json`**: + + - A JSON file at the project root containing an array of tasks (each with `id`, `title`, `description`, `status`, etc.). + - The `meta` field can store additional info like the project's name, version, or reference to the PRD. - Tasks can have `subtasks` for more detailed implementation steps. - Dependencies are displayed with status indicators (✅ for completed, ⏱️ for pending) to easily track progress. @@ -50,7 +53,7 @@ The script can be configured through environment variables in a `.env` file at t ```bash # If installed globally task-master [command] [options] - + # If using locally within the project node scripts/dev.js [command] [options] ``` @@ -111,6 +114,7 @@ task-master update --file=custom-tasks.json --from=5 --prompt="Change database f ``` Notes: + - The `--prompt` parameter is required and should explain the changes or new context - Only tasks that aren't marked as 'done' will be updated - Tasks with ID >= the specified --from value will be updated @@ -134,6 +138,7 @@ task-master set-status --id=1,2,3 --status=done ``` Notes: + - When marking a parent task as "done", all of its subtasks will automatically be marked as "done" as well - Common status values are 'done', 'pending', and 'deferred', but any string is accepted - You can specify multiple task IDs by separating them with commas @@ -183,6 +188,7 @@ task-master clear-subtasks --all ``` Notes: + - After clearing subtasks, task files are automatically regenerated - This is useful when you want to regenerate subtasks with a different approach - Can be combined with the `expand` command to immediately generate new subtasks @@ -198,6 +204,7 @@ The script integrates with two AI services: The Perplexity integration uses the OpenAI client to connect to Perplexity's API, which provides enhanced research capabilities for generating more informed subtasks. If the Perplexity API is unavailable or encounters an error, the script will automatically fall back to using Anthropic's Claude. To use the Perplexity integration: + 1. Obtain a Perplexity API key 2. Add `PERPLEXITY_API_KEY` to your `.env` file 3. Optionally specify `PERPLEXITY_MODEL` in your `.env` file (default: "sonar-medium-online") @@ -206,6 +213,7 @@ To use the Perplexity integration: ## Logging The script supports different logging levels controlled by the `LOG_LEVEL` environment variable: + - `debug`: Detailed information, typically useful for troubleshooting - `info`: Confirmation that things are working as expected (default) - `warn`: Warning messages that don't prevent execution @@ -228,17 +236,20 @@ task-master remove-dependency --id= --depends-on= These commands: 1. **Allow precise dependency management**: + - Add dependencies between tasks with automatic validation - Remove dependencies when they're no longer needed - Update task files automatically after changes 2. **Include validation checks**: + - Prevent circular dependencies (a task depending on itself) - Prevent duplicate dependencies - Verify that both tasks exist before adding/removing dependencies - Check if dependencies exist before attempting to remove them 3. **Provide clear feedback**: + - Success messages confirm when dependencies are added/removed - Error messages explain why operations failed (if applicable) @@ -263,6 +274,7 @@ task-master validate-dependencies --file=custom-tasks.json ``` This command: + - Scans all tasks and subtasks for non-existent dependencies - Identifies potential self-dependencies (tasks referencing themselves) - Reports all found issues without modifying files @@ -284,6 +296,7 @@ task-master fix-dependencies --file=custom-tasks.json ``` This command: + 1. **Validates all dependencies** across tasks and subtasks 2. **Automatically removes**: - References to non-existent tasks and subtasks @@ -321,6 +334,7 @@ task-master analyze-complexity --research ``` Notes: + - The command uses Claude to analyze each task's complexity (or Perplexity with --research flag) - Tasks are scored on a scale of 1-10 - Each task receives a recommended number of subtasks based on DEFAULT_SUBTASKS configuration @@ -345,33 +359,35 @@ task-master expand --id=8 --num=5 --prompt="Custom prompt" ``` When a complexity report exists: + - The `expand` command will use the recommended subtask count from the report (unless overridden) - It will use the tailored expansion prompt from the report (unless a custom prompt is provided) - When using `--all`, tasks are sorted by complexity score (highest first) - The `--research` flag is preserved from the complexity analysis to expansion The output report structure is: + ```json { - "meta": { - "generatedAt": "2023-06-15T12:34:56.789Z", - "tasksAnalyzed": 20, - "thresholdScore": 5, - "projectName": "Your Project Name", - "usedResearch": true - }, - "complexityAnalysis": [ - { - "taskId": 8, - "taskTitle": "Develop Implementation Drift Handling", - "complexityScore": 9.5, - "recommendedSubtasks": 6, - "expansionPrompt": "Create subtasks that handle detecting...", - "reasoning": "This task requires sophisticated logic...", - "expansionCommand": "task-master expand --id=8 --num=6 --prompt=\"Create subtasks...\" --research" - }, - // More tasks sorted by complexity score (highest first) - ] + "meta": { + "generatedAt": "2023-06-15T12:34:56.789Z", + "tasksAnalyzed": 20, + "thresholdScore": 5, + "projectName": "Your Project Name", + "usedResearch": true + }, + "complexityAnalysis": [ + { + "taskId": 8, + "taskTitle": "Develop Implementation Drift Handling", + "complexityScore": 9.5, + "recommendedSubtasks": 6, + "expansionPrompt": "Create subtasks that handle detecting...", + "reasoning": "This task requires sophisticated logic...", + "expansionCommand": "task-master expand --id=8 --num=6 --prompt=\"Create subtasks...\" --research" + } + // More tasks sorted by complexity score (highest first) + ] } ``` @@ -438,4 +454,4 @@ This command: - Commands for working with subtasks - For subtasks, provides a link to view the parent task -This command is particularly useful when you need to examine a specific task in detail before implementing it or when you want to check the status and details of a particular task. \ No newline at end of file +This command is particularly useful when you need to examine a specific task in detail before implementing it or when you want to check the status and details of a particular task. diff --git a/bin/task-master-init.js b/bin/task-master-init.js index 4c51663c..a4f27ffb 100755 --- a/bin/task-master-init.js +++ b/bin/task-master-init.js @@ -20,11 +20,11 @@ const args = process.argv.slice(2); // Spawn the init script with all arguments const child = spawn('node', [initScriptPath, ...args], { - stdio: 'inherit', - cwd: process.cwd() + stdio: 'inherit', + cwd: process.cwd() }); // Handle exit child.on('close', (code) => { - process.exit(code); -}); \ No newline at end of file + process.exit(code); +}); diff --git a/bin/task-master.js b/bin/task-master.js index 61fa5de1..afa40fc8 100755 --- a/bin/task-master.js +++ b/bin/task-master.js @@ -44,30 +44,36 @@ const initScriptPath = resolve(__dirname, '../scripts/init.js'); // Helper function to run dev.js with arguments function runDevScript(args) { - // Debug: Show the transformed arguments when DEBUG=1 is set - if (process.env.DEBUG === '1') { - console.error('\nDEBUG - CLI Wrapper Analysis:'); - console.error('- Original command: ' + process.argv.join(' ')); - console.error('- Transformed args: ' + args.join(' ')); - console.error('- dev.js will receive: node ' + devScriptPath + ' ' + args.join(' ') + '\n'); - } - - // For testing: If TEST_MODE is set, just print args and exit - if (process.env.TEST_MODE === '1') { - console.log('Would execute:'); - console.log(`node ${devScriptPath} ${args.join(' ')}`); - process.exit(0); - return; - } - - const child = spawn('node', [devScriptPath, ...args], { - stdio: 'inherit', - cwd: process.cwd() - }); - - child.on('close', (code) => { - process.exit(code); - }); + // Debug: Show the transformed arguments when DEBUG=1 is set + if (process.env.DEBUG === '1') { + console.error('\nDEBUG - CLI Wrapper Analysis:'); + console.error('- Original command: ' + process.argv.join(' ')); + console.error('- Transformed args: ' + args.join(' ')); + console.error( + '- dev.js will receive: node ' + + devScriptPath + + ' ' + + args.join(' ') + + '\n' + ); + } + + // For testing: If TEST_MODE is set, just print args and exit + if (process.env.TEST_MODE === '1') { + console.log('Would execute:'); + console.log(`node ${devScriptPath} ${args.join(' ')}`); + process.exit(0); + return; + } + + const child = spawn('node', [devScriptPath, ...args], { + stdio: 'inherit', + cwd: process.cwd() + }); + + child.on('close', (code) => { + process.exit(code); + }); } // Helper function to detect camelCase and convert to kebab-case @@ -79,228 +85,239 @@ const toKebabCase = (str) => str.replace(/([A-Z])/g, '-$1').toLowerCase(); * @returns {Function} Wrapper action function */ function createDevScriptAction(commandName) { - return (options, cmd) => { - // Check for camelCase flags and error out with helpful message - const camelCaseFlags = detectCamelCaseFlags(process.argv); - - // If camelCase flags were found, show error and exit - if (camelCaseFlags.length > 0) { - console.error('\nError: Please use kebab-case for CLI flags:'); - camelCaseFlags.forEach(flag => { - console.error(` Instead of: --${flag.original}`); - console.error(` Use: --${flag.kebabCase}`); - }); - console.error('\nExample: task-master parse-prd --num-tasks=5 instead of --numTasks=5\n'); - process.exit(1); - } - - // Since we've ensured no camelCase flags, we can now just: - // 1. Start with the command name - const args = [commandName]; - - // 3. Get positional arguments and explicit flags from the command line - const commandArgs = []; - const positionals = new Set(); // Track positional args we've seen - - // Find the command in raw process.argv to extract args - const commandIndex = process.argv.indexOf(commandName); - if (commandIndex !== -1) { - // Process all args after the command name - for (let i = commandIndex + 1; i < process.argv.length; i++) { - const arg = process.argv[i]; - - if (arg.startsWith('--')) { - // It's a flag - pass through as is - commandArgs.push(arg); - // Skip the next arg if this is a flag with a value (not --flag=value format) - if (!arg.includes('=') && - i + 1 < process.argv.length && - !process.argv[i+1].startsWith('--')) { - commandArgs.push(process.argv[++i]); - } - } else if (!positionals.has(arg)) { - // It's a positional argument we haven't seen - commandArgs.push(arg); - positionals.add(arg); - } - } - } - - // Add all command line args we collected - args.push(...commandArgs); - - // 4. Add default options from Commander if not specified on command line - // Track which options we've seen on the command line - const userOptions = new Set(); - for (const arg of commandArgs) { - if (arg.startsWith('--')) { - // Extract option name (without -- and value) - const name = arg.split('=')[0].slice(2); - userOptions.add(name); - - // Add the kebab-case version too, to prevent duplicates - const kebabName = name.replace(/([A-Z])/g, '-$1').toLowerCase(); - userOptions.add(kebabName); - - // Add the camelCase version as well - const camelName = kebabName.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); - userOptions.add(camelName); - } - } - - // Add Commander-provided defaults for options not specified by user - Object.entries(options).forEach(([key, value]) => { - // Debug output to see what keys we're getting - if (process.env.DEBUG === '1') { - console.error(`DEBUG - Processing option: ${key} = ${value}`); - } + return (options, cmd) => { + // Check for camelCase flags and error out with helpful message + const camelCaseFlags = detectCamelCaseFlags(process.argv); - // Special case for numTasks > num-tasks (a known problem case) - if (key === 'numTasks') { - if (process.env.DEBUG === '1') { - console.error('DEBUG - Converting numTasks to num-tasks'); - } - if (!userOptions.has('num-tasks') && !userOptions.has('numTasks')) { - args.push(`--num-tasks=${value}`); - } - return; - } - - // Skip built-in Commander properties and options the user provided - if (['parent', 'commands', 'options', 'rawArgs'].includes(key) || userOptions.has(key)) { - return; - } - - // Also check the kebab-case version of this key - const kebabKey = key.replace(/([A-Z])/g, '-$1').toLowerCase(); - if (userOptions.has(kebabKey)) { - return; - } - - // Add default values, using kebab-case for the parameter name - if (value !== undefined) { - if (typeof value === 'boolean') { - if (value === true) { - args.push(`--${kebabKey}`); - } else if (value === false && key === 'generate') { - args.push('--skip-generate'); - } - } else { - // Always use kebab-case for option names - args.push(`--${kebabKey}=${value}`); - } - } - }); - - // Special handling for parent parameter (uses -p) - if (options.parent && !args.includes('-p') && !userOptions.has('parent')) { - args.push('-p', options.parent); - } - - // Debug output for troubleshooting - if (process.env.DEBUG === '1') { - console.error('DEBUG - Command args:', commandArgs); - console.error('DEBUG - User options:', Array.from(userOptions)); - console.error('DEBUG - Commander options:', options); - console.error('DEBUG - Final args:', args); - } - - // Run the script with our processed args - runDevScript(args); - }; + // If camelCase flags were found, show error and exit + if (camelCaseFlags.length > 0) { + console.error('\nError: Please use kebab-case for CLI flags:'); + camelCaseFlags.forEach((flag) => { + console.error(` Instead of: --${flag.original}`); + console.error(` Use: --${flag.kebabCase}`); + }); + console.error( + '\nExample: task-master parse-prd --num-tasks=5 instead of --numTasks=5\n' + ); + process.exit(1); + } + + // Since we've ensured no camelCase flags, we can now just: + // 1. Start with the command name + const args = [commandName]; + + // 3. Get positional arguments and explicit flags from the command line + const commandArgs = []; + const positionals = new Set(); // Track positional args we've seen + + // Find the command in raw process.argv to extract args + const commandIndex = process.argv.indexOf(commandName); + if (commandIndex !== -1) { + // Process all args after the command name + for (let i = commandIndex + 1; i < process.argv.length; i++) { + const arg = process.argv[i]; + + if (arg.startsWith('--')) { + // It's a flag - pass through as is + commandArgs.push(arg); + // Skip the next arg if this is a flag with a value (not --flag=value format) + if ( + !arg.includes('=') && + i + 1 < process.argv.length && + !process.argv[i + 1].startsWith('--') + ) { + commandArgs.push(process.argv[++i]); + } + } else if (!positionals.has(arg)) { + // It's a positional argument we haven't seen + commandArgs.push(arg); + positionals.add(arg); + } + } + } + + // Add all command line args we collected + args.push(...commandArgs); + + // 4. Add default options from Commander if not specified on command line + // Track which options we've seen on the command line + const userOptions = new Set(); + for (const arg of commandArgs) { + if (arg.startsWith('--')) { + // Extract option name (without -- and value) + const name = arg.split('=')[0].slice(2); + userOptions.add(name); + + // Add the kebab-case version too, to prevent duplicates + const kebabName = name.replace(/([A-Z])/g, '-$1').toLowerCase(); + userOptions.add(kebabName); + + // Add the camelCase version as well + const camelName = kebabName.replace(/-([a-z])/g, (_, letter) => + letter.toUpperCase() + ); + userOptions.add(camelName); + } + } + + // Add Commander-provided defaults for options not specified by user + Object.entries(options).forEach(([key, value]) => { + // Debug output to see what keys we're getting + if (process.env.DEBUG === '1') { + console.error(`DEBUG - Processing option: ${key} = ${value}`); + } + + // Special case for numTasks > num-tasks (a known problem case) + if (key === 'numTasks') { + if (process.env.DEBUG === '1') { + console.error('DEBUG - Converting numTasks to num-tasks'); + } + if (!userOptions.has('num-tasks') && !userOptions.has('numTasks')) { + args.push(`--num-tasks=${value}`); + } + return; + } + + // Skip built-in Commander properties and options the user provided + if ( + ['parent', 'commands', 'options', 'rawArgs'].includes(key) || + userOptions.has(key) + ) { + return; + } + + // Also check the kebab-case version of this key + const kebabKey = key.replace(/([A-Z])/g, '-$1').toLowerCase(); + if (userOptions.has(kebabKey)) { + return; + } + + // Add default values, using kebab-case for the parameter name + if (value !== undefined) { + if (typeof value === 'boolean') { + if (value === true) { + args.push(`--${kebabKey}`); + } else if (value === false && key === 'generate') { + args.push('--skip-generate'); + } + } else { + // Always use kebab-case for option names + args.push(`--${kebabKey}=${value}`); + } + } + }); + + // Special handling for parent parameter (uses -p) + if (options.parent && !args.includes('-p') && !userOptions.has('parent')) { + args.push('-p', options.parent); + } + + // Debug output for troubleshooting + if (process.env.DEBUG === '1') { + console.error('DEBUG - Command args:', commandArgs); + console.error('DEBUG - User options:', Array.from(userOptions)); + console.error('DEBUG - Commander options:', options); + console.error('DEBUG - Final args:', args); + } + + // Run the script with our processed args + runDevScript(args); + }; } // Special case for the 'init' command which uses a different script function registerInitCommand(program) { - program - .command('init') - .description('Initialize a new project') - .option('-y, --yes', 'Skip prompts and use default values') - .option('-n, --name ', 'Project name') - .option('-d, --description ', 'Project description') - .option('-v, --version ', 'Project version') - .option('-a, --author ', 'Author name') - .option('--skip-install', 'Skip installing dependencies') - .option('--dry-run', 'Show what would be done without making changes') - .action((options) => { - // Pass through any options to the init script - const args = ['--yes', 'name', 'description', 'version', 'author', 'skip-install', 'dry-run'] - .filter(opt => options[opt]) - .map(opt => { - if (opt === 'yes' || opt === 'skip-install' || opt === 'dry-run') { - return `--${opt}`; - } - return `--${opt}=${options[opt]}`; - }); - - const child = spawn('node', [initScriptPath, ...args], { - stdio: 'inherit', - cwd: process.cwd() - }); - - child.on('close', (code) => { - process.exit(code); - }); - }); + program + .command('init') + .description('Initialize a new project') + .option('-y, --yes', 'Skip prompts and use default values') + .option('-n, --name ', 'Project name') + .option('-d, --description ', 'Project description') + .option('-v, --version ', 'Project version') + .option('-a, --author ', 'Author name') + .option('--skip-install', 'Skip installing dependencies') + .option('--dry-run', 'Show what would be done without making changes') + .action((options) => { + // Pass through any options to the init script + const args = [ + '--yes', + 'name', + 'description', + 'version', + 'author', + 'skip-install', + 'dry-run' + ] + .filter((opt) => options[opt]) + .map((opt) => { + if (opt === 'yes' || opt === 'skip-install' || opt === 'dry-run') { + return `--${opt}`; + } + return `--${opt}=${options[opt]}`; + }); + + const child = spawn('node', [initScriptPath, ...args], { + stdio: 'inherit', + cwd: process.cwd() + }); + + child.on('close', (code) => { + process.exit(code); + }); + }); } // Set up the command-line interface const program = new Command(); program - .name('task-master') - .description('Claude Task Master CLI') - .version(version) - .addHelpText('afterAll', () => { - // Use the same help display function as dev.js for consistency - displayHelp(); - return ''; // Return empty string to prevent commander's default help - }); + .name('task-master') + .description('Claude Task Master CLI') + .version(version) + .addHelpText('afterAll', () => { + // Use the same help display function as dev.js for consistency + displayHelp(); + return ''; // Return empty string to prevent commander's default help + }); // Add custom help option to directly call our help display program.helpOption('-h, --help', 'Display help information'); program.on('--help', () => { - displayHelp(); + displayHelp(); }); // Add special case commands registerInitCommand(program); program - .command('dev') - .description('Run the dev.js script') - .action(() => { - const args = process.argv.slice(process.argv.indexOf('dev') + 1); - runDevScript(args); - }); + .command('dev') + .description('Run the dev.js script') + .action(() => { + const args = process.argv.slice(process.argv.indexOf('dev') + 1); + runDevScript(args); + }); // Use a temporary Command instance to get all command definitions const tempProgram = new Command(); registerCommands(tempProgram); // For each command in the temp instance, add a modified version to our actual program -tempProgram.commands.forEach(cmd => { - if (['init', 'dev'].includes(cmd.name())) { - // Skip commands we've already defined specially - return; - } - - // Create a new command with the same name and description - const newCmd = program - .command(cmd.name()) - .description(cmd.description()); - - // Copy all options - cmd.options.forEach(opt => { - newCmd.option( - opt.flags, - opt.description, - opt.defaultValue - ); - }); - - // Set the action to proxy to dev.js - newCmd.action(createDevScriptAction(cmd.name())); +tempProgram.commands.forEach((cmd) => { + if (['init', 'dev'].includes(cmd.name())) { + // Skip commands we've already defined specially + return; + } + + // Create a new command with the same name and description + const newCmd = program.command(cmd.name()).description(cmd.description()); + + // Copy all options + cmd.options.forEach((opt) => { + newCmd.option(opt.flags, opt.description, opt.defaultValue); + }); + + // Set the action to proxy to dev.js + newCmd.action(createDevScriptAction(cmd.name())); }); // Parse the command line arguments @@ -308,47 +325,56 @@ program.parse(process.argv); // Add global error handling for unknown commands and options process.on('uncaughtException', (err) => { - // Check if this is a commander.js unknown option error - if (err.code === 'commander.unknownOption') { - const option = err.message.match(/'([^']+)'/)?.[1]; - const commandArg = process.argv.find(arg => !arg.startsWith('-') && - arg !== 'task-master' && - !arg.includes('/') && - arg !== 'node'); - const command = commandArg || 'unknown'; - - console.error(chalk.red(`Error: Unknown option '${option}'`)); - console.error(chalk.yellow(`Run 'task-master ${command} --help' to see available options for this command`)); - process.exit(1); - } - - // Check if this is a commander.js unknown command error - if (err.code === 'commander.unknownCommand') { - const command = err.message.match(/'([^']+)'/)?.[1]; - - console.error(chalk.red(`Error: Unknown command '${command}'`)); - console.error(chalk.yellow(`Run 'task-master --help' to see available commands`)); - process.exit(1); - } - - // Handle other uncaught exceptions - console.error(chalk.red(`Error: ${err.message}`)); - if (process.env.DEBUG === '1') { - console.error(err); - } - process.exit(1); + // Check if this is a commander.js unknown option error + if (err.code === 'commander.unknownOption') { + const option = err.message.match(/'([^']+)'/)?.[1]; + const commandArg = process.argv.find( + (arg) => + !arg.startsWith('-') && + arg !== 'task-master' && + !arg.includes('/') && + arg !== 'node' + ); + const command = commandArg || 'unknown'; + + console.error(chalk.red(`Error: Unknown option '${option}'`)); + console.error( + chalk.yellow( + `Run 'task-master ${command} --help' to see available options for this command` + ) + ); + process.exit(1); + } + + // Check if this is a commander.js unknown command error + if (err.code === 'commander.unknownCommand') { + const command = err.message.match(/'([^']+)'/)?.[1]; + + console.error(chalk.red(`Error: Unknown command '${command}'`)); + console.error( + chalk.yellow(`Run 'task-master --help' to see available commands`) + ); + process.exit(1); + } + + // Handle other uncaught exceptions + console.error(chalk.red(`Error: ${err.message}`)); + if (process.env.DEBUG === '1') { + console.error(err); + } + process.exit(1); }); // Show help if no command was provided (just 'task-master' with no args) if (process.argv.length <= 2) { - displayBanner(); - displayHelp(); - process.exit(0); + displayBanner(); + displayHelp(); + process.exit(0); } // Add exports at the end of the file if (typeof module !== 'undefined') { - module.exports = { - detectCamelCaseFlags - }; -} \ No newline at end of file + module.exports = { + detectCamelCaseFlags + }; +} diff --git a/docs/MCP_INTEGRATION.md b/docs/MCP_INTEGRATION.md index e1212841..7cf2b023 100644 --- a/docs/MCP_INTEGRATION.md +++ b/docs/MCP_INTEGRATION.md @@ -41,39 +41,39 @@ Core functions should follow this pattern to support both CLI and MCP use: * @returns {Object|undefined} - Returns data when source is 'mcp' */ function exampleFunction(param1, param2, options = {}) { - try { - // Skip UI for MCP - if (options.source !== 'mcp') { - displayBanner(); - console.log(chalk.blue('Processing operation...')); - } - - // Do the core business logic - const result = doSomething(param1, param2); - - // For MCP, return structured data - if (options.source === 'mcp') { - return { - success: true, - data: result - }; - } - - // For CLI, display output - console.log(chalk.green('Operation completed successfully!')); - } catch (error) { - // Handle errors based on source - if (options.source === 'mcp') { - return { - success: false, - error: error.message - }; - } - - // CLI error handling - console.error(chalk.red(`Error: ${error.message}`)); - process.exit(1); - } + try { + // Skip UI for MCP + if (options.source !== 'mcp') { + displayBanner(); + console.log(chalk.blue('Processing operation...')); + } + + // Do the core business logic + const result = doSomething(param1, param2); + + // For MCP, return structured data + if (options.source === 'mcp') { + return { + success: true, + data: result + }; + } + + // For CLI, display output + console.log(chalk.green('Operation completed successfully!')); + } catch (error) { + // Handle errors based on source + if (options.source === 'mcp') { + return { + success: false, + error: error.message + }; + } + + // CLI error handling + console.error(chalk.red(`Error: ${error.message}`)); + process.exit(1); + } } ``` @@ -89,17 +89,17 @@ export const simpleFunction = adaptForMcp(originalFunction); // Split implementation - completely different code paths for CLI vs MCP export const complexFunction = sourceSplitFunction( - // CLI version with UI - function(param1, param2) { - displayBanner(); - console.log(`Processing ${param1}...`); - // ... CLI implementation - }, - // MCP version with structured return - function(param1, param2, options = {}) { - // ... MCP implementation - return { success: true, data }; - } + // CLI version with UI + function (param1, param2) { + displayBanner(); + console.log(`Processing ${param1}...`); + // ... CLI implementation + }, + // MCP version with structured return + function (param1, param2, options = {}) { + // ... MCP implementation + return { success: true, data }; + } ); ``` @@ -110,7 +110,7 @@ When adding new features, follow these steps to ensure CLI and MCP compatibility 1. **Implement Core Logic** in the appropriate module file 2. **Add Source Parameter Support** using the pattern above 3. **Add to task-master-core.js** to make it available for direct import -4. **Update Command Map** in `mcp-server/src/tools/utils.js` +4. **Update Command Map** in `mcp-server/src/tools/utils.js` 5. **Create Tool Implementation** in `mcp-server/src/tools/` 6. **Register the Tool** in `mcp-server/src/tools/index.js` @@ -119,39 +119,39 @@ When adding new features, follow these steps to ensure CLI and MCP compatibility ```javascript // In scripts/modules/task-manager.js export async function newFeature(param1, param2, options = {}) { - try { - // Source-specific UI - if (options.source !== 'mcp') { - displayBanner(); - console.log(chalk.blue('Running new feature...')); - } - - // Shared core logic - const result = processFeature(param1, param2); - - // Source-specific return handling - if (options.source === 'mcp') { - return { - success: true, - data: result - }; - } - - // CLI output - console.log(chalk.green('Feature completed successfully!')); - displayOutput(result); - } catch (error) { - // Error handling based on source - if (options.source === 'mcp') { - return { - success: false, - error: error.message - }; - } - - console.error(chalk.red(`Error: ${error.message}`)); - process.exit(1); - } + try { + // Source-specific UI + if (options.source !== 'mcp') { + displayBanner(); + console.log(chalk.blue('Running new feature...')); + } + + // Shared core logic + const result = processFeature(param1, param2); + + // Source-specific return handling + if (options.source === 'mcp') { + return { + success: true, + data: result + }; + } + + // CLI output + console.log(chalk.green('Feature completed successfully!')); + displayOutput(result); + } catch (error) { + // Error handling based on source + if (options.source === 'mcp') { + return { + success: false, + error: error.message + }; + } + + console.error(chalk.red(`Error: ${error.message}`)); + process.exit(1); + } } ``` @@ -163,12 +163,12 @@ import { newFeature } from '../../../scripts/modules/task-manager.js'; // Add to exports export default { - // ... existing functions - - async newFeature(args = {}, options = {}) { - const { param1, param2 } = args; - return executeFunction(newFeature, [param1, param2], options); - } + // ... existing functions + + async newFeature(args = {}, options = {}) { + const { param1, param2 } = args; + return executeFunction(newFeature, [param1, param2], options); + } }; ``` @@ -177,8 +177,8 @@ export default { ```javascript // In mcp-server/src/tools/utils.js const commandMap = { - // ... existing mappings - 'new-feature': 'newFeature' + // ... existing mappings + 'new-feature': 'newFeature' }; ``` @@ -186,53 +186,53 @@ const commandMap = { ```javascript // In mcp-server/src/tools/newFeature.js -import { z } from "zod"; +import { z } from 'zod'; import { - executeTaskMasterCommand, - createContentResponse, - createErrorResponse, -} from "./utils.js"; + executeTaskMasterCommand, + createContentResponse, + createErrorResponse +} from './utils.js'; export function registerNewFeatureTool(server) { - server.addTool({ - name: "newFeature", - description: "Run the new feature", - parameters: z.object({ - param1: z.string().describe("First parameter"), - param2: z.number().optional().describe("Second parameter"), - file: z.string().optional().describe("Path to the tasks file"), - projectRoot: z.string().describe("Root directory of the project") - }), - execute: async (args, { log }) => { - try { - log.info(`Running new feature with args: ${JSON.stringify(args)}`); + server.addTool({ + name: 'newFeature', + description: 'Run the new feature', + parameters: z.object({ + param1: z.string().describe('First parameter'), + param2: z.number().optional().describe('Second parameter'), + file: z.string().optional().describe('Path to the tasks file'), + projectRoot: z.string().describe('Root directory of the project') + }), + execute: async (args, { log }) => { + try { + log.info(`Running new feature with args: ${JSON.stringify(args)}`); - const cmdArgs = []; - if (args.param1) cmdArgs.push(`--param1=${args.param1}`); - if (args.param2) cmdArgs.push(`--param2=${args.param2}`); - if (args.file) cmdArgs.push(`--file=${args.file}`); + const cmdArgs = []; + if (args.param1) cmdArgs.push(`--param1=${args.param1}`); + if (args.param2) cmdArgs.push(`--param2=${args.param2}`); + if (args.file) cmdArgs.push(`--file=${args.file}`); - const projectRoot = args.projectRoot; + const projectRoot = args.projectRoot; - // Execute the command - const result = await executeTaskMasterCommand( - "new-feature", - log, - cmdArgs, - projectRoot - ); + // Execute the command + const result = await executeTaskMasterCommand( + 'new-feature', + log, + cmdArgs, + projectRoot + ); - if (!result.success) { - throw new Error(result.error); - } + if (!result.success) { + throw new Error(result.error); + } - return createContentResponse(result.stdout); - } catch (error) { - log.error(`Error in new feature: ${error.message}`); - return createErrorResponse(`Error in new feature: ${error.message}`); - } - }, - }); + return createContentResponse(result.stdout); + } catch (error) { + log.error(`Error in new feature: ${error.message}`); + return createErrorResponse(`Error in new feature: ${error.message}`); + } + } + }); } ``` @@ -240,11 +240,11 @@ export function registerNewFeatureTool(server) { ```javascript // In mcp-server/src/tools/index.js -import { registerNewFeatureTool } from "./newFeature.js"; +import { registerNewFeatureTool } from './newFeature.js'; export function registerTaskMasterTools(server) { - // ... existing registrations - registerNewFeatureTool(server); + // ... existing registrations + registerNewFeatureTool(server); } ``` @@ -266,4 +266,4 @@ node mcp-server/tests/test-command.js newFeature 2. **Structured Data for MCP** - Return clean JSON objects from MCP source functions 3. **Consistent Error Handling** - Standardize error formats for both interfaces 4. **Documentation** - Update MCP tool documentation when adding new features -5. **Testing** - Test both CLI and MCP interfaces for any new or modified feature \ No newline at end of file +5. **Testing** - Test both CLI and MCP interfaces for any new or modified feature diff --git a/docs/mcp-protocol-schema-03262025.json b/docs/mcp-protocol-schema-03262025.json index e7f730e5..0cf54b38 100644 --- a/docs/mcp-protocol-schema-03262025.json +++ b/docs/mcp-protocol-schema-03262025.json @@ -1,2128 +1,1913 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "Annotations": { - "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", - "properties": { - "audience": { - "description": "Describes who the intended customer of this object or data is.\n\nIt can include multiple entries to indicate content useful for multiple audiences (e.g., `[\"user\", \"assistant\"]`).", - "items": { - "$ref": "#/definitions/Role" - }, - "type": "array" - }, - "priority": { - "description": "Describes how important this data is for operating the server.\n\nA value of 1 means \"most important,\" and indicates that the data is\neffectively required, while 0 means \"least important,\" and indicates that\nthe data is entirely optional.", - "maximum": 1, - "minimum": 0, - "type": "number" - } - }, - "type": "object" - }, - "AudioContent": { - "description": "Audio provided to or from an LLM.", - "properties": { - "annotations": { - "$ref": "#/definitions/Annotations", - "description": "Optional annotations for the client." - }, - "data": { - "description": "The base64-encoded audio data.", - "format": "byte", - "type": "string" - }, - "mimeType": { - "description": "The MIME type of the audio. Different providers may support different audio types.", - "type": "string" - }, - "type": { - "const": "audio", - "type": "string" - } - }, - "required": [ - "data", - "mimeType", - "type" - ], - "type": "object" - }, - "BlobResourceContents": { - "properties": { - "blob": { - "description": "A base64-encoded string representing the binary data of the item.", - "format": "byte", - "type": "string" - }, - "mimeType": { - "description": "The MIME type of this resource, if known.", - "type": "string" - }, - "uri": { - "description": "The URI of this resource.", - "format": "uri", - "type": "string" - } - }, - "required": [ - "blob", - "uri" - ], - "type": "object" - }, - "CallToolRequest": { - "description": "Used by the client to invoke a tool provided by the server.", - "properties": { - "method": { - "const": "tools/call", - "type": "string" - }, - "params": { - "properties": { - "arguments": { - "additionalProperties": {}, - "type": "object" - }, - "name": { - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - }, - "required": [ - "method", - "params" - ], - "type": "object" - }, - "CallToolResult": { - "description": "The server's response to a tool call.\n\nAny errors that originate from the tool SHOULD be reported inside the result\nobject, with `isError` set to true, _not_ as an MCP protocol-level error\nresponse. Otherwise, the LLM would not be able to see that an error occurred\nand self-correct.\n\nHowever, any errors in _finding_ the tool, an error indicating that the\nserver does not support tool calls, or any other exceptional conditions,\nshould be reported as an MCP error response.", - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", - "type": "object" - }, - "content": { - "items": { - "anyOf": [ - { - "$ref": "#/definitions/TextContent" - }, - { - "$ref": "#/definitions/ImageContent" - }, - { - "$ref": "#/definitions/AudioContent" - }, - { - "$ref": "#/definitions/EmbeddedResource" - } - ] - }, - "type": "array" - }, - "isError": { - "description": "Whether the tool call ended in an error.\n\nIf not set, this is assumed to be false (the call was successful).", - "type": "boolean" - } - }, - "required": [ - "content" - ], - "type": "object" - }, - "CancelledNotification": { - "description": "This notification can be sent by either side to indicate that it is cancelling a previously-issued request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.\n\nA client MUST NOT attempt to cancel its `initialize` request.", - "properties": { - "method": { - "const": "notifications/cancelled", - "type": "string" - }, - "params": { - "properties": { - "reason": { - "description": "An optional string describing the reason for the cancellation. This MAY be logged or presented to the user.", - "type": "string" - }, - "requestId": { - "$ref": "#/definitions/RequestId", - "description": "The ID of the request to cancel.\n\nThis MUST correspond to the ID of a request previously issued in the same direction." - } - }, - "required": [ - "requestId" - ], - "type": "object" - } - }, - "required": [ - "method", - "params" - ], - "type": "object" - }, - "ClientCapabilities": { - "description": "Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.", - "properties": { - "experimental": { - "additionalProperties": { - "additionalProperties": true, - "properties": {}, - "type": "object" - }, - "description": "Experimental, non-standard capabilities that the client supports.", - "type": "object" - }, - "roots": { - "description": "Present if the client supports listing roots.", - "properties": { - "listChanged": { - "description": "Whether the client supports notifications for changes to the roots list.", - "type": "boolean" - } - }, - "type": "object" - }, - "sampling": { - "additionalProperties": true, - "description": "Present if the client supports sampling from an LLM.", - "properties": {}, - "type": "object" - } - }, - "type": "object" - }, - "ClientNotification": { - "anyOf": [ - { - "$ref": "#/definitions/CancelledNotification" - }, - { - "$ref": "#/definitions/InitializedNotification" - }, - { - "$ref": "#/definitions/ProgressNotification" - }, - { - "$ref": "#/definitions/RootsListChangedNotification" - } - ] - }, - "ClientRequest": { - "anyOf": [ - { - "$ref": "#/definitions/InitializeRequest" - }, - { - "$ref": "#/definitions/PingRequest" - }, - { - "$ref": "#/definitions/ListResourcesRequest" - }, - { - "$ref": "#/definitions/ReadResourceRequest" - }, - { - "$ref": "#/definitions/SubscribeRequest" - }, - { - "$ref": "#/definitions/UnsubscribeRequest" - }, - { - "$ref": "#/definitions/ListPromptsRequest" - }, - { - "$ref": "#/definitions/GetPromptRequest" - }, - { - "$ref": "#/definitions/ListToolsRequest" - }, - { - "$ref": "#/definitions/CallToolRequest" - }, - { - "$ref": "#/definitions/SetLevelRequest" - }, - { - "$ref": "#/definitions/CompleteRequest" - } - ] - }, - "ClientResult": { - "anyOf": [ - { - "$ref": "#/definitions/Result" - }, - { - "$ref": "#/definitions/CreateMessageResult" - }, - { - "$ref": "#/definitions/ListRootsResult" - } - ] - }, - "CompleteRequest": { - "description": "A request from the client to the server, to ask for completion options.", - "properties": { - "method": { - "const": "completion/complete", - "type": "string" - }, - "params": { - "properties": { - "argument": { - "description": "The argument's information", - "properties": { - "name": { - "description": "The name of the argument", - "type": "string" - }, - "value": { - "description": "The value of the argument to use for completion matching.", - "type": "string" - } - }, - "required": [ - "name", - "value" - ], - "type": "object" - }, - "ref": { - "anyOf": [ - { - "$ref": "#/definitions/PromptReference" - }, - { - "$ref": "#/definitions/ResourceReference" - } - ] - } - }, - "required": [ - "argument", - "ref" - ], - "type": "object" - } - }, - "required": [ - "method", - "params" - ], - "type": "object" - }, - "CompleteResult": { - "description": "The server's response to a completion/complete request", - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", - "type": "object" - }, - "completion": { - "properties": { - "hasMore": { - "description": "Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown.", - "type": "boolean" - }, - "total": { - "description": "The total number of completion options available. This can exceed the number of values actually sent in the response.", - "type": "integer" - }, - "values": { - "description": "An array of completion values. Must not exceed 100 items.", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "values" - ], - "type": "object" - } - }, - "required": [ - "completion" - ], - "type": "object" - }, - "CreateMessageRequest": { - "description": "A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it.", - "properties": { - "method": { - "const": "sampling/createMessage", - "type": "string" - }, - "params": { - "properties": { - "includeContext": { - "description": "A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. The client MAY ignore this request.", - "enum": [ - "allServers", - "none", - "thisServer" - ], - "type": "string" - }, - "maxTokens": { - "description": "The maximum number of tokens to sample, as requested by the server. The client MAY choose to sample fewer tokens than requested.", - "type": "integer" - }, - "messages": { - "items": { - "$ref": "#/definitions/SamplingMessage" - }, - "type": "array" - }, - "metadata": { - "additionalProperties": true, - "description": "Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific.", - "properties": {}, - "type": "object" - }, - "modelPreferences": { - "$ref": "#/definitions/ModelPreferences", - "description": "The server's preferences for which model to select. The client MAY ignore these preferences." - }, - "stopSequences": { - "items": { - "type": "string" - }, - "type": "array" - }, - "systemPrompt": { - "description": "An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt.", - "type": "string" - }, - "temperature": { - "type": "number" - } - }, - "required": [ - "maxTokens", - "messages" - ], - "type": "object" - } - }, - "required": [ - "method", - "params" - ], - "type": "object" - }, - "CreateMessageResult": { - "description": "The client's response to a sampling/create_message request from the server. The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) and decide whether to allow the server to see it.", - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", - "type": "object" - }, - "content": { - "anyOf": [ - { - "$ref": "#/definitions/TextContent" - }, - { - "$ref": "#/definitions/ImageContent" - }, - { - "$ref": "#/definitions/AudioContent" - } - ] - }, - "model": { - "description": "The name of the model that generated the message.", - "type": "string" - }, - "role": { - "$ref": "#/definitions/Role" - }, - "stopReason": { - "description": "The reason why sampling stopped, if known.", - "type": "string" - } - }, - "required": [ - "content", - "model", - "role" - ], - "type": "object" - }, - "Cursor": { - "description": "An opaque token used to represent a cursor for pagination.", - "type": "string" - }, - "EmbeddedResource": { - "description": "The contents of a resource, embedded into a prompt or tool call result.\n\nIt is up to the client how best to render embedded resources for the benefit\nof the LLM and/or the user.", - "properties": { - "annotations": { - "$ref": "#/definitions/Annotations", - "description": "Optional annotations for the client." - }, - "resource": { - "anyOf": [ - { - "$ref": "#/definitions/TextResourceContents" - }, - { - "$ref": "#/definitions/BlobResourceContents" - } - ] - }, - "type": { - "const": "resource", - "type": "string" - } - }, - "required": [ - "resource", - "type" - ], - "type": "object" - }, - "EmptyResult": { - "$ref": "#/definitions/Result" - }, - "GetPromptRequest": { - "description": "Used by the client to get a prompt provided by the server.", - "properties": { - "method": { - "const": "prompts/get", - "type": "string" - }, - "params": { - "properties": { - "arguments": { - "additionalProperties": { - "type": "string" - }, - "description": "Arguments to use for templating the prompt.", - "type": "object" - }, - "name": { - "description": "The name of the prompt or prompt template.", - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - }, - "required": [ - "method", - "params" - ], - "type": "object" - }, - "GetPromptResult": { - "description": "The server's response to a prompts/get request from the client.", - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", - "type": "object" - }, - "description": { - "description": "An optional description for the prompt.", - "type": "string" - }, - "messages": { - "items": { - "$ref": "#/definitions/PromptMessage" - }, - "type": "array" - } - }, - "required": [ - "messages" - ], - "type": "object" - }, - "ImageContent": { - "description": "An image provided to or from an LLM.", - "properties": { - "annotations": { - "$ref": "#/definitions/Annotations", - "description": "Optional annotations for the client." - }, - "data": { - "description": "The base64-encoded image data.", - "format": "byte", - "type": "string" - }, - "mimeType": { - "description": "The MIME type of the image. Different providers may support different image types.", - "type": "string" - }, - "type": { - "const": "image", - "type": "string" - } - }, - "required": [ - "data", - "mimeType", - "type" - ], - "type": "object" - }, - "Implementation": { - "description": "Describes the name and version of an MCP implementation.", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - } - }, - "required": [ - "name", - "version" - ], - "type": "object" - }, - "InitializeRequest": { - "description": "This request is sent from the client to the server when it first connects, asking it to begin initialization.", - "properties": { - "method": { - "const": "initialize", - "type": "string" - }, - "params": { - "properties": { - "capabilities": { - "$ref": "#/definitions/ClientCapabilities" - }, - "clientInfo": { - "$ref": "#/definitions/Implementation" - }, - "protocolVersion": { - "description": "The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well.", - "type": "string" - } - }, - "required": [ - "capabilities", - "clientInfo", - "protocolVersion" - ], - "type": "object" - } - }, - "required": [ - "method", - "params" - ], - "type": "object" - }, - "InitializeResult": { - "description": "After receiving an initialize request from the client, the server sends this response.", - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", - "type": "object" - }, - "capabilities": { - "$ref": "#/definitions/ServerCapabilities" - }, - "instructions": { - "description": "Instructions describing how to use the server and its features.\n\nThis can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a \"hint\" to the model. For example, this information MAY be added to the system prompt.", - "type": "string" - }, - "protocolVersion": { - "description": "The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect.", - "type": "string" - }, - "serverInfo": { - "$ref": "#/definitions/Implementation" - } - }, - "required": [ - "capabilities", - "protocolVersion", - "serverInfo" - ], - "type": "object" - }, - "InitializedNotification": { - "description": "This notification is sent from the client to the server after initialization has finished.", - "properties": { - "method": { - "const": "notifications/initialized", - "type": "string" - }, - "params": { - "additionalProperties": {}, - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", - "type": "object" - } - }, - "type": "object" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "JSONRPCBatchRequest": { - "description": "A JSON-RPC batch request, as described in https://www.jsonrpc.org/specification#batch.", - "items": { - "anyOf": [ - { - "$ref": "#/definitions/JSONRPCRequest" - }, - { - "$ref": "#/definitions/JSONRPCNotification" - } - ] - }, - "type": "array" - }, - "JSONRPCBatchResponse": { - "description": "A JSON-RPC batch response, as described in https://www.jsonrpc.org/specification#batch.", - "items": { - "anyOf": [ - { - "$ref": "#/definitions/JSONRPCResponse" - }, - { - "$ref": "#/definitions/JSONRPCError" - } - ] - }, - "type": "array" - }, - "JSONRPCError": { - "description": "A response to a request that indicates an error occurred.", - "properties": { - "error": { - "properties": { - "code": { - "description": "The error type that occurred.", - "type": "integer" - }, - "data": { - "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." - }, - "message": { - "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "type": "object" - }, - "id": { - "$ref": "#/definitions/RequestId" - }, - "jsonrpc": { - "const": "2.0", - "type": "string" - } - }, - "required": [ - "error", - "id", - "jsonrpc" - ], - "type": "object" - }, - "JSONRPCMessage": { - "anyOf": [ - { - "$ref": "#/definitions/JSONRPCRequest" - }, - { - "$ref": "#/definitions/JSONRPCNotification" - }, - { - "description": "A JSON-RPC batch request, as described in https://www.jsonrpc.org/specification#batch.", - "items": { - "anyOf": [ - { - "$ref": "#/definitions/JSONRPCRequest" - }, - { - "$ref": "#/definitions/JSONRPCNotification" - } - ] - }, - "type": "array" - }, - { - "$ref": "#/definitions/JSONRPCResponse" - }, - { - "$ref": "#/definitions/JSONRPCError" - }, - { - "description": "A JSON-RPC batch response, as described in https://www.jsonrpc.org/specification#batch.", - "items": { - "anyOf": [ - { - "$ref": "#/definitions/JSONRPCResponse" - }, - { - "$ref": "#/definitions/JSONRPCError" - } - ] - }, - "type": "array" - } - ], - "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent." - }, - "JSONRPCNotification": { - "description": "A notification which does not expect a response.", - "properties": { - "jsonrpc": { - "const": "2.0", - "type": "string" - }, - "method": { - "type": "string" - }, - "params": { - "additionalProperties": {}, - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", - "type": "object" - } - }, - "type": "object" - } - }, - "required": [ - "jsonrpc", - "method" - ], - "type": "object" - }, - "JSONRPCRequest": { - "description": "A request that expects a response.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "jsonrpc": { - "const": "2.0", - "type": "string" - }, - "method": { - "type": "string" - }, - "params": { - "additionalProperties": {}, - "properties": { - "_meta": { - "properties": { - "progressToken": { - "$ref": "#/definitions/ProgressToken", - "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." - } - }, - "type": "object" - } - }, - "type": "object" - } - }, - "required": [ - "id", - "jsonrpc", - "method" - ], - "type": "object" - }, - "JSONRPCResponse": { - "description": "A successful (non-error) response to a request.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "jsonrpc": { - "const": "2.0", - "type": "string" - }, - "result": { - "$ref": "#/definitions/Result" - } - }, - "required": [ - "id", - "jsonrpc", - "result" - ], - "type": "object" - }, - "ListPromptsRequest": { - "description": "Sent from the client to request a list of prompts and prompt templates the server has.", - "properties": { - "method": { - "const": "prompts/list", - "type": "string" - }, - "params": { - "properties": { - "cursor": { - "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", - "type": "string" - } - }, - "type": "object" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "ListPromptsResult": { - "description": "The server's response to a prompts/list request from the client.", - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", - "type": "object" - }, - "nextCursor": { - "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", - "type": "string" - }, - "prompts": { - "items": { - "$ref": "#/definitions/Prompt" - }, - "type": "array" - } - }, - "required": [ - "prompts" - ], - "type": "object" - }, - "ListResourceTemplatesRequest": { - "description": "Sent from the client to request a list of resource templates the server has.", - "properties": { - "method": { - "const": "resources/templates/list", - "type": "string" - }, - "params": { - "properties": { - "cursor": { - "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", - "type": "string" - } - }, - "type": "object" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "ListResourceTemplatesResult": { - "description": "The server's response to a resources/templates/list request from the client.", - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", - "type": "object" - }, - "nextCursor": { - "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", - "type": "string" - }, - "resourceTemplates": { - "items": { - "$ref": "#/definitions/ResourceTemplate" - }, - "type": "array" - } - }, - "required": [ - "resourceTemplates" - ], - "type": "object" - }, - "ListResourcesRequest": { - "description": "Sent from the client to request a list of resources the server has.", - "properties": { - "method": { - "const": "resources/list", - "type": "string" - }, - "params": { - "properties": { - "cursor": { - "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", - "type": "string" - } - }, - "type": "object" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "ListResourcesResult": { - "description": "The server's response to a resources/list request from the client.", - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", - "type": "object" - }, - "nextCursor": { - "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", - "type": "string" - }, - "resources": { - "items": { - "$ref": "#/definitions/Resource" - }, - "type": "array" - } - }, - "required": [ - "resources" - ], - "type": "object" - }, - "ListRootsRequest": { - "description": "Sent from the server to request a list of root URIs from the client. Roots allow\nservers to ask for specific directories or files to operate on. A common example\nfor roots is providing a set of repositories or directories a server should operate\non.\n\nThis request is typically used when the server needs to understand the file system\nstructure or access specific locations that the client has permission to read from.", - "properties": { - "method": { - "const": "roots/list", - "type": "string" - }, - "params": { - "additionalProperties": {}, - "properties": { - "_meta": { - "properties": { - "progressToken": { - "$ref": "#/definitions/ProgressToken", - "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." - } - }, - "type": "object" - } - }, - "type": "object" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "ListRootsResult": { - "description": "The client's response to a roots/list request from the server.\nThis result contains an array of Root objects, each representing a root directory\nor file that the server can operate on.", - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", - "type": "object" - }, - "roots": { - "items": { - "$ref": "#/definitions/Root" - }, - "type": "array" - } - }, - "required": [ - "roots" - ], - "type": "object" - }, - "ListToolsRequest": { - "description": "Sent from the client to request a list of tools the server has.", - "properties": { - "method": { - "const": "tools/list", - "type": "string" - }, - "params": { - "properties": { - "cursor": { - "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", - "type": "string" - } - }, - "type": "object" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "ListToolsResult": { - "description": "The server's response to a tools/list request from the client.", - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", - "type": "object" - }, - "nextCursor": { - "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", - "type": "string" - }, - "tools": { - "items": { - "$ref": "#/definitions/Tool" - }, - "type": "array" - } - }, - "required": [ - "tools" - ], - "type": "object" - }, - "LoggingLevel": { - "description": "The severity of a log message.\n\nThese map to syslog message severities, as specified in RFC-5424:\nhttps://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1", - "enum": [ - "alert", - "critical", - "debug", - "emergency", - "error", - "info", - "notice", - "warning" - ], - "type": "string" - }, - "LoggingMessageNotification": { - "description": "Notification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically.", - "properties": { - "method": { - "const": "notifications/message", - "type": "string" - }, - "params": { - "properties": { - "data": { - "description": "The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here." - }, - "level": { - "$ref": "#/definitions/LoggingLevel", - "description": "The severity of this log message." - }, - "logger": { - "description": "An optional name of the logger issuing this message.", - "type": "string" - } - }, - "required": [ - "data", - "level" - ], - "type": "object" - } - }, - "required": [ - "method", - "params" - ], - "type": "object" - }, - "ModelHint": { - "description": "Hints to use for model selection.\n\nKeys not declared here are currently left unspecified by the spec and are up\nto the client to interpret.", - "properties": { - "name": { - "description": "A hint for a model name.\n\nThe client SHOULD treat this as a substring of a model name; for example:\n - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022`\n - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc.\n - `claude` should match any Claude model\n\nThe client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example:\n - `gemini-1.5-flash` could match `claude-3-haiku-20240307`", - "type": "string" - } - }, - "type": "object" - }, - "ModelPreferences": { - "description": "The server's preferences for model selection, requested of the client during sampling.\n\nBecause LLMs can vary along multiple dimensions, choosing the \"best\" model is\nrarely straightforward. Different models excel in different areas—some are\nfaster but less capable, others are more capable but more expensive, and so\non. This interface allows servers to express their priorities across multiple\ndimensions to help clients make an appropriate selection for their use case.\n\nThese preferences are always advisory. The client MAY ignore them. It is also\nup to the client to decide how to interpret these preferences and how to\nbalance them against other considerations.", - "properties": { - "costPriority": { - "description": "How much to prioritize cost when selecting a model. A value of 0 means cost\nis not important, while a value of 1 means cost is the most important\nfactor.", - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "hints": { - "description": "Optional hints to use for model selection.\n\nIf multiple hints are specified, the client MUST evaluate them in order\n(such that the first match is taken).\n\nThe client SHOULD prioritize these hints over the numeric priorities, but\nMAY still use the priorities to select from ambiguous matches.", - "items": { - "$ref": "#/definitions/ModelHint" - }, - "type": "array" - }, - "intelligencePriority": { - "description": "How much to prioritize intelligence and capabilities when selecting a\nmodel. A value of 0 means intelligence is not important, while a value of 1\nmeans intelligence is the most important factor.", - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "speedPriority": { - "description": "How much to prioritize sampling speed (latency) when selecting a model. A\nvalue of 0 means speed is not important, while a value of 1 means speed is\nthe most important factor.", - "maximum": 1, - "minimum": 0, - "type": "number" - } - }, - "type": "object" - }, - "Notification": { - "properties": { - "method": { - "type": "string" - }, - "params": { - "additionalProperties": {}, - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", - "type": "object" - } - }, - "type": "object" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "PaginatedRequest": { - "properties": { - "method": { - "type": "string" - }, - "params": { - "properties": { - "cursor": { - "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", - "type": "string" - } - }, - "type": "object" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "PaginatedResult": { - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", - "type": "object" - }, - "nextCursor": { - "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", - "type": "string" - } - }, - "type": "object" - }, - "PingRequest": { - "description": "A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected.", - "properties": { - "method": { - "const": "ping", - "type": "string" - }, - "params": { - "additionalProperties": {}, - "properties": { - "_meta": { - "properties": { - "progressToken": { - "$ref": "#/definitions/ProgressToken", - "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." - } - }, - "type": "object" - } - }, - "type": "object" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "ProgressNotification": { - "description": "An out-of-band notification used to inform the receiver of a progress update for a long-running request.", - "properties": { - "method": { - "const": "notifications/progress", - "type": "string" - }, - "params": { - "properties": { - "message": { - "description": "An optional message describing the current progress.", - "type": "string" - }, - "progress": { - "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", - "type": "number" - }, - "progressToken": { - "$ref": "#/definitions/ProgressToken", - "description": "The progress token which was given in the initial request, used to associate this notification with the request that is proceeding." - }, - "total": { - "description": "Total number of items to process (or total progress required), if known.", - "type": "number" - } - }, - "required": [ - "progress", - "progressToken" - ], - "type": "object" - } - }, - "required": [ - "method", - "params" - ], - "type": "object" - }, - "ProgressToken": { - "description": "A progress token, used to associate progress notifications with the original request.", - "type": [ - "string", - "integer" - ] - }, - "Prompt": { - "description": "A prompt or prompt template that the server offers.", - "properties": { - "arguments": { - "description": "A list of arguments to use for templating the prompt.", - "items": { - "$ref": "#/definitions/PromptArgument" - }, - "type": "array" - }, - "description": { - "description": "An optional description of what this prompt provides", - "type": "string" - }, - "name": { - "description": "The name of the prompt or prompt template.", - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - }, - "PromptArgument": { - "description": "Describes an argument that a prompt can accept.", - "properties": { - "description": { - "description": "A human-readable description of the argument.", - "type": "string" - }, - "name": { - "description": "The name of the argument.", - "type": "string" - }, - "required": { - "description": "Whether this argument must be provided.", - "type": "boolean" - } - }, - "required": [ - "name" - ], - "type": "object" - }, - "PromptListChangedNotification": { - "description": "An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client.", - "properties": { - "method": { - "const": "notifications/prompts/list_changed", - "type": "string" - }, - "params": { - "additionalProperties": {}, - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", - "type": "object" - } - }, - "type": "object" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "PromptMessage": { - "description": "Describes a message returned as part of a prompt.\n\nThis is similar to `SamplingMessage`, but also supports the embedding of\nresources from the MCP server.", - "properties": { - "content": { - "anyOf": [ - { - "$ref": "#/definitions/TextContent" - }, - { - "$ref": "#/definitions/ImageContent" - }, - { - "$ref": "#/definitions/AudioContent" - }, - { - "$ref": "#/definitions/EmbeddedResource" - } - ] - }, - "role": { - "$ref": "#/definitions/Role" - } - }, - "required": [ - "content", - "role" - ], - "type": "object" - }, - "PromptReference": { - "description": "Identifies a prompt.", - "properties": { - "name": { - "description": "The name of the prompt or prompt template", - "type": "string" - }, - "type": { - "const": "ref/prompt", - "type": "string" - } - }, - "required": [ - "name", - "type" - ], - "type": "object" - }, - "ReadResourceRequest": { - "description": "Sent from the client to the server, to read a specific resource URI.", - "properties": { - "method": { - "const": "resources/read", - "type": "string" - }, - "params": { - "properties": { - "uri": { - "description": "The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it.", - "format": "uri", - "type": "string" - } - }, - "required": [ - "uri" - ], - "type": "object" - } - }, - "required": [ - "method", - "params" - ], - "type": "object" - }, - "ReadResourceResult": { - "description": "The server's response to a resources/read request from the client.", - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", - "type": "object" - }, - "contents": { - "items": { - "anyOf": [ - { - "$ref": "#/definitions/TextResourceContents" - }, - { - "$ref": "#/definitions/BlobResourceContents" - } - ] - }, - "type": "array" - } - }, - "required": [ - "contents" - ], - "type": "object" - }, - "Request": { - "properties": { - "method": { - "type": "string" - }, - "params": { - "additionalProperties": {}, - "properties": { - "_meta": { - "properties": { - "progressToken": { - "$ref": "#/definitions/ProgressToken", - "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." - } - }, - "type": "object" - } - }, - "type": "object" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "RequestId": { - "description": "A uniquely identifying ID for a request in JSON-RPC.", - "type": [ - "string", - "integer" - ] - }, - "Resource": { - "description": "A known resource that the server is capable of reading.", - "properties": { - "annotations": { - "$ref": "#/definitions/Annotations", - "description": "Optional annotations for the client." - }, - "description": { - "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", - "type": "string" - }, - "mimeType": { - "description": "The MIME type of this resource, if known.", - "type": "string" - }, - "name": { - "description": "A human-readable name for this resource.\n\nThis can be used by clients to populate UI elements.", - "type": "string" - }, - "uri": { - "description": "The URI of this resource.", - "format": "uri", - "type": "string" - } - }, - "required": [ - "name", - "uri" - ], - "type": "object" - }, - "ResourceContents": { - "description": "The contents of a specific resource or sub-resource.", - "properties": { - "mimeType": { - "description": "The MIME type of this resource, if known.", - "type": "string" - }, - "uri": { - "description": "The URI of this resource.", - "format": "uri", - "type": "string" - } - }, - "required": [ - "uri" - ], - "type": "object" - }, - "ResourceListChangedNotification": { - "description": "An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client.", - "properties": { - "method": { - "const": "notifications/resources/list_changed", - "type": "string" - }, - "params": { - "additionalProperties": {}, - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", - "type": "object" - } - }, - "type": "object" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "ResourceReference": { - "description": "A reference to a resource or resource template definition.", - "properties": { - "type": { - "const": "ref/resource", - "type": "string" - }, - "uri": { - "description": "The URI or URI template of the resource.", - "format": "uri-template", - "type": "string" - } - }, - "required": [ - "type", - "uri" - ], - "type": "object" - }, - "ResourceTemplate": { - "description": "A template description for resources available on the server.", - "properties": { - "annotations": { - "$ref": "#/definitions/Annotations", - "description": "Optional annotations for the client." - }, - "description": { - "description": "A description of what this template is for.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", - "type": "string" - }, - "mimeType": { - "description": "The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type.", - "type": "string" - }, - "name": { - "description": "A human-readable name for the type of resource this template refers to.\n\nThis can be used by clients to populate UI elements.", - "type": "string" - }, - "uriTemplate": { - "description": "A URI template (according to RFC 6570) that can be used to construct resource URIs.", - "format": "uri-template", - "type": "string" - } - }, - "required": [ - "name", - "uriTemplate" - ], - "type": "object" - }, - "ResourceUpdatedNotification": { - "description": "A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request.", - "properties": { - "method": { - "const": "notifications/resources/updated", - "type": "string" - }, - "params": { - "properties": { - "uri": { - "description": "The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to.", - "format": "uri", - "type": "string" - } - }, - "required": [ - "uri" - ], - "type": "object" - } - }, - "required": [ - "method", - "params" - ], - "type": "object" - }, - "Result": { - "additionalProperties": {}, - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", - "type": "object" - } - }, - "type": "object" - }, - "Role": { - "description": "The sender or recipient of messages and data in a conversation.", - "enum": [ - "assistant", - "user" - ], - "type": "string" - }, - "Root": { - "description": "Represents a root directory or file that the server can operate on.", - "properties": { - "name": { - "description": "An optional name for the root. This can be used to provide a human-readable\nidentifier for the root, which may be useful for display purposes or for\nreferencing the root in other parts of the application.", - "type": "string" - }, - "uri": { - "description": "The URI identifying the root. This *must* start with file:// for now.\nThis restriction may be relaxed in future versions of the protocol to allow\nother URI schemes.", - "format": "uri", - "type": "string" - } - }, - "required": [ - "uri" - ], - "type": "object" - }, - "RootsListChangedNotification": { - "description": "A notification from the client to the server, informing it that the list of roots has changed.\nThis notification should be sent whenever the client adds, removes, or modifies any root.\nThe server should then request an updated list of roots using the ListRootsRequest.", - "properties": { - "method": { - "const": "notifications/roots/list_changed", - "type": "string" - }, - "params": { - "additionalProperties": {}, - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", - "type": "object" - } - }, - "type": "object" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "SamplingMessage": { - "description": "Describes a message issued to or received from an LLM API.", - "properties": { - "content": { - "anyOf": [ - { - "$ref": "#/definitions/TextContent" - }, - { - "$ref": "#/definitions/ImageContent" - }, - { - "$ref": "#/definitions/AudioContent" - } - ] - }, - "role": { - "$ref": "#/definitions/Role" - } - }, - "required": [ - "content", - "role" - ], - "type": "object" - }, - "ServerCapabilities": { - "description": "Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities.", - "properties": { - "completions": { - "additionalProperties": true, - "description": "Present if the server supports argument autocompletion suggestions.", - "properties": {}, - "type": "object" - }, - "experimental": { - "additionalProperties": { - "additionalProperties": true, - "properties": {}, - "type": "object" - }, - "description": "Experimental, non-standard capabilities that the server supports.", - "type": "object" - }, - "logging": { - "additionalProperties": true, - "description": "Present if the server supports sending log messages to the client.", - "properties": {}, - "type": "object" - }, - "prompts": { - "description": "Present if the server offers any prompt templates.", - "properties": { - "listChanged": { - "description": "Whether this server supports notifications for changes to the prompt list.", - "type": "boolean" - } - }, - "type": "object" - }, - "resources": { - "description": "Present if the server offers any resources to read.", - "properties": { - "listChanged": { - "description": "Whether this server supports notifications for changes to the resource list.", - "type": "boolean" - }, - "subscribe": { - "description": "Whether this server supports subscribing to resource updates.", - "type": "boolean" - } - }, - "type": "object" - }, - "tools": { - "description": "Present if the server offers any tools to call.", - "properties": { - "listChanged": { - "description": "Whether this server supports notifications for changes to the tool list.", - "type": "boolean" - } - }, - "type": "object" - } - }, - "type": "object" - }, - "ServerNotification": { - "anyOf": [ - { - "$ref": "#/definitions/CancelledNotification" - }, - { - "$ref": "#/definitions/ProgressNotification" - }, - { - "$ref": "#/definitions/ResourceListChangedNotification" - }, - { - "$ref": "#/definitions/ResourceUpdatedNotification" - }, - { - "$ref": "#/definitions/PromptListChangedNotification" - }, - { - "$ref": "#/definitions/ToolListChangedNotification" - }, - { - "$ref": "#/definitions/LoggingMessageNotification" - } - ] - }, - "ServerRequest": { - "anyOf": [ - { - "$ref": "#/definitions/PingRequest" - }, - { - "$ref": "#/definitions/CreateMessageRequest" - }, - { - "$ref": "#/definitions/ListRootsRequest" - } - ] - }, - "ServerResult": { - "anyOf": [ - { - "$ref": "#/definitions/Result" - }, - { - "$ref": "#/definitions/InitializeResult" - }, - { - "$ref": "#/definitions/ListResourcesResult" - }, - { - "$ref": "#/definitions/ReadResourceResult" - }, - { - "$ref": "#/definitions/ListPromptsResult" - }, - { - "$ref": "#/definitions/GetPromptResult" - }, - { - "$ref": "#/definitions/ListToolsResult" - }, - { - "$ref": "#/definitions/CallToolResult" - }, - { - "$ref": "#/definitions/CompleteResult" - } - ] - }, - "SetLevelRequest": { - "description": "A request from the client to the server, to enable or adjust logging.", - "properties": { - "method": { - "const": "logging/setLevel", - "type": "string" - }, - "params": { - "properties": { - "level": { - "$ref": "#/definitions/LoggingLevel", - "description": "The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message." - } - }, - "required": [ - "level" - ], - "type": "object" - } - }, - "required": [ - "method", - "params" - ], - "type": "object" - }, - "SubscribeRequest": { - "description": "Sent from the client to request resources/updated notifications from the server whenever a particular resource changes.", - "properties": { - "method": { - "const": "resources/subscribe", - "type": "string" - }, - "params": { - "properties": { - "uri": { - "description": "The URI of the resource to subscribe to. The URI can use any protocol; it is up to the server how to interpret it.", - "format": "uri", - "type": "string" - } - }, - "required": [ - "uri" - ], - "type": "object" - } - }, - "required": [ - "method", - "params" - ], - "type": "object" - }, - "TextContent": { - "description": "Text provided to or from an LLM.", - "properties": { - "annotations": { - "$ref": "#/definitions/Annotations", - "description": "Optional annotations for the client." - }, - "text": { - "description": "The text content of the message.", - "type": "string" - }, - "type": { - "const": "text", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "type": "object" - }, - "TextResourceContents": { - "properties": { - "mimeType": { - "description": "The MIME type of this resource, if known.", - "type": "string" - }, - "text": { - "description": "The text of the item. This must only be set if the item can actually be represented as text (not binary data).", - "type": "string" - }, - "uri": { - "description": "The URI of this resource.", - "format": "uri", - "type": "string" - } - }, - "required": [ - "text", - "uri" - ], - "type": "object" - }, - "Tool": { - "description": "Definition for a tool the client can call.", - "properties": { - "annotations": { - "$ref": "#/definitions/ToolAnnotations", - "description": "Optional additional tool information." - }, - "description": { - "description": "A human-readable description of the tool.\n\nThis can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a \"hint\" to the model.", - "type": "string" - }, - "inputSchema": { - "description": "A JSON Schema object defining the expected parameters for the tool.", - "properties": { - "properties": { - "additionalProperties": { - "additionalProperties": true, - "properties": {}, - "type": "object" - }, - "type": "object" - }, - "required": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "const": "object", - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - "name": { - "description": "The name of the tool.", - "type": "string" - } - }, - "required": [ - "inputSchema", - "name" - ], - "type": "object" - }, - "ToolAnnotations": { - "description": "Additional properties describing a Tool to clients.\n\nNOTE: all properties in ToolAnnotations are **hints**. \nThey are not guaranteed to provide a faithful description of \ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on ToolAnnotations\nreceived from untrusted servers.", - "properties": { - "destructiveHint": { - "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true", - "type": "boolean" - }, - "idempotentHint": { - "description": "If true, calling the tool repeatedly with the same arguments \nwill have no additional effect on the its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false", - "type": "boolean" - }, - "openWorldHint": { - "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", - "type": "boolean" - }, - "readOnlyHint": { - "description": "If true, the tool does not modify its environment.\n\nDefault: false", - "type": "boolean" - }, - "title": { - "description": "A human-readable title for the tool.", - "type": "string" - } - }, - "type": "object" - }, - "ToolListChangedNotification": { - "description": "An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client.", - "properties": { - "method": { - "const": "notifications/tools/list_changed", - "type": "string" - }, - "params": { - "additionalProperties": {}, - "properties": { - "_meta": { - "additionalProperties": {}, - "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", - "type": "object" - } - }, - "type": "object" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "UnsubscribeRequest": { - "description": "Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request.", - "properties": { - "method": { - "const": "resources/unsubscribe", - "type": "string" - }, - "params": { - "properties": { - "uri": { - "description": "The URI of the resource to unsubscribe from.", - "format": "uri", - "type": "string" - } - }, - "required": [ - "uri" - ], - "type": "object" - } - }, - "required": [ - "method", - "params" - ], - "type": "object" - } - } + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "properties": { + "audience": { + "description": "Describes who the intended customer of this object or data is.\n\nIt can include multiple entries to indicate content useful for multiple audiences (e.g., `[\"user\", \"assistant\"]`).", + "items": { + "$ref": "#/definitions/Role" + }, + "type": "array" + }, + "priority": { + "description": "Describes how important this data is for operating the server.\n\nA value of 1 means \"most important,\" and indicates that the data is\neffectively required, while 0 means \"least important,\" and indicates that\nthe data is entirely optional.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "properties": { + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded audio data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio. Different providers may support different audio types.", + "type": "string" + }, + "type": { + "const": "audio", + "type": "string" + } + }, + "required": ["data", "mimeType", "type"], + "type": "object" + }, + "BlobResourceContents": { + "properties": { + "blob": { + "description": "A base64-encoded string representing the binary data of the item.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": ["blob", "uri"], + "type": "object" + }, + "CallToolRequest": { + "description": "Used by the client to invoke a tool provided by the server.", + "properties": { + "method": { + "const": "tools/call", + "type": "string" + }, + "params": { + "properties": { + "arguments": { + "additionalProperties": {}, + "type": "object" + }, + "name": { + "type": "string" + } + }, + "required": ["name"], + "type": "object" + } + }, + "required": ["method", "params"], + "type": "object" + }, + "CallToolResult": { + "description": "The server's response to a tool call.\n\nAny errors that originate from the tool SHOULD be reported inside the result\nobject, with `isError` set to true, _not_ as an MCP protocol-level error\nresponse. Otherwise, the LLM would not be able to see that an error occurred\nand self-correct.\n\nHowever, any errors in _finding_ the tool, an error indicating that the\nserver does not support tool calls, or any other exceptional conditions,\nshould be reported as an MCP error response.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "content": { + "items": { + "anyOf": [ + { + "$ref": "#/definitions/TextContent" + }, + { + "$ref": "#/definitions/ImageContent" + }, + { + "$ref": "#/definitions/AudioContent" + }, + { + "$ref": "#/definitions/EmbeddedResource" + } + ] + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool call ended in an error.\n\nIf not set, this is assumed to be false (the call was successful).", + "type": "boolean" + } + }, + "required": ["content"], + "type": "object" + }, + "CancelledNotification": { + "description": "This notification can be sent by either side to indicate that it is cancelling a previously-issued request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.\n\nA client MUST NOT attempt to cancel its `initialize` request.", + "properties": { + "method": { + "const": "notifications/cancelled", + "type": "string" + }, + "params": { + "properties": { + "reason": { + "description": "An optional string describing the reason for the cancellation. This MAY be logged or presented to the user.", + "type": "string" + }, + "requestId": { + "$ref": "#/definitions/RequestId", + "description": "The ID of the request to cancel.\n\nThis MUST correspond to the ID of a request previously issued in the same direction." + } + }, + "required": ["requestId"], + "type": "object" + } + }, + "required": ["method", "params"], + "type": "object" + }, + "ClientCapabilities": { + "description": "Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.", + "properties": { + "experimental": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "description": "Experimental, non-standard capabilities that the client supports.", + "type": "object" + }, + "roots": { + "description": "Present if the client supports listing roots.", + "properties": { + "listChanged": { + "description": "Whether the client supports notifications for changes to the roots list.", + "type": "boolean" + } + }, + "type": "object" + }, + "sampling": { + "additionalProperties": true, + "description": "Present if the client supports sampling from an LLM.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "ClientNotification": { + "anyOf": [ + { + "$ref": "#/definitions/CancelledNotification" + }, + { + "$ref": "#/definitions/InitializedNotification" + }, + { + "$ref": "#/definitions/ProgressNotification" + }, + { + "$ref": "#/definitions/RootsListChangedNotification" + } + ] + }, + "ClientRequest": { + "anyOf": [ + { + "$ref": "#/definitions/InitializeRequest" + }, + { + "$ref": "#/definitions/PingRequest" + }, + { + "$ref": "#/definitions/ListResourcesRequest" + }, + { + "$ref": "#/definitions/ReadResourceRequest" + }, + { + "$ref": "#/definitions/SubscribeRequest" + }, + { + "$ref": "#/definitions/UnsubscribeRequest" + }, + { + "$ref": "#/definitions/ListPromptsRequest" + }, + { + "$ref": "#/definitions/GetPromptRequest" + }, + { + "$ref": "#/definitions/ListToolsRequest" + }, + { + "$ref": "#/definitions/CallToolRequest" + }, + { + "$ref": "#/definitions/SetLevelRequest" + }, + { + "$ref": "#/definitions/CompleteRequest" + } + ] + }, + "ClientResult": { + "anyOf": [ + { + "$ref": "#/definitions/Result" + }, + { + "$ref": "#/definitions/CreateMessageResult" + }, + { + "$ref": "#/definitions/ListRootsResult" + } + ] + }, + "CompleteRequest": { + "description": "A request from the client to the server, to ask for completion options.", + "properties": { + "method": { + "const": "completion/complete", + "type": "string" + }, + "params": { + "properties": { + "argument": { + "description": "The argument's information", + "properties": { + "name": { + "description": "The name of the argument", + "type": "string" + }, + "value": { + "description": "The value of the argument to use for completion matching.", + "type": "string" + } + }, + "required": ["name", "value"], + "type": "object" + }, + "ref": { + "anyOf": [ + { + "$ref": "#/definitions/PromptReference" + }, + { + "$ref": "#/definitions/ResourceReference" + } + ] + } + }, + "required": ["argument", "ref"], + "type": "object" + } + }, + "required": ["method", "params"], + "type": "object" + }, + "CompleteResult": { + "description": "The server's response to a completion/complete request", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "completion": { + "properties": { + "hasMore": { + "description": "Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown.", + "type": "boolean" + }, + "total": { + "description": "The total number of completion options available. This can exceed the number of values actually sent in the response.", + "type": "integer" + }, + "values": { + "description": "An array of completion values. Must not exceed 100 items.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": ["values"], + "type": "object" + } + }, + "required": ["completion"], + "type": "object" + }, + "CreateMessageRequest": { + "description": "A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it.", + "properties": { + "method": { + "const": "sampling/createMessage", + "type": "string" + }, + "params": { + "properties": { + "includeContext": { + "description": "A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. The client MAY ignore this request.", + "enum": ["allServers", "none", "thisServer"], + "type": "string" + }, + "maxTokens": { + "description": "The maximum number of tokens to sample, as requested by the server. The client MAY choose to sample fewer tokens than requested.", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/definitions/SamplingMessage" + }, + "type": "array" + }, + "metadata": { + "additionalProperties": true, + "description": "Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific.", + "properties": {}, + "type": "object" + }, + "modelPreferences": { + "$ref": "#/definitions/ModelPreferences", + "description": "The server's preferences for which model to select. The client MAY ignore these preferences." + }, + "stopSequences": { + "items": { + "type": "string" + }, + "type": "array" + }, + "systemPrompt": { + "description": "An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt.", + "type": "string" + }, + "temperature": { + "type": "number" + } + }, + "required": ["maxTokens", "messages"], + "type": "object" + } + }, + "required": ["method", "params"], + "type": "object" + }, + "CreateMessageResult": { + "description": "The client's response to a sampling/create_message request from the server. The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) and decide whether to allow the server to see it.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "content": { + "anyOf": [ + { + "$ref": "#/definitions/TextContent" + }, + { + "$ref": "#/definitions/ImageContent" + }, + { + "$ref": "#/definitions/AudioContent" + } + ] + }, + "model": { + "description": "The name of the model that generated the message.", + "type": "string" + }, + "role": { + "$ref": "#/definitions/Role" + }, + "stopReason": { + "description": "The reason why sampling stopped, if known.", + "type": "string" + } + }, + "required": ["content", "model", "role"], + "type": "object" + }, + "Cursor": { + "description": "An opaque token used to represent a cursor for pagination.", + "type": "string" + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.\n\nIt is up to the client how best to render embedded resources for the benefit\nof the LLM and/or the user.", + "properties": { + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "resource": { + "anyOf": [ + { + "$ref": "#/definitions/TextResourceContents" + }, + { + "$ref": "#/definitions/BlobResourceContents" + } + ] + }, + "type": { + "const": "resource", + "type": "string" + } + }, + "required": ["resource", "type"], + "type": "object" + }, + "EmptyResult": { + "$ref": "#/definitions/Result" + }, + "GetPromptRequest": { + "description": "Used by the client to get a prompt provided by the server.", + "properties": { + "method": { + "const": "prompts/get", + "type": "string" + }, + "params": { + "properties": { + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Arguments to use for templating the prompt.", + "type": "object" + }, + "name": { + "description": "The name of the prompt or prompt template.", + "type": "string" + } + }, + "required": ["name"], + "type": "object" + } + }, + "required": ["method", "params"], + "type": "object" + }, + "GetPromptResult": { + "description": "The server's response to a prompts/get request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "description": { + "description": "An optional description for the prompt.", + "type": "string" + }, + "messages": { + "items": { + "$ref": "#/definitions/PromptMessage" + }, + "type": "array" + } + }, + "required": ["messages"], + "type": "object" + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "properties": { + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded image data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image. Different providers may support different image types.", + "type": "string" + }, + "type": { + "const": "image", + "type": "string" + } + }, + "required": ["data", "mimeType", "type"], + "type": "object" + }, + "Implementation": { + "description": "Describes the name and version of an MCP implementation.", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": ["name", "version"], + "type": "object" + }, + "InitializeRequest": { + "description": "This request is sent from the client to the server when it first connects, asking it to begin initialization.", + "properties": { + "method": { + "const": "initialize", + "type": "string" + }, + "params": { + "properties": { + "capabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "protocolVersion": { + "description": "The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well.", + "type": "string" + } + }, + "required": ["capabilities", "clientInfo", "protocolVersion"], + "type": "object" + } + }, + "required": ["method", "params"], + "type": "object" + }, + "InitializeResult": { + "description": "After receiving an initialize request from the client, the server sends this response.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "capabilities": { + "$ref": "#/definitions/ServerCapabilities" + }, + "instructions": { + "description": "Instructions describing how to use the server and its features.\n\nThis can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a \"hint\" to the model. For example, this information MAY be added to the system prompt.", + "type": "string" + }, + "protocolVersion": { + "description": "The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect.", + "type": "string" + }, + "serverInfo": { + "$ref": "#/definitions/Implementation" + } + }, + "required": ["capabilities", "protocolVersion", "serverInfo"], + "type": "object" + }, + "InitializedNotification": { + "description": "This notification is sent from the client to the server after initialization has finished.", + "properties": { + "method": { + "const": "notifications/initialized", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": ["method"], + "type": "object" + }, + "JSONRPCBatchRequest": { + "description": "A JSON-RPC batch request, as described in https://www.jsonrpc.org/specification#batch.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/JSONRPCRequest" + }, + { + "$ref": "#/definitions/JSONRPCNotification" + } + ] + }, + "type": "array" + }, + "JSONRPCBatchResponse": { + "description": "A JSON-RPC batch response, as described in https://www.jsonrpc.org/specification#batch.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/JSONRPCResponse" + }, + { + "$ref": "#/definitions/JSONRPCError" + } + ] + }, + "type": "array" + }, + "JSONRPCError": { + "description": "A response to a request that indicates an error occurred.", + "properties": { + "error": { + "properties": { + "code": { + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": ["code", "message"], + "type": "object" + }, + "id": { + "$ref": "#/definitions/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": ["error", "id", "jsonrpc"], + "type": "object" + }, + "JSONRPCMessage": { + "anyOf": [ + { + "$ref": "#/definitions/JSONRPCRequest" + }, + { + "$ref": "#/definitions/JSONRPCNotification" + }, + { + "description": "A JSON-RPC batch request, as described in https://www.jsonrpc.org/specification#batch.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/JSONRPCRequest" + }, + { + "$ref": "#/definitions/JSONRPCNotification" + } + ] + }, + "type": "array" + }, + { + "$ref": "#/definitions/JSONRPCResponse" + }, + { + "$ref": "#/definitions/JSONRPCError" + }, + { + "description": "A JSON-RPC batch response, as described in https://www.jsonrpc.org/specification#batch.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/JSONRPCResponse" + }, + { + "$ref": "#/definitions/JSONRPCError" + } + ] + }, + "type": "array" + } + ], + "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent." + }, + "JSONRPCNotification": { + "description": "A notification which does not expect a response.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": ["jsonrpc", "method"], + "type": "object" + }, + "JSONRPCRequest": { + "description": "A request that expects a response.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "properties": { + "progressToken": { + "$ref": "#/definitions/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "required": ["id", "jsonrpc", "method"], + "type": "object" + }, + "JSONRPCResponse": { + "description": "A successful (non-error) response to a request.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/definitions/Result" + } + }, + "required": ["id", "jsonrpc", "result"], + "type": "object" + }, + "ListPromptsRequest": { + "description": "Sent from the client to request a list of prompts and prompt templates the server has.", + "properties": { + "method": { + "const": "prompts/list", + "type": "string" + }, + "params": { + "properties": { + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + } + }, + "required": ["method"], + "type": "object" + }, + "ListPromptsResult": { + "description": "The server's response to a prompts/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "prompts": { + "items": { + "$ref": "#/definitions/Prompt" + }, + "type": "array" + } + }, + "required": ["prompts"], + "type": "object" + }, + "ListResourceTemplatesRequest": { + "description": "Sent from the client to request a list of resource templates the server has.", + "properties": { + "method": { + "const": "resources/templates/list", + "type": "string" + }, + "params": { + "properties": { + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + } + }, + "required": ["method"], + "type": "object" + }, + "ListResourceTemplatesResult": { + "description": "The server's response to a resources/templates/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resourceTemplates": { + "items": { + "$ref": "#/definitions/ResourceTemplate" + }, + "type": "array" + } + }, + "required": ["resourceTemplates"], + "type": "object" + }, + "ListResourcesRequest": { + "description": "Sent from the client to request a list of resources the server has.", + "properties": { + "method": { + "const": "resources/list", + "type": "string" + }, + "params": { + "properties": { + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + } + }, + "required": ["method"], + "type": "object" + }, + "ListResourcesResult": { + "description": "The server's response to a resources/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resources": { + "items": { + "$ref": "#/definitions/Resource" + }, + "type": "array" + } + }, + "required": ["resources"], + "type": "object" + }, + "ListRootsRequest": { + "description": "Sent from the server to request a list of root URIs from the client. Roots allow\nservers to ask for specific directories or files to operate on. A common example\nfor roots is providing a set of repositories or directories a server should operate\non.\n\nThis request is typically used when the server needs to understand the file system\nstructure or access specific locations that the client has permission to read from.", + "properties": { + "method": { + "const": "roots/list", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "properties": { + "progressToken": { + "$ref": "#/definitions/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "required": ["method"], + "type": "object" + }, + "ListRootsResult": { + "description": "The client's response to a roots/list request from the server.\nThis result contains an array of Root objects, each representing a root directory\nor file that the server can operate on.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "roots": { + "items": { + "$ref": "#/definitions/Root" + }, + "type": "array" + } + }, + "required": ["roots"], + "type": "object" + }, + "ListToolsRequest": { + "description": "Sent from the client to request a list of tools the server has.", + "properties": { + "method": { + "const": "tools/list", + "type": "string" + }, + "params": { + "properties": { + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + } + }, + "required": ["method"], + "type": "object" + }, + "ListToolsResult": { + "description": "The server's response to a tools/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "tools": { + "items": { + "$ref": "#/definitions/Tool" + }, + "type": "array" + } + }, + "required": ["tools"], + "type": "object" + }, + "LoggingLevel": { + "description": "The severity of a log message.\n\nThese map to syslog message severities, as specified in RFC-5424:\nhttps://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1", + "enum": [ + "alert", + "critical", + "debug", + "emergency", + "error", + "info", + "notice", + "warning" + ], + "type": "string" + }, + "LoggingMessageNotification": { + "description": "Notification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically.", + "properties": { + "method": { + "const": "notifications/message", + "type": "string" + }, + "params": { + "properties": { + "data": { + "description": "The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here." + }, + "level": { + "$ref": "#/definitions/LoggingLevel", + "description": "The severity of this log message." + }, + "logger": { + "description": "An optional name of the logger issuing this message.", + "type": "string" + } + }, + "required": ["data", "level"], + "type": "object" + } + }, + "required": ["method", "params"], + "type": "object" + }, + "ModelHint": { + "description": "Hints to use for model selection.\n\nKeys not declared here are currently left unspecified by the spec and are up\nto the client to interpret.", + "properties": { + "name": { + "description": "A hint for a model name.\n\nThe client SHOULD treat this as a substring of a model name; for example:\n - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022`\n - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc.\n - `claude` should match any Claude model\n\nThe client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example:\n - `gemini-1.5-flash` could match `claude-3-haiku-20240307`", + "type": "string" + } + }, + "type": "object" + }, + "ModelPreferences": { + "description": "The server's preferences for model selection, requested of the client during sampling.\n\nBecause LLMs can vary along multiple dimensions, choosing the \"best\" model is\nrarely straightforward. Different models excel in different areas—some are\nfaster but less capable, others are more capable but more expensive, and so\non. This interface allows servers to express their priorities across multiple\ndimensions to help clients make an appropriate selection for their use case.\n\nThese preferences are always advisory. The client MAY ignore them. It is also\nup to the client to decide how to interpret these preferences and how to\nbalance them against other considerations.", + "properties": { + "costPriority": { + "description": "How much to prioritize cost when selecting a model. A value of 0 means cost\nis not important, while a value of 1 means cost is the most important\nfactor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "hints": { + "description": "Optional hints to use for model selection.\n\nIf multiple hints are specified, the client MUST evaluate them in order\n(such that the first match is taken).\n\nThe client SHOULD prioritize these hints over the numeric priorities, but\nMAY still use the priorities to select from ambiguous matches.", + "items": { + "$ref": "#/definitions/ModelHint" + }, + "type": "array" + }, + "intelligencePriority": { + "description": "How much to prioritize intelligence and capabilities when selecting a\nmodel. A value of 0 means intelligence is not important, while a value of 1\nmeans intelligence is the most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "speedPriority": { + "description": "How much to prioritize sampling speed (latency) when selecting a model. A\nvalue of 0 means speed is not important, while a value of 1 means speed is\nthe most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "Notification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": ["method"], + "type": "object" + }, + "PaginatedRequest": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "properties": { + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + } + }, + "required": ["method"], + "type": "object" + }, + "PaginatedResult": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + } + }, + "type": "object" + }, + "PingRequest": { + "description": "A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected.", + "properties": { + "method": { + "const": "ping", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "properties": { + "progressToken": { + "$ref": "#/definitions/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "required": ["method"], + "type": "object" + }, + "ProgressNotification": { + "description": "An out-of-band notification used to inform the receiver of a progress update for a long-running request.", + "properties": { + "method": { + "const": "notifications/progress", + "type": "string" + }, + "params": { + "properties": { + "message": { + "description": "An optional message describing the current progress.", + "type": "string" + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken", + "description": "The progress token which was given in the initial request, used to associate this notification with the request that is proceeding." + }, + "total": { + "description": "Total number of items to process (or total progress required), if known.", + "type": "number" + } + }, + "required": ["progress", "progressToken"], + "type": "object" + } + }, + "required": ["method", "params"], + "type": "object" + }, + "ProgressToken": { + "description": "A progress token, used to associate progress notifications with the original request.", + "type": ["string", "integer"] + }, + "Prompt": { + "description": "A prompt or prompt template that the server offers.", + "properties": { + "arguments": { + "description": "A list of arguments to use for templating the prompt.", + "items": { + "$ref": "#/definitions/PromptArgument" + }, + "type": "array" + }, + "description": { + "description": "An optional description of what this prompt provides", + "type": "string" + }, + "name": { + "description": "The name of the prompt or prompt template.", + "type": "string" + } + }, + "required": ["name"], + "type": "object" + }, + "PromptArgument": { + "description": "Describes an argument that a prompt can accept.", + "properties": { + "description": { + "description": "A human-readable description of the argument.", + "type": "string" + }, + "name": { + "description": "The name of the argument.", + "type": "string" + }, + "required": { + "description": "Whether this argument must be provided.", + "type": "boolean" + } + }, + "required": ["name"], + "type": "object" + }, + "PromptListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "method": { + "const": "notifications/prompts/list_changed", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": ["method"], + "type": "object" + }, + "PromptMessage": { + "description": "Describes a message returned as part of a prompt.\n\nThis is similar to `SamplingMessage`, but also supports the embedding of\nresources from the MCP server.", + "properties": { + "content": { + "anyOf": [ + { + "$ref": "#/definitions/TextContent" + }, + { + "$ref": "#/definitions/ImageContent" + }, + { + "$ref": "#/definitions/AudioContent" + }, + { + "$ref": "#/definitions/EmbeddedResource" + } + ] + }, + "role": { + "$ref": "#/definitions/Role" + } + }, + "required": ["content", "role"], + "type": "object" + }, + "PromptReference": { + "description": "Identifies a prompt.", + "properties": { + "name": { + "description": "The name of the prompt or prompt template", + "type": "string" + }, + "type": { + "const": "ref/prompt", + "type": "string" + } + }, + "required": ["name", "type"], + "type": "object" + }, + "ReadResourceRequest": { + "description": "Sent from the client to the server, to read a specific resource URI.", + "properties": { + "method": { + "const": "resources/read", + "type": "string" + }, + "params": { + "properties": { + "uri": { + "description": "The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": ["uri"], + "type": "object" + } + }, + "required": ["method", "params"], + "type": "object" + }, + "ReadResourceResult": { + "description": "The server's response to a resources/read request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "contents": { + "items": { + "anyOf": [ + { + "$ref": "#/definitions/TextResourceContents" + }, + { + "$ref": "#/definitions/BlobResourceContents" + } + ] + }, + "type": "array" + } + }, + "required": ["contents"], + "type": "object" + }, + "Request": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "properties": { + "progressToken": { + "$ref": "#/definitions/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "required": ["method"], + "type": "object" + }, + "RequestId": { + "description": "A uniquely identifying ID for a request in JSON-RPC.", + "type": ["string", "integer"] + }, + "Resource": { + "description": "A known resource that the server is capable of reading.", + "properties": { + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "A human-readable name for this resource.\n\nThis can be used by clients to populate UI elements.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": ["name", "uri"], + "type": "object" + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "properties": { + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": ["uri"], + "type": "object" + }, + "ResourceListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "method": { + "const": "notifications/resources/list_changed", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": ["method"], + "type": "object" + }, + "ResourceReference": { + "description": "A reference to a resource or resource template definition.", + "properties": { + "type": { + "const": "ref/resource", + "type": "string" + }, + "uri": { + "description": "The URI or URI template of the resource.", + "format": "uri-template", + "type": "string" + } + }, + "required": ["type", "uri"], + "type": "object" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server.", + "properties": { + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this template is for.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type.", + "type": "string" + }, + "name": { + "description": "A human-readable name for the type of resource this template refers to.\n\nThis can be used by clients to populate UI elements.", + "type": "string" + }, + "uriTemplate": { + "description": "A URI template (according to RFC 6570) that can be used to construct resource URIs.", + "format": "uri-template", + "type": "string" + } + }, + "required": ["name", "uriTemplate"], + "type": "object" + }, + "ResourceUpdatedNotification": { + "description": "A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request.", + "properties": { + "method": { + "const": "notifications/resources/updated", + "type": "string" + }, + "params": { + "properties": { + "uri": { + "description": "The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to.", + "format": "uri", + "type": "string" + } + }, + "required": ["uri"], + "type": "object" + } + }, + "required": ["method", "params"], + "type": "object" + }, + "Result": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + } + }, + "type": "object" + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "enum": ["assistant", "user"], + "type": "string" + }, + "Root": { + "description": "Represents a root directory or file that the server can operate on.", + "properties": { + "name": { + "description": "An optional name for the root. This can be used to provide a human-readable\nidentifier for the root, which may be useful for display purposes or for\nreferencing the root in other parts of the application.", + "type": "string" + }, + "uri": { + "description": "The URI identifying the root. This *must* start with file:// for now.\nThis restriction may be relaxed in future versions of the protocol to allow\nother URI schemes.", + "format": "uri", + "type": "string" + } + }, + "required": ["uri"], + "type": "object" + }, + "RootsListChangedNotification": { + "description": "A notification from the client to the server, informing it that the list of roots has changed.\nThis notification should be sent whenever the client adds, removes, or modifies any root.\nThe server should then request an updated list of roots using the ListRootsRequest.", + "properties": { + "method": { + "const": "notifications/roots/list_changed", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": ["method"], + "type": "object" + }, + "SamplingMessage": { + "description": "Describes a message issued to or received from an LLM API.", + "properties": { + "content": { + "anyOf": [ + { + "$ref": "#/definitions/TextContent" + }, + { + "$ref": "#/definitions/ImageContent" + }, + { + "$ref": "#/definitions/AudioContent" + } + ] + }, + "role": { + "$ref": "#/definitions/Role" + } + }, + "required": ["content", "role"], + "type": "object" + }, + "ServerCapabilities": { + "description": "Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities.", + "properties": { + "completions": { + "additionalProperties": true, + "description": "Present if the server supports argument autocompletion suggestions.", + "properties": {}, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "description": "Experimental, non-standard capabilities that the server supports.", + "type": "object" + }, + "logging": { + "additionalProperties": true, + "description": "Present if the server supports sending log messages to the client.", + "properties": {}, + "type": "object" + }, + "prompts": { + "description": "Present if the server offers any prompt templates.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the prompt list.", + "type": "boolean" + } + }, + "type": "object" + }, + "resources": { + "description": "Present if the server offers any resources to read.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the resource list.", + "type": "boolean" + }, + "subscribe": { + "description": "Whether this server supports subscribing to resource updates.", + "type": "boolean" + } + }, + "type": "object" + }, + "tools": { + "description": "Present if the server offers any tools to call.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the tool list.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ServerNotification": { + "anyOf": [ + { + "$ref": "#/definitions/CancelledNotification" + }, + { + "$ref": "#/definitions/ProgressNotification" + }, + { + "$ref": "#/definitions/ResourceListChangedNotification" + }, + { + "$ref": "#/definitions/ResourceUpdatedNotification" + }, + { + "$ref": "#/definitions/PromptListChangedNotification" + }, + { + "$ref": "#/definitions/ToolListChangedNotification" + }, + { + "$ref": "#/definitions/LoggingMessageNotification" + } + ] + }, + "ServerRequest": { + "anyOf": [ + { + "$ref": "#/definitions/PingRequest" + }, + { + "$ref": "#/definitions/CreateMessageRequest" + }, + { + "$ref": "#/definitions/ListRootsRequest" + } + ] + }, + "ServerResult": { + "anyOf": [ + { + "$ref": "#/definitions/Result" + }, + { + "$ref": "#/definitions/InitializeResult" + }, + { + "$ref": "#/definitions/ListResourcesResult" + }, + { + "$ref": "#/definitions/ReadResourceResult" + }, + { + "$ref": "#/definitions/ListPromptsResult" + }, + { + "$ref": "#/definitions/GetPromptResult" + }, + { + "$ref": "#/definitions/ListToolsResult" + }, + { + "$ref": "#/definitions/CallToolResult" + }, + { + "$ref": "#/definitions/CompleteResult" + } + ] + }, + "SetLevelRequest": { + "description": "A request from the client to the server, to enable or adjust logging.", + "properties": { + "method": { + "const": "logging/setLevel", + "type": "string" + }, + "params": { + "properties": { + "level": { + "$ref": "#/definitions/LoggingLevel", + "description": "The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message." + } + }, + "required": ["level"], + "type": "object" + } + }, + "required": ["method", "params"], + "type": "object" + }, + "SubscribeRequest": { + "description": "Sent from the client to request resources/updated notifications from the server whenever a particular resource changes.", + "properties": { + "method": { + "const": "resources/subscribe", + "type": "string" + }, + "params": { + "properties": { + "uri": { + "description": "The URI of the resource to subscribe to. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": ["uri"], + "type": "object" + } + }, + "required": ["method", "params"], + "type": "object" + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "properties": { + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "text": { + "description": "The text content of the message.", + "type": "string" + }, + "type": { + "const": "text", + "type": "string" + } + }, + "required": ["text", "type"], + "type": "object" + }, + "TextResourceContents": { + "properties": { + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "text": { + "description": "The text of the item. This must only be set if the item can actually be represented as text (not binary data).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": ["text", "uri"], + "type": "object" + }, + "Tool": { + "description": "Definition for a tool the client can call.", + "properties": { + "annotations": { + "$ref": "#/definitions/ToolAnnotations", + "description": "Optional additional tool information." + }, + "description": { + "description": "A human-readable description of the tool.\n\nThis can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "inputSchema": { + "description": "A JSON Schema object defining the expected parameters for the tool.", + "properties": { + "properties": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + "name": { + "description": "The name of the tool.", + "type": "string" + } + }, + "required": ["inputSchema", "name"], + "type": "object" + }, + "ToolAnnotations": { + "description": "Additional properties describing a Tool to clients.\n\nNOTE: all properties in ToolAnnotations are **hints**. \nThey are not guaranteed to provide a faithful description of \ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on ToolAnnotations\nreceived from untrusted servers.", + "properties": { + "destructiveHint": { + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true", + "type": "boolean" + }, + "idempotentHint": { + "description": "If true, calling the tool repeatedly with the same arguments \nwill have no additional effect on the its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false", + "type": "boolean" + }, + "openWorldHint": { + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", + "type": "boolean" + }, + "readOnlyHint": { + "description": "If true, the tool does not modify its environment.\n\nDefault: false", + "type": "boolean" + }, + "title": { + "description": "A human-readable title for the tool.", + "type": "string" + } + }, + "type": "object" + }, + "ToolListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "method": { + "const": "notifications/tools/list_changed", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": ["method"], + "type": "object" + }, + "UnsubscribeRequest": { + "description": "Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request.", + "properties": { + "method": { + "const": "resources/unsubscribe", + "type": "string" + }, + "params": { + "properties": { + "uri": { + "description": "The URI of the resource to unsubscribe from.", + "format": "uri", + "type": "string" + } + }, + "required": ["uri"], + "type": "object" + } + }, + "required": ["method", "params"], + "type": "object" + } + } } diff --git a/index.js b/index.js index 77c43518..f7c5e2b5 100644 --- a/index.js +++ b/index.js @@ -41,27 +41,27 @@ export const devScriptPath = resolve(__dirname, './scripts/dev.js'); // Export a function to initialize a new project programmatically export const initProject = async (options = {}) => { - const init = await import('./scripts/init.js'); - return init.initializeProject(options); + const init = await import('./scripts/init.js'); + return init.initializeProject(options); }; // Export a function to run init as a CLI command export const runInitCLI = async () => { - // Using spawn to ensure proper handling of stdio and process exit - const child = spawn('node', [resolve(__dirname, './scripts/init.js')], { - stdio: 'inherit', - cwd: process.cwd() - }); - - return new Promise((resolve, reject) => { - child.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`Init script exited with code ${code}`)); - } - }); - }); + // Using spawn to ensure proper handling of stdio and process exit + const child = spawn('node', [resolve(__dirname, './scripts/init.js')], { + stdio: 'inherit', + cwd: process.cwd() + }); + + return new Promise((resolve, reject) => { + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Init script exited with code ${code}`)); + } + }); + }); }; // Export version information @@ -69,81 +69,81 @@ export const version = packageJson.version; // CLI implementation if (import.meta.url === `file://${process.argv[1]}`) { - const program = new Command(); - - program - .name('task-master') - .description('Claude Task Master CLI') - .version(version); - - program - .command('init') - .description('Initialize a new project') - .action(() => { - runInitCLI().catch(err => { - console.error('Init failed:', err.message); - process.exit(1); - }); - }); - - program - .command('dev') - .description('Run the dev.js script') - .allowUnknownOption(true) - .action(() => { - const args = process.argv.slice(process.argv.indexOf('dev') + 1); - const child = spawn('node', [devScriptPath, ...args], { - stdio: 'inherit', - cwd: process.cwd() - }); - - child.on('close', (code) => { - process.exit(code); - }); - }); - - // Add shortcuts for common dev.js commands - program - .command('list') - .description('List all tasks') - .action(() => { - const child = spawn('node', [devScriptPath, 'list'], { - stdio: 'inherit', - cwd: process.cwd() - }); - - child.on('close', (code) => { - process.exit(code); - }); - }); - - program - .command('next') - .description('Show the next task to work on') - .action(() => { - const child = spawn('node', [devScriptPath, 'next'], { - stdio: 'inherit', - cwd: process.cwd() - }); - - child.on('close', (code) => { - process.exit(code); - }); - }); - - program - .command('generate') - .description('Generate task files') - .action(() => { - const child = spawn('node', [devScriptPath, 'generate'], { - stdio: 'inherit', - cwd: process.cwd() - }); - - child.on('close', (code) => { - process.exit(code); - }); - }); - - program.parse(process.argv); -} \ No newline at end of file + const program = new Command(); + + program + .name('task-master') + .description('Claude Task Master CLI') + .version(version); + + program + .command('init') + .description('Initialize a new project') + .action(() => { + runInitCLI().catch((err) => { + console.error('Init failed:', err.message); + process.exit(1); + }); + }); + + program + .command('dev') + .description('Run the dev.js script') + .allowUnknownOption(true) + .action(() => { + const args = process.argv.slice(process.argv.indexOf('dev') + 1); + const child = spawn('node', [devScriptPath, ...args], { + stdio: 'inherit', + cwd: process.cwd() + }); + + child.on('close', (code) => { + process.exit(code); + }); + }); + + // Add shortcuts for common dev.js commands + program + .command('list') + .description('List all tasks') + .action(() => { + const child = spawn('node', [devScriptPath, 'list'], { + stdio: 'inherit', + cwd: process.cwd() + }); + + child.on('close', (code) => { + process.exit(code); + }); + }); + + program + .command('next') + .description('Show the next task to work on') + .action(() => { + const child = spawn('node', [devScriptPath, 'next'], { + stdio: 'inherit', + cwd: process.cwd() + }); + + child.on('close', (code) => { + process.exit(code); + }); + }); + + program + .command('generate') + .description('Generate task files') + .action(() => { + const child = spawn('node', [devScriptPath, 'generate'], { + stdio: 'inherit', + cwd: process.cwd() + }); + + child.on('close', (code) => { + process.exit(code); + }); + }); + + program.parse(process.argv); +} diff --git a/jest.config.js b/jest.config.js index 43929da5..fe301cf5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,56 +1,56 @@ export default { - // Use Node.js environment for testing - testEnvironment: 'node', - - // Automatically clear mock calls between every test - clearMocks: true, - - // Indicates whether the coverage information should be collected while executing the test - collectCoverage: false, - - // The directory where Jest should output its coverage files - coverageDirectory: 'coverage', - - // A list of paths to directories that Jest should use to search for files in - roots: ['/tests'], - - // The glob patterns Jest uses to detect test files - testMatch: [ - '**/__tests__/**/*.js', - '**/?(*.)+(spec|test).js', - '**/tests/*.test.js' - ], - - // Transform files - transform: {}, - - // Disable transformations for node_modules - transformIgnorePatterns: ['/node_modules/'], - - // Set moduleNameMapper for absolute paths - moduleNameMapper: { - '^@/(.*)$': '/$1' - }, - - // Setup module aliases - moduleDirectories: ['node_modules', ''], - - // Configure test coverage thresholds - coverageThreshold: { - global: { - branches: 80, - functions: 80, - lines: 80, - statements: 80 - } - }, - - // Generate coverage report in these formats - coverageReporters: ['text', 'lcov'], - - // Verbose output - verbose: true, - - // Setup file - setupFilesAfterEnv: ['/tests/setup.js'] -}; \ No newline at end of file + // Use Node.js environment for testing + testEnvironment: 'node', + + // Automatically clear mock calls between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: false, + + // The directory where Jest should output its coverage files + coverageDirectory: 'coverage', + + // A list of paths to directories that Jest should use to search for files in + roots: ['/tests'], + + // The glob patterns Jest uses to detect test files + testMatch: [ + '**/__tests__/**/*.js', + '**/?(*.)+(spec|test).js', + '**/tests/*.test.js' + ], + + // Transform files + transform: {}, + + // Disable transformations for node_modules + transformIgnorePatterns: ['/node_modules/'], + + // Set moduleNameMapper for absolute paths + moduleNameMapper: { + '^@/(.*)$': '/$1' + }, + + // Setup module aliases + moduleDirectories: ['node_modules', ''], + + // Configure test coverage thresholds + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80 + } + }, + + // Generate coverage report in these formats + coverageReporters: ['text', 'lcov'], + + // Verbose output + verbose: true, + + // Setup file + setupFilesAfterEnv: ['/tests/setup.js'] +}; diff --git a/mcp-server/server.js b/mcp-server/server.js index dfca0f55..025cfc6f 100755 --- a/mcp-server/server.js +++ b/mcp-server/server.js @@ -1,8 +1,8 @@ #!/usr/bin/env node -import TaskMasterMCPServer from "./src/index.js"; -import dotenv from "dotenv"; -import logger from "./src/logger.js"; +import TaskMasterMCPServer from './src/index.js'; +import dotenv from 'dotenv'; +import logger from './src/logger.js'; // Load environment variables dotenv.config(); @@ -11,25 +11,25 @@ dotenv.config(); * Start the MCP server */ async function startServer() { - const server = new TaskMasterMCPServer(); + const server = new TaskMasterMCPServer(); - // Handle graceful shutdown - process.on("SIGINT", async () => { - await server.stop(); - process.exit(0); - }); + // Handle graceful shutdown + process.on('SIGINT', async () => { + await server.stop(); + process.exit(0); + }); - process.on("SIGTERM", async () => { - await server.stop(); - process.exit(0); - }); + process.on('SIGTERM', async () => { + await server.stop(); + process.exit(0); + }); - try { - await server.start(); - } catch (error) { - logger.error(`Failed to start MCP server: ${error.message}`); - process.exit(1); - } + try { + await server.start(); + } catch (error) { + logger.error(`Failed to start MCP server: ${error.message}`); + process.exit(1); + } } // Start the server diff --git a/mcp-server/src/core/__tests__/context-manager.test.js b/mcp-server/src/core/__tests__/context-manager.test.js index 6e1b1805..9051d3c9 100644 --- a/mcp-server/src/core/__tests__/context-manager.test.js +++ b/mcp-server/src/core/__tests__/context-manager.test.js @@ -2,84 +2,90 @@ import { jest } from '@jest/globals'; import { ContextManager } from '../context-manager.js'; describe('ContextManager', () => { - let contextManager; + let contextManager; - beforeEach(() => { - contextManager = new ContextManager({ - maxCacheSize: 10, - ttl: 1000, // 1 second for testing - maxContextSize: 1000 - }); - }); + beforeEach(() => { + contextManager = new ContextManager({ + maxCacheSize: 10, + ttl: 1000, // 1 second for testing + maxContextSize: 1000 + }); + }); - describe('getContext', () => { - it('should create a new context when not in cache', async () => { - const context = await contextManager.getContext('test-id', { test: true }); - expect(context.id).toBe('test-id'); - expect(context.metadata.test).toBe(true); - expect(contextManager.stats.misses).toBe(1); - expect(contextManager.stats.hits).toBe(0); - }); + describe('getContext', () => { + it('should create a new context when not in cache', async () => { + const context = await contextManager.getContext('test-id', { + test: true + }); + expect(context.id).toBe('test-id'); + expect(context.metadata.test).toBe(true); + expect(contextManager.stats.misses).toBe(1); + expect(contextManager.stats.hits).toBe(0); + }); - it('should return cached context when available', async () => { - // First call creates the context - await contextManager.getContext('test-id', { test: true }); - - // Second call should hit cache - const context = await contextManager.getContext('test-id', { test: true }); - expect(context.id).toBe('test-id'); - expect(context.metadata.test).toBe(true); - expect(contextManager.stats.hits).toBe(1); - expect(contextManager.stats.misses).toBe(1); - }); + it('should return cached context when available', async () => { + // First call creates the context + await contextManager.getContext('test-id', { test: true }); - it('should respect TTL settings', async () => { - // Create context - await contextManager.getContext('test-id', { test: true }); - - // Wait for TTL to expire - await new Promise(resolve => setTimeout(resolve, 1100)); - - // Should create new context - await contextManager.getContext('test-id', { test: true }); - expect(contextManager.stats.misses).toBe(2); - expect(contextManager.stats.hits).toBe(0); - }); - }); + // Second call should hit cache + const context = await contextManager.getContext('test-id', { + test: true + }); + expect(context.id).toBe('test-id'); + expect(context.metadata.test).toBe(true); + expect(contextManager.stats.hits).toBe(1); + expect(contextManager.stats.misses).toBe(1); + }); - describe('updateContext', () => { - it('should update existing context metadata', async () => { - await contextManager.getContext('test-id', { initial: true }); - const updated = await contextManager.updateContext('test-id', { updated: true }); - - expect(updated.metadata.initial).toBe(true); - expect(updated.metadata.updated).toBe(true); - }); - }); + it('should respect TTL settings', async () => { + // Create context + await contextManager.getContext('test-id', { test: true }); - describe('invalidateContext', () => { - it('should remove context from cache', async () => { - await contextManager.getContext('test-id', { test: true }); - contextManager.invalidateContext('test-id', { test: true }); - - // Should be a cache miss - await contextManager.getContext('test-id', { test: true }); - expect(contextManager.stats.invalidations).toBe(1); - expect(contextManager.stats.misses).toBe(2); - }); - }); + // Wait for TTL to expire + await new Promise((resolve) => setTimeout(resolve, 1100)); - describe('getStats', () => { - it('should return current cache statistics', async () => { - await contextManager.getContext('test-id', { test: true }); - const stats = contextManager.getStats(); - - expect(stats.hits).toBe(0); - expect(stats.misses).toBe(1); - expect(stats.invalidations).toBe(0); - expect(stats.size).toBe(1); - expect(stats.maxSize).toBe(10); - expect(stats.ttl).toBe(1000); - }); - }); -}); \ No newline at end of file + // Should create new context + await contextManager.getContext('test-id', { test: true }); + expect(contextManager.stats.misses).toBe(2); + expect(contextManager.stats.hits).toBe(0); + }); + }); + + describe('updateContext', () => { + it('should update existing context metadata', async () => { + await contextManager.getContext('test-id', { initial: true }); + const updated = await contextManager.updateContext('test-id', { + updated: true + }); + + expect(updated.metadata.initial).toBe(true); + expect(updated.metadata.updated).toBe(true); + }); + }); + + describe('invalidateContext', () => { + it('should remove context from cache', async () => { + await contextManager.getContext('test-id', { test: true }); + contextManager.invalidateContext('test-id', { test: true }); + + // Should be a cache miss + await contextManager.getContext('test-id', { test: true }); + expect(contextManager.stats.invalidations).toBe(1); + expect(contextManager.stats.misses).toBe(2); + }); + }); + + describe('getStats', () => { + it('should return current cache statistics', async () => { + await contextManager.getContext('test-id', { test: true }); + const stats = contextManager.getStats(); + + expect(stats.hits).toBe(0); + expect(stats.misses).toBe(1); + expect(stats.invalidations).toBe(0); + expect(stats.size).toBe(1); + expect(stats.maxSize).toBe(10); + expect(stats.ttl).toBe(1000); + }); + }); +}); diff --git a/mcp-server/src/core/context-manager.js b/mcp-server/src/core/context-manager.js index ccf98d02..8f3843c2 100644 --- a/mcp-server/src/core/context-manager.js +++ b/mcp-server/src/core/context-manager.js @@ -15,156 +15,157 @@ import { LRUCache } from 'lru-cache'; */ export class ContextManager { - /** - * Create a new ContextManager instance - * @param {ContextManagerConfig} config - Configuration options - */ - constructor(config = {}) { - this.config = { - maxCacheSize: config.maxCacheSize || 1000, - ttl: config.ttl || 1000 * 60 * 5, // 5 minutes default - maxContextSize: config.maxContextSize || 4000 - }; + /** + * Create a new ContextManager instance + * @param {ContextManagerConfig} config - Configuration options + */ + constructor(config = {}) { + this.config = { + maxCacheSize: config.maxCacheSize || 1000, + ttl: config.ttl || 1000 * 60 * 5, // 5 minutes default + maxContextSize: config.maxContextSize || 4000 + }; - // Initialize LRU cache for context data - this.cache = new LRUCache({ - max: this.config.maxCacheSize, - ttl: this.config.ttl, - updateAgeOnGet: true - }); + // Initialize LRU cache for context data + this.cache = new LRUCache({ + max: this.config.maxCacheSize, + ttl: this.config.ttl, + updateAgeOnGet: true + }); - // Cache statistics - this.stats = { - hits: 0, - misses: 0, - invalidations: 0 - }; - } + // Cache statistics + this.stats = { + hits: 0, + misses: 0, + invalidations: 0 + }; + } - /** - * Create a new context or retrieve from cache - * @param {string} contextId - Unique identifier for the context - * @param {Object} metadata - Additional metadata for the context - * @returns {Object} Context object with metadata - */ - async getContext(contextId, metadata = {}) { - const cacheKey = this._getCacheKey(contextId, metadata); - - // Try to get from cache first - const cached = this.cache.get(cacheKey); - if (cached) { - this.stats.hits++; - return cached; - } + /** + * Create a new context or retrieve from cache + * @param {string} contextId - Unique identifier for the context + * @param {Object} metadata - Additional metadata for the context + * @returns {Object} Context object with metadata + */ + async getContext(contextId, metadata = {}) { + const cacheKey = this._getCacheKey(contextId, metadata); - this.stats.misses++; - - // Create new context if not in cache - const context = { - id: contextId, - metadata: { - ...metadata, - created: new Date().toISOString() - } - }; + // Try to get from cache first + const cached = this.cache.get(cacheKey); + if (cached) { + this.stats.hits++; + return cached; + } - // Cache the new context - this.cache.set(cacheKey, context); - - return context; - } + this.stats.misses++; - /** - * Update an existing context - * @param {string} contextId - Context identifier - * @param {Object} updates - Updates to apply to the context - * @returns {Object} Updated context - */ - async updateContext(contextId, updates) { - const context = await this.getContext(contextId); - - // Apply updates to context - Object.assign(context.metadata, updates); - - // Update cache - const cacheKey = this._getCacheKey(contextId, context.metadata); - this.cache.set(cacheKey, context); - - return context; - } + // Create new context if not in cache + const context = { + id: contextId, + metadata: { + ...metadata, + created: new Date().toISOString() + } + }; - /** - * Invalidate a context in the cache - * @param {string} contextId - Context identifier - * @param {Object} metadata - Metadata used in the cache key - */ - invalidateContext(contextId, metadata = {}) { - const cacheKey = this._getCacheKey(contextId, metadata); - this.cache.delete(cacheKey); - this.stats.invalidations++; - } + // Cache the new context + this.cache.set(cacheKey, context); - /** - * Get cached data associated with a specific key. - * Increments cache hit stats if found. - * @param {string} key - The cache key. - * @returns {any | undefined} The cached data or undefined if not found/expired. - */ - getCachedData(key) { - const cached = this.cache.get(key); - if (cached !== undefined) { // Check for undefined specifically, as null/false might be valid cached values - this.stats.hits++; - return cached; - } - this.stats.misses++; - return undefined; - } + return context; + } - /** - * Set data in the cache with a specific key. - * @param {string} key - The cache key. - * @param {any} data - The data to cache. - */ - setCachedData(key, data) { - this.cache.set(key, data); - } + /** + * Update an existing context + * @param {string} contextId - Context identifier + * @param {Object} updates - Updates to apply to the context + * @returns {Object} Updated context + */ + async updateContext(contextId, updates) { + const context = await this.getContext(contextId); - /** - * Invalidate a specific cache key. - * Increments invalidation stats. - * @param {string} key - The cache key to invalidate. - */ - invalidateCacheKey(key) { - this.cache.delete(key); - this.stats.invalidations++; - } + // Apply updates to context + Object.assign(context.metadata, updates); - /** - * Get cache statistics - * @returns {Object} Cache statistics - */ - getStats() { - return { - hits: this.stats.hits, - misses: this.stats.misses, - invalidations: this.stats.invalidations, - size: this.cache.size, - maxSize: this.config.maxCacheSize, - ttl: this.config.ttl - }; - } + // Update cache + const cacheKey = this._getCacheKey(contextId, context.metadata); + this.cache.set(cacheKey, context); - /** - * Generate a cache key from context ID and metadata - * @private - * @deprecated No longer used for direct cache key generation outside the manager. - * Prefer generating specific keys in calling functions. - */ - _getCacheKey(contextId, metadata) { - // Kept for potential backward compatibility or internal use if needed later. - return `${contextId}:${JSON.stringify(metadata)}`; - } + return context; + } + + /** + * Invalidate a context in the cache + * @param {string} contextId - Context identifier + * @param {Object} metadata - Metadata used in the cache key + */ + invalidateContext(contextId, metadata = {}) { + const cacheKey = this._getCacheKey(contextId, metadata); + this.cache.delete(cacheKey); + this.stats.invalidations++; + } + + /** + * Get cached data associated with a specific key. + * Increments cache hit stats if found. + * @param {string} key - The cache key. + * @returns {any | undefined} The cached data or undefined if not found/expired. + */ + getCachedData(key) { + const cached = this.cache.get(key); + if (cached !== undefined) { + // Check for undefined specifically, as null/false might be valid cached values + this.stats.hits++; + return cached; + } + this.stats.misses++; + return undefined; + } + + /** + * Set data in the cache with a specific key. + * @param {string} key - The cache key. + * @param {any} data - The data to cache. + */ + setCachedData(key, data) { + this.cache.set(key, data); + } + + /** + * Invalidate a specific cache key. + * Increments invalidation stats. + * @param {string} key - The cache key to invalidate. + */ + invalidateCacheKey(key) { + this.cache.delete(key); + this.stats.invalidations++; + } + + /** + * Get cache statistics + * @returns {Object} Cache statistics + */ + getStats() { + return { + hits: this.stats.hits, + misses: this.stats.misses, + invalidations: this.stats.invalidations, + size: this.cache.size, + maxSize: this.config.maxCacheSize, + ttl: this.config.ttl + }; + } + + /** + * Generate a cache key from context ID and metadata + * @private + * @deprecated No longer used for direct cache key generation outside the manager. + * Prefer generating specific keys in calling functions. + */ + _getCacheKey(contextId, metadata) { + // Kept for potential backward compatibility or internal use if needed later. + return `${contextId}:${JSON.stringify(metadata)}`; + } } // Export a singleton instance with default config -export const contextManager = new ContextManager(); \ No newline at end of file +export const contextManager = new ContextManager(); diff --git a/mcp-server/src/core/task-master-core.js b/mcp-server/src/core/task-master-core.js index 472cee77..80d366dc 100644 --- a/mcp-server/src/core/task-master-core.js +++ b/mcp-server/src/core/task-master-core.js @@ -1,7 +1,7 @@ /** * task-master-core.js * Direct function imports from Task Master modules - * + * * This module provides direct access to Task Master core functions * for improved performance and error handling compared to CLI execution. */ @@ -16,9 +16,9 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Import Task Master modules -import { - listTasks, - // We'll import more functions as we continue implementation +import { + listTasks + // We'll import more functions as we continue implementation } from '../../../scripts/modules/task-manager.js'; // Import context manager @@ -33,38 +33,40 @@ import { getCachedOrExecute } from '../tools/utils.js'; // Import the utility he * @throws {Error} - If tasks.json cannot be found. */ function findTasksJsonPath(args, log) { - // Assume projectRoot is already normalized absolute path if passed in args - // Or use getProjectRoot if we decide to centralize that logic - const projectRoot = args.projectRoot || process.cwd(); - log.info(`Searching for tasks.json within project root: ${projectRoot}`); + // Assume projectRoot is already normalized absolute path if passed in args + // Or use getProjectRoot if we decide to centralize that logic + const projectRoot = args.projectRoot || process.cwd(); + log.info(`Searching for tasks.json within project root: ${projectRoot}`); - const possiblePaths = []; + const possiblePaths = []; - // 1. If a file is explicitly provided relative to projectRoot - if (args.file) { - possiblePaths.push(path.resolve(projectRoot, args.file)); - } + // 1. If a file is explicitly provided relative to projectRoot + if (args.file) { + possiblePaths.push(path.resolve(projectRoot, args.file)); + } - // 2. Check the standard locations relative to projectRoot - possiblePaths.push( - path.join(projectRoot, 'tasks.json'), - path.join(projectRoot, 'tasks', 'tasks.json') - ); + // 2. Check the standard locations relative to projectRoot + possiblePaths.push( + path.join(projectRoot, 'tasks.json'), + path.join(projectRoot, 'tasks', 'tasks.json') + ); - log.info(`Checking potential task file paths: ${possiblePaths.join(', ')}`); + log.info(`Checking potential task file paths: ${possiblePaths.join(', ')}`); - // Find the first existing path - for (const p of possiblePaths) { - if (fs.existsSync(p)) { - log.info(`Found tasks file at: ${p}`); - return p; - } - } + // Find the first existing path + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + log.info(`Found tasks file at: ${p}`); + return p; + } + } - // If no file was found, throw an error - const error = new Error(`Tasks file not found in any of the expected locations relative to ${projectRoot}: ${possiblePaths.join(', ')}`); - error.code = 'TASKS_FILE_NOT_FOUND'; - throw error; + // If no file was found, throw an error + const error = new Error( + `Tasks file not found in any of the expected locations relative to ${projectRoot}: ${possiblePaths.join(', ')}` + ); + error.code = 'TASKS_FILE_NOT_FOUND'; + throw error; } /** @@ -75,60 +77,94 @@ function findTasksJsonPath(args, log) { * @returns {Promise} - Task list result { success: boolean, data?: any, error?: { code: string, message: string }, fromCache: boolean }. */ export async function listTasksDirect(args, log) { - let tasksPath; - try { - // Find the tasks path first - needed for cache key and execution - tasksPath = findTasksJsonPath(args, log); - } catch (error) { - if (error.code === 'TASKS_FILE_NOT_FOUND') { - log.error(`Tasks file not found: ${error.message}`); - // Return the error structure expected by the calling tool/handler - return { success: false, error: { code: error.code, message: error.message }, fromCache: false }; - } - log.error(`Unexpected error finding tasks file: ${error.message}`); - // Re-throw for outer catch or return structured error - return { success: false, error: { code: 'FIND_TASKS_PATH_ERROR', message: error.message }, fromCache: false }; - } + let tasksPath; + try { + // Find the tasks path first - needed for cache key and execution + tasksPath = findTasksJsonPath(args, log); + } catch (error) { + if (error.code === 'TASKS_FILE_NOT_FOUND') { + log.error(`Tasks file not found: ${error.message}`); + // Return the error structure expected by the calling tool/handler + return { + success: false, + error: { code: error.code, message: error.message }, + fromCache: false + }; + } + log.error(`Unexpected error finding tasks file: ${error.message}`); + // Re-throw for outer catch or return structured error + return { + success: false, + error: { code: 'FIND_TASKS_PATH_ERROR', message: error.message }, + fromCache: false + }; + } - // Generate cache key *after* finding tasksPath - const statusFilter = args.status || 'all'; - const withSubtasks = args.withSubtasks || false; - const cacheKey = `listTasks:${tasksPath}:${statusFilter}:${withSubtasks}`; - - // Define the action function to be executed on cache miss - const coreListTasksAction = async () => { - try { - log.info(`Executing core listTasks function for path: ${tasksPath}, filter: ${statusFilter}, subtasks: ${withSubtasks}`); - const resultData = listTasks(tasksPath, statusFilter, withSubtasks, 'json'); + // Generate cache key *after* finding tasksPath + const statusFilter = args.status || 'all'; + const withSubtasks = args.withSubtasks || false; + const cacheKey = `listTasks:${tasksPath}:${statusFilter}:${withSubtasks}`; - if (!resultData || !resultData.tasks) { - log.error('Invalid or empty response from listTasks core function'); - return { success: false, error: { code: 'INVALID_CORE_RESPONSE', message: 'Invalid or empty response from listTasks core function' } }; - } - log.info(`Core listTasks function retrieved ${resultData.tasks.length} tasks`); - return { success: true, data: resultData }; + // Define the action function to be executed on cache miss + const coreListTasksAction = async () => { + try { + log.info( + `Executing core listTasks function for path: ${tasksPath}, filter: ${statusFilter}, subtasks: ${withSubtasks}` + ); + const resultData = listTasks( + tasksPath, + statusFilter, + withSubtasks, + 'json' + ); - } catch (error) { - log.error(`Core listTasks function failed: ${error.message}`); - return { success: false, error: { code: 'LIST_TASKS_CORE_ERROR', message: error.message || 'Failed to list tasks' } }; - } - }; + if (!resultData || !resultData.tasks) { + log.error('Invalid or empty response from listTasks core function'); + return { + success: false, + error: { + code: 'INVALID_CORE_RESPONSE', + message: 'Invalid or empty response from listTasks core function' + } + }; + } + log.info( + `Core listTasks function retrieved ${resultData.tasks.length} tasks` + ); + return { success: true, data: resultData }; + } catch (error) { + log.error(`Core listTasks function failed: ${error.message}`); + return { + success: false, + error: { + code: 'LIST_TASKS_CORE_ERROR', + message: error.message || 'Failed to list tasks' + } + }; + } + }; - // Use the caching utility - try { - const result = await getCachedOrExecute({ - cacheKey, - actionFn: coreListTasksAction, - log - }); - log.info(`listTasksDirect completed. From cache: ${result.fromCache}`); - return result; // Returns { success, data/error, fromCache } - } catch(error) { - // Catch unexpected errors from getCachedOrExecute itself (though unlikely) - log.error(`Unexpected error during getCachedOrExecute for listTasks: ${error.message}`); - console.error(error.stack); - return { success: false, error: { code: 'CACHE_UTIL_ERROR', message: error.message }, fromCache: false }; - } + // Use the caching utility + try { + const result = await getCachedOrExecute({ + cacheKey, + actionFn: coreListTasksAction, + log + }); + log.info(`listTasksDirect completed. From cache: ${result.fromCache}`); + return result; // Returns { success, data/error, fromCache } + } catch (error) { + // Catch unexpected errors from getCachedOrExecute itself (though unlikely) + log.error( + `Unexpected error during getCachedOrExecute for listTasks: ${error.message}` + ); + console.error(error.stack); + return { + success: false, + error: { code: 'CACHE_UTIL_ERROR', message: error.message }, + fromCache: false + }; + } } /** @@ -138,30 +174,30 @@ export async function listTasksDirect(args, log) { * @returns {Object} - Cache statistics */ export async function getCacheStatsDirect(args, log) { - try { - log.info('Retrieving cache statistics'); - const stats = contextManager.getStats(); - return { - success: true, - data: stats - }; - } catch (error) { - log.error(`Error getting cache stats: ${error.message}`); - return { - success: false, - error: { - code: 'CACHE_STATS_ERROR', - message: error.message || 'Unknown error occurred' - } - }; - } + try { + log.info('Retrieving cache statistics'); + const stats = contextManager.getStats(); + return { + success: true, + data: stats + }; + } catch (error) { + log.error(`Error getting cache stats: ${error.message}`); + return { + success: false, + error: { + code: 'CACHE_STATS_ERROR', + message: error.message || 'Unknown error occurred' + } + }; + } } /** * Maps Task Master functions to their direct implementation */ export const directFunctions = { - list: listTasksDirect, - cacheStats: getCacheStatsDirect, - // Add more functions as we implement them -}; \ No newline at end of file + list: listTasksDirect, + cacheStats: getCacheStatsDirect + // Add more functions as we implement them +}; diff --git a/mcp-server/src/index.js b/mcp-server/src/index.js index 3fe17b58..3ca9d40d 100644 --- a/mcp-server/src/index.js +++ b/mcp-server/src/index.js @@ -1,10 +1,10 @@ -import { FastMCP } from "fastmcp"; -import path from "path"; -import dotenv from "dotenv"; -import { fileURLToPath } from "url"; -import fs from "fs"; -import logger from "./logger.js"; -import { registerTaskMasterTools } from "./tools/index.js"; +import { FastMCP } from 'fastmcp'; +import path from 'path'; +import dotenv from 'dotenv'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import logger from './logger.js'; +import { registerTaskMasterTools } from './tools/index.js'; // Load environment variables dotenv.config(); @@ -17,70 +17,70 @@ const __dirname = path.dirname(__filename); * Main MCP server class that integrates with Task Master */ class TaskMasterMCPServer { - constructor() { - // Get version from package.json using synchronous fs - const packagePath = path.join(__dirname, "../../package.json"); - const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); + constructor() { + // Get version from package.json using synchronous fs + const packagePath = path.join(__dirname, '../../package.json'); + const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); - this.options = { - name: "Task Master MCP Server", - version: packageJson.version, - }; + this.options = { + name: 'Task Master MCP Server', + version: packageJson.version + }; - this.server = new FastMCP(this.options); - this.initialized = false; + this.server = new FastMCP(this.options); + this.initialized = false; - // this.server.addResource({}); + // this.server.addResource({}); - // this.server.addResourceTemplate({}); + // this.server.addResourceTemplate({}); - // Bind methods - this.init = this.init.bind(this); - this.start = this.start.bind(this); - this.stop = this.stop.bind(this); + // Bind methods + this.init = this.init.bind(this); + this.start = this.start.bind(this); + this.stop = this.stop.bind(this); - // Setup logging - this.logger = logger; - } + // Setup logging + this.logger = logger; + } - /** - * Initialize the MCP server with necessary tools and routes - */ - async init() { - if (this.initialized) return; + /** + * Initialize the MCP server with necessary tools and routes + */ + async init() { + if (this.initialized) return; - // Register Task Master tools - registerTaskMasterTools(this.server); + // Register Task Master tools + registerTaskMasterTools(this.server); - this.initialized = true; + this.initialized = true; - return this; - } + return this; + } - /** - * Start the MCP server - */ - async start() { - if (!this.initialized) { - await this.init(); - } + /** + * Start the MCP server + */ + async start() { + if (!this.initialized) { + await this.init(); + } - // Start the FastMCP server - await this.server.start({ - transportType: "stdio", - }); + // Start the FastMCP server + await this.server.start({ + transportType: 'stdio' + }); - return this; - } + return this; + } - /** - * Stop the MCP server - */ - async stop() { - if (this.server) { - await this.server.stop(); - } - } + /** + * Stop the MCP server + */ + async stop() { + if (this.server) { + await this.server.stop(); + } + } } export default TaskMasterMCPServer; diff --git a/mcp-server/src/logger.js b/mcp-server/src/logger.js index 80c0e55c..cc8f1e28 100644 --- a/mcp-server/src/logger.js +++ b/mcp-server/src/logger.js @@ -1,18 +1,18 @@ -import chalk from "chalk"; +import chalk from 'chalk'; // Define log levels const LOG_LEVELS = { - debug: 0, - info: 1, - warn: 2, - error: 3, - success: 4, + debug: 0, + info: 1, + warn: 2, + error: 3, + success: 4 }; // Get log level from environment or default to info const LOG_LEVEL = process.env.LOG_LEVEL - ? LOG_LEVELS[process.env.LOG_LEVEL.toLowerCase()] - : LOG_LEVELS.info; + ? LOG_LEVELS[process.env.LOG_LEVEL.toLowerCase()] + : LOG_LEVELS.info; /** * Logs a message with the specified level @@ -20,29 +20,29 @@ const LOG_LEVEL = process.env.LOG_LEVEL * @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("✅"), - }; + const icons = { + debug: chalk.gray('🔍'), + info: chalk.blue('ℹ️'), + warn: chalk.yellow('⚠️'), + error: chalk.red('❌'), + success: chalk.green('✅') + }; - if (LOG_LEVELS[level] >= LOG_LEVEL) { - const icon = icons[level] || ""; + if (LOG_LEVELS[level] >= LOG_LEVEL) { + const icon = icons[level] || ''; - if (level === "error") { - console.error(icon, chalk.red(...args)); - } else if (level === "warn") { - console.warn(icon, chalk.yellow(...args)); - } else if (level === "success") { - console.log(icon, chalk.green(...args)); - } else if (level === "info") { - console.log(icon, chalk.blue(...args)); - } else { - console.log(icon, ...args); - } - } + if (level === 'error') { + console.error(icon, chalk.red(...args)); + } else if (level === 'warn') { + console.warn(icon, chalk.yellow(...args)); + } else if (level === 'success') { + console.log(icon, chalk.green(...args)); + } else if (level === 'info') { + console.log(icon, chalk.blue(...args)); + } else { + console.log(icon, ...args); + } + } } /** @@ -51,14 +51,14 @@ function log(level, ...args) { * @returns {Object} Logger object with info, error, debug, warn, and success methods */ export function createLogger() { - return { - debug: (message) => log("debug", message), - info: (message) => log("info", message), - warn: (message) => log("warn", message), - error: (message) => log("error", message), - success: (message) => log("success", message), - log: log, // Also expose the raw log function - }; + return { + debug: (message) => log('debug', message), + info: (message) => log('info', message), + warn: (message) => log('warn', message), + error: (message) => log('error', message), + success: (message) => log('success', message), + log: log // Also expose the raw log function + }; } // Export a default logger instance diff --git a/mcp-server/src/tools/addTask.js b/mcp-server/src/tools/addTask.js index 0b12d9fc..58e519c1 100644 --- a/mcp-server/src/tools/addTask.js +++ b/mcp-server/src/tools/addTask.js @@ -3,64 +3,64 @@ * Tool to add a new task using AI */ -import { z } from "zod"; +import { z } from 'zod'; import { - executeTaskMasterCommand, - createContentResponse, - createErrorResponse, -} from "./utils.js"; + executeTaskMasterCommand, + createContentResponse, + createErrorResponse +} from './utils.js'; /** * Register the addTask tool with the MCP server * @param {FastMCP} server - FastMCP server instance */ export function registerAddTaskTool(server) { - server.addTool({ - name: "addTask", - description: "Add a new task using AI", - parameters: z.object({ - prompt: z.string().describe("Description of the task to add"), - dependencies: z - .string() - .optional() - .describe("Comma-separated list of task IDs this task depends on"), - priority: z - .string() - .optional() - .describe("Task priority (high, medium, low)"), - file: z.string().optional().describe("Path to the tasks file"), - projectRoot: z - .string() - .describe( - "Root directory of the project (default: current working directory)" - ), - }), - execute: async (args, { log }) => { - try { - log.info(`Adding new task: ${args.prompt}`); + server.addTool({ + name: 'addTask', + description: 'Add a new task using AI', + parameters: z.object({ + prompt: z.string().describe('Description of the task to add'), + dependencies: z + .string() + .optional() + .describe('Comma-separated list of task IDs this task depends on'), + priority: z + .string() + .optional() + .describe('Task priority (high, medium, low)'), + file: z.string().optional().describe('Path to the tasks file'), + projectRoot: z + .string() + .describe( + 'Root directory of the project (default: current working directory)' + ) + }), + execute: async (args, { log }) => { + try { + log.info(`Adding new task: ${args.prompt}`); - const cmdArgs = [`--prompt="${args.prompt}"`]; - if (args.dependencies) - cmdArgs.push(`--dependencies=${args.dependencies}`); - if (args.priority) cmdArgs.push(`--priority=${args.priority}`); - if (args.file) cmdArgs.push(`--file=${args.file}`); + const cmdArgs = [`--prompt="${args.prompt}"`]; + if (args.dependencies) + cmdArgs.push(`--dependencies=${args.dependencies}`); + if (args.priority) cmdArgs.push(`--priority=${args.priority}`); + if (args.file) cmdArgs.push(`--file=${args.file}`); - const result = executeTaskMasterCommand( - "add-task", - log, - cmdArgs, - projectRoot - ); + const result = executeTaskMasterCommand( + 'add-task', + log, + cmdArgs, + projectRoot + ); - if (!result.success) { - throw new Error(result.error); - } + if (!result.success) { + throw new Error(result.error); + } - return createContentResponse(result.stdout); - } catch (error) { - log.error(`Error adding task: ${error.message}`); - return createErrorResponse(`Error adding task: ${error.message}`); - } - }, - }); + return createContentResponse(result.stdout); + } catch (error) { + log.error(`Error adding task: ${error.message}`); + return createErrorResponse(`Error adding task: ${error.message}`); + } + } + }); } diff --git a/mcp-server/src/tools/expandTask.js b/mcp-server/src/tools/expandTask.js index ae0b4550..b756dffd 100644 --- a/mcp-server/src/tools/expandTask.js +++ b/mcp-server/src/tools/expandTask.js @@ -3,76 +3,76 @@ * Tool to break down a task into detailed subtasks */ -import { z } from "zod"; +import { z } from 'zod'; import { - executeTaskMasterCommand, - createContentResponse, - createErrorResponse, -} from "./utils.js"; + executeTaskMasterCommand, + createContentResponse, + createErrorResponse +} from './utils.js'; /** * Register the expandTask tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerExpandTaskTool(server) { - server.addTool({ - name: "expandTask", - description: "Break down a task into detailed subtasks", - parameters: z.object({ - id: z.string().describe("Task ID to expand"), - num: z.number().optional().describe("Number of subtasks to generate"), - research: z - .boolean() - .optional() - .describe( - "Enable Perplexity AI for research-backed subtask generation" - ), - prompt: z - .string() - .optional() - .describe("Additional context to guide subtask generation"), - force: z - .boolean() - .optional() - .describe( - "Force regeneration of subtasks for tasks that already have them" - ), - file: z.string().optional().describe("Path to the tasks file"), - projectRoot: z - .string() - .describe( - "Root directory of the project (default: current working directory)" - ), - }), - execute: async (args, { log }) => { - try { - log.info(`Expanding task ${args.id}`); + server.addTool({ + name: 'expandTask', + description: 'Break down a task into detailed subtasks', + parameters: z.object({ + id: z.string().describe('Task ID to expand'), + num: z.number().optional().describe('Number of subtasks to generate'), + research: z + .boolean() + .optional() + .describe( + 'Enable Perplexity AI for research-backed subtask generation' + ), + prompt: z + .string() + .optional() + .describe('Additional context to guide subtask generation'), + force: z + .boolean() + .optional() + .describe( + 'Force regeneration of subtasks for tasks that already have them' + ), + file: z.string().optional().describe('Path to the tasks file'), + projectRoot: z + .string() + .describe( + 'Root directory of the project (default: current working directory)' + ) + }), + execute: async (args, { log }) => { + try { + log.info(`Expanding task ${args.id}`); - const cmdArgs = [`--id=${args.id}`]; - if (args.num) cmdArgs.push(`--num=${args.num}`); - if (args.research) cmdArgs.push("--research"); - if (args.prompt) cmdArgs.push(`--prompt="${args.prompt}"`); - if (args.force) cmdArgs.push("--force"); - if (args.file) cmdArgs.push(`--file=${args.file}`); + const cmdArgs = [`--id=${args.id}`]; + if (args.num) cmdArgs.push(`--num=${args.num}`); + if (args.research) cmdArgs.push('--research'); + if (args.prompt) cmdArgs.push(`--prompt="${args.prompt}"`); + if (args.force) cmdArgs.push('--force'); + if (args.file) cmdArgs.push(`--file=${args.file}`); - const projectRoot = args.projectRoot; + const projectRoot = args.projectRoot; - const result = executeTaskMasterCommand( - "expand", - log, - cmdArgs, - projectRoot - ); + const result = executeTaskMasterCommand( + 'expand', + log, + cmdArgs, + projectRoot + ); - if (!result.success) { - throw new Error(result.error); - } + if (!result.success) { + throw new Error(result.error); + } - return createContentResponse(result.stdout); - } catch (error) { - log.error(`Error expanding task: ${error.message}`); - return createErrorResponse(`Error expanding task: ${error.message}`); - } - }, - }); + return createContentResponse(result.stdout); + } catch (error) { + log.error(`Error expanding task: ${error.message}`); + return createErrorResponse(`Error expanding task: ${error.message}`); + } + } + }); } diff --git a/mcp-server/src/tools/index.js b/mcp-server/src/tools/index.js index 97d47438..96bb0e14 100644 --- a/mcp-server/src/tools/index.js +++ b/mcp-server/src/tools/index.js @@ -3,27 +3,27 @@ * Export all Task Master CLI tools for MCP server */ -import logger from "../logger.js"; -import { registerListTasksTool } from "./listTasks.js"; -import { registerShowTaskTool } from "./showTask.js"; -import { registerSetTaskStatusTool } from "./setTaskStatus.js"; -import { registerExpandTaskTool } from "./expandTask.js"; -import { registerNextTaskTool } from "./nextTask.js"; -import { registerAddTaskTool } from "./addTask.js"; +import logger from '../logger.js'; +import { registerListTasksTool } from './listTasks.js'; +import { registerShowTaskTool } from './showTask.js'; +import { registerSetTaskStatusTool } from './setTaskStatus.js'; +import { registerExpandTaskTool } from './expandTask.js'; +import { registerNextTaskTool } from './nextTask.js'; +import { registerAddTaskTool } from './addTask.js'; /** * Register all Task Master tools with the MCP server * @param {Object} server - FastMCP server instance */ export function registerTaskMasterTools(server) { - registerListTasksTool(server); - registerShowTaskTool(server); - registerSetTaskStatusTool(server); - registerExpandTaskTool(server); - registerNextTaskTool(server); - registerAddTaskTool(server); + registerListTasksTool(server); + registerShowTaskTool(server); + registerSetTaskStatusTool(server); + registerExpandTaskTool(server); + registerNextTaskTool(server); + registerAddTaskTool(server); } export default { - registerTaskMasterTools, + registerTaskMasterTools }; diff --git a/mcp-server/src/tools/listTasks.js b/mcp-server/src/tools/listTasks.js index 35446ba2..c7636ff3 100644 --- a/mcp-server/src/tools/listTasks.js +++ b/mcp-server/src/tools/listTasks.js @@ -3,51 +3,50 @@ * Tool to list all tasks from Task Master */ -import { z } from "zod"; -import { - createErrorResponse, - handleApiResult -} from "./utils.js"; -import { listTasksDirect } from "../core/task-master-core.js"; +import { z } from 'zod'; +import { createErrorResponse, handleApiResult } from './utils.js'; +import { listTasksDirect } from '../core/task-master-core.js'; /** * Register the listTasks tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerListTasksTool(server) { - server.addTool({ - name: "listTasks", - description: "List all tasks from Task Master", - parameters: z.object({ - status: z.string().optional().describe("Filter tasks by status"), - withSubtasks: z - .boolean() - .optional() - .describe("Include subtasks in the response"), - file: z.string().optional().describe("Path to the tasks file"), - projectRoot: z - .string() - .optional() - .describe( - "Root directory of the project (default: current working directory)" - ), - }), - execute: async (args, { log }) => { - try { - log.info(`Listing tasks with filters: ${JSON.stringify(args)}`); - - // Call core function - args contains projectRoot which is handled internally - const result = await listTasksDirect(args, log); - - // Log result and use handleApiResult utility - log.info(`Retrieved ${result.success ? (result.data?.tasks?.length || 0) : 0} tasks`); - return handleApiResult(result, log, 'Error listing tasks'); - } catch (error) { - log.error(`Error listing tasks: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); + server.addTool({ + name: 'listTasks', + description: 'List all tasks from Task Master', + parameters: z.object({ + status: z.string().optional().describe('Filter tasks by status'), + withSubtasks: z + .boolean() + .optional() + .describe('Include subtasks in the response'), + file: z.string().optional().describe('Path to the tasks file'), + projectRoot: z + .string() + .optional() + .describe( + 'Root directory of the project (default: current working directory)' + ) + }), + execute: async (args, { log }) => { + try { + log.info(`Listing tasks with filters: ${JSON.stringify(args)}`); + + // Call core function - args contains projectRoot which is handled internally + const result = await listTasksDirect(args, log); + + // Log result and use handleApiResult utility + log.info( + `Retrieved ${result.success ? result.data?.tasks?.length || 0 : 0} tasks` + ); + return handleApiResult(result, log, 'Error listing tasks'); + } catch (error) { + log.error(`Error listing tasks: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); } // We no longer need the formatTasksResponse function as we're returning raw JSON data diff --git a/mcp-server/src/tools/nextTask.js b/mcp-server/src/tools/nextTask.js index 729c5fec..f1f368ff 100644 --- a/mcp-server/src/tools/nextTask.js +++ b/mcp-server/src/tools/nextTask.js @@ -3,55 +3,55 @@ * Tool to show the next task to work on based on dependencies and status */ -import { z } from "zod"; +import { z } from 'zod'; import { - executeTaskMasterCommand, - createContentResponse, - createErrorResponse, -} from "./utils.js"; + executeTaskMasterCommand, + createContentResponse, + createErrorResponse +} from './utils.js'; /** * Register the nextTask tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerNextTaskTool(server) { - server.addTool({ - name: "nextTask", - description: - "Show the next task to work on based on dependencies and status", - parameters: z.object({ - file: z.string().optional().describe("Path to the tasks file"), - projectRoot: z - .string() - .describe( - "Root directory of the project (default: current working directory)" - ), - }), - execute: async (args, { log }) => { - try { - log.info(`Finding next task to work on`); + server.addTool({ + name: 'nextTask', + description: + 'Show the next task to work on based on dependencies and status', + parameters: z.object({ + file: z.string().optional().describe('Path to the tasks file'), + projectRoot: z + .string() + .describe( + 'Root directory of the project (default: current working directory)' + ) + }), + execute: async (args, { log }) => { + try { + log.info(`Finding next task to work on`); - const cmdArgs = []; - if (args.file) cmdArgs.push(`--file=${args.file}`); + const cmdArgs = []; + if (args.file) cmdArgs.push(`--file=${args.file}`); - const projectRoot = args.projectRoot; + const projectRoot = args.projectRoot; - const result = executeTaskMasterCommand( - "next", - log, - cmdArgs, - projectRoot - ); + const result = executeTaskMasterCommand( + 'next', + log, + cmdArgs, + projectRoot + ); - if (!result.success) { - throw new Error(result.error); - } + if (!result.success) { + throw new Error(result.error); + } - return createContentResponse(result.stdout); - } catch (error) { - log.error(`Error finding next task: ${error.message}`); - return createErrorResponse(`Error finding next task: ${error.message}`); - } - }, - }); + return createContentResponse(result.stdout); + } catch (error) { + log.error(`Error finding next task: ${error.message}`); + return createErrorResponse(`Error finding next task: ${error.message}`); + } + } + }); } diff --git a/mcp-server/src/tools/setTaskStatus.js b/mcp-server/src/tools/setTaskStatus.js index d2c0b2c1..cea84483 100644 --- a/mcp-server/src/tools/setTaskStatus.js +++ b/mcp-server/src/tools/setTaskStatus.js @@ -3,62 +3,62 @@ * Tool to set the status of a task */ -import { z } from "zod"; +import { z } from 'zod'; import { - executeTaskMasterCommand, - createContentResponse, - createErrorResponse, -} from "./utils.js"; + executeTaskMasterCommand, + createContentResponse, + createErrorResponse +} from './utils.js'; /** * Register the setTaskStatus tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerSetTaskStatusTool(server) { - server.addTool({ - name: "setTaskStatus", - description: "Set the status of a task", - parameters: z.object({ - id: z - .string() - .describe("Task ID (can be comma-separated for multiple tasks)"), - status: z - .string() - .describe("New status (todo, in-progress, review, done)"), - file: z.string().optional().describe("Path to the tasks file"), - projectRoot: z - .string() - .describe( - "Root directory of the project (default: current working directory)" - ), - }), - execute: async (args, { log }) => { - try { - log.info(`Setting status of task(s) ${args.id} to: ${args.status}`); + server.addTool({ + name: 'setTaskStatus', + description: 'Set the status of a task', + parameters: z.object({ + id: z + .string() + .describe('Task ID (can be comma-separated for multiple tasks)'), + status: z + .string() + .describe('New status (todo, in-progress, review, done)'), + file: z.string().optional().describe('Path to the tasks file'), + projectRoot: z + .string() + .describe( + 'Root directory of the project (default: current working directory)' + ) + }), + execute: async (args, { log }) => { + try { + log.info(`Setting status of task(s) ${args.id} to: ${args.status}`); - const cmdArgs = [`--id=${args.id}`, `--status=${args.status}`]; - if (args.file) cmdArgs.push(`--file=${args.file}`); + const cmdArgs = [`--id=${args.id}`, `--status=${args.status}`]; + if (args.file) cmdArgs.push(`--file=${args.file}`); - const projectRoot = args.projectRoot; + const projectRoot = args.projectRoot; - const result = executeTaskMasterCommand( - "set-status", - log, - cmdArgs, - projectRoot - ); + const result = executeTaskMasterCommand( + 'set-status', + log, + cmdArgs, + projectRoot + ); - if (!result.success) { - throw new Error(result.error); - } + if (!result.success) { + throw new Error(result.error); + } - return createContentResponse(result.stdout); - } catch (error) { - log.error(`Error setting task status: ${error.message}`); - return createErrorResponse( - `Error setting task status: ${error.message}` - ); - } - }, - }); + return createContentResponse(result.stdout); + } catch (error) { + log.error(`Error setting task status: ${error.message}`); + return createErrorResponse( + `Error setting task status: ${error.message}` + ); + } + } + }); } diff --git a/mcp-server/src/tools/showTask.js b/mcp-server/src/tools/showTask.js index 33e4da79..47245fdb 100644 --- a/mcp-server/src/tools/showTask.js +++ b/mcp-server/src/tools/showTask.js @@ -3,76 +3,80 @@ * Tool to show detailed information about a specific task */ -import { z } from "zod"; +import { z } from 'zod'; import { - executeTaskMasterCommand, - createErrorResponse, - handleApiResult -} from "./utils.js"; + executeTaskMasterCommand, + createErrorResponse, + handleApiResult +} from './utils.js'; /** * Register the showTask tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerShowTaskTool(server) { - server.addTool({ - name: "showTask", - description: "Show detailed information about a specific task", - parameters: z.object({ - id: z.string().describe("Task ID to show"), - file: z.string().optional().describe("Path to the tasks file"), - projectRoot: z - .string() - .optional() - .describe( - "Root directory of the project (default: current working directory)" - ), - }), - execute: async (args, { log }) => { - try { - log.info(`Showing task details for ID: ${args.id}`); + server.addTool({ + name: 'showTask', + description: 'Show detailed information about a specific task', + parameters: z.object({ + id: z.string().describe('Task ID to show'), + file: z.string().optional().describe('Path to the tasks file'), + projectRoot: z + .string() + .optional() + .describe( + 'Root directory of the project (default: current working directory)' + ) + }), + execute: async (args, { log }) => { + try { + log.info(`Showing task details for ID: ${args.id}`); - // Prepare arguments for CLI command - const cmdArgs = [`--id=${args.id}`]; - if (args.file) cmdArgs.push(`--file=${args.file}`); + // Prepare arguments for CLI command + const cmdArgs = [`--id=${args.id}`]; + if (args.file) cmdArgs.push(`--file=${args.file}`); - // Execute the command - function now handles project root internally - const result = executeTaskMasterCommand( - "show", - log, - cmdArgs, - args.projectRoot // Pass raw project root, function will normalize it - ); + // Execute the command - function now handles project root internally + const result = executeTaskMasterCommand( + 'show', + log, + cmdArgs, + args.projectRoot // Pass raw project root, function will normalize it + ); - // Process CLI result into API result format for handleApiResult - if (result.success) { - try { - // Try to parse response as JSON - const data = JSON.parse(result.stdout); - // Return equivalent of a successful API call with data - return handleApiResult({ success: true, data }, log, 'Error showing task'); - } catch (e) { - // If parsing fails, still return success but with raw string data - return handleApiResult( - { success: true, data: result.stdout }, - log, - 'Error showing task', - // Skip data processing for string data - null - ); - } - } else { - // Return equivalent of a failed API call - return handleApiResult( - { success: false, error: { message: result.error } }, - log, - 'Error showing task' - ); - } - } catch (error) { - log.error(`Error showing task: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); + // Process CLI result into API result format for handleApiResult + if (result.success) { + try { + // Try to parse response as JSON + const data = JSON.parse(result.stdout); + // Return equivalent of a successful API call with data + return handleApiResult( + { success: true, data }, + log, + 'Error showing task' + ); + } catch (e) { + // If parsing fails, still return success but with raw string data + return handleApiResult( + { success: true, data: result.stdout }, + log, + 'Error showing task', + // Skip data processing for string data + null + ); + } + } else { + // Return equivalent of a failed API call + return handleApiResult( + { success: false, error: { message: result.error } }, + log, + 'Error showing task' + ); + } + } catch (error) { + log.error(`Error showing task: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); } diff --git a/mcp-server/src/tools/utils.js b/mcp-server/src/tools/utils.js index f448e4e8..587b19bc 100644 --- a/mcp-server/src/tools/utils.js +++ b/mcp-server/src/tools/utils.js @@ -3,27 +3,27 @@ * Utility functions for Task Master CLI integration */ -import { spawnSync } from "child_process"; -import path from "path"; +import { spawnSync } from 'child_process'; +import path from 'path'; import { contextManager } from '../core/context-manager.js'; // Import the singleton /** - * Get normalized project root path + * Get normalized project root path * @param {string|undefined} projectRootRaw - Raw project root from arguments * @param {Object} log - Logger object * @returns {string} - Normalized absolute path to project root */ export function getProjectRoot(projectRootRaw, log) { - // Make sure projectRoot is set - const rootPath = projectRootRaw || process.cwd(); - - // Ensure projectRoot is absolute - const projectRoot = path.isAbsolute(rootPath) - ? rootPath - : path.resolve(process.cwd(), rootPath); - - log.info(`Using project root: ${projectRoot}`); - return projectRoot; + // Make sure projectRoot is set + const rootPath = projectRootRaw || process.cwd(); + + // Ensure projectRoot is absolute + const projectRoot = path.isAbsolute(rootPath) + ? rootPath + : path.resolve(process.cwd(), rootPath); + + log.info(`Using project root: ${projectRoot}`); + return projectRoot; } /** @@ -34,28 +34,35 @@ export function getProjectRoot(projectRootRaw, log) { * @param {Function} processFunction - Optional function to process successful result data * @returns {Object} - Standardized MCP response object */ -export function handleApiResult(result, log, errorPrefix = 'API error', processFunction = processMCPResponseData) { - if (!result.success) { - const errorMsg = result.error?.message || `Unknown ${errorPrefix}`; - // Include cache status in error logs - log.error(`${errorPrefix}: ${errorMsg}. From cache: ${result.fromCache}`); // Keep logging cache status on error - return createErrorResponse(errorMsg); - } - - // Process the result data if needed - const processedData = processFunction ? processFunction(result.data) : result.data; - - // Log success including cache status - log.info(`Successfully completed operation. From cache: ${result.fromCache}`); // Add success log with cache status +export function handleApiResult( + result, + log, + errorPrefix = 'API error', + processFunction = processMCPResponseData +) { + if (!result.success) { + const errorMsg = result.error?.message || `Unknown ${errorPrefix}`; + // Include cache status in error logs + log.error(`${errorPrefix}: ${errorMsg}. From cache: ${result.fromCache}`); // Keep logging cache status on error + return createErrorResponse(errorMsg); + } - // Create the response payload including the fromCache flag - const responsePayload = { - fromCache: result.fromCache, // Get the flag from the original 'result' - data: processedData // Nest the processed data under a 'data' key - }; - - // Pass this combined payload to createContentResponse - return createContentResponse(responsePayload); + // Process the result data if needed + const processedData = processFunction + ? processFunction(result.data) + : result.data; + + // Log success including cache status + log.info(`Successfully completed operation. From cache: ${result.fromCache}`); // Add success log with cache status + + // Create the response payload including the fromCache flag + const responsePayload = { + fromCache: result.fromCache, // Get the flag from the original 'result' + data: processedData // Nest the processed data under a 'data' key + }; + + // Pass this combined payload to createContentResponse + return createContentResponse(responsePayload); } /** @@ -67,68 +74,68 @@ export function handleApiResult(result, log, errorPrefix = 'API error', processF * @returns {Object} - The result of the command execution */ export function executeTaskMasterCommand( - command, - log, - args = [], - projectRootRaw = null + command, + log, + args = [], + projectRootRaw = null ) { - try { - // Normalize project root internally using the getProjectRoot utility - const cwd = getProjectRoot(projectRootRaw, log); + try { + // Normalize project root internally using the getProjectRoot utility + const cwd = getProjectRoot(projectRootRaw, log); - log.info( - `Executing task-master ${command} with args: ${JSON.stringify( - args - )} in directory: ${cwd}` - ); + log.info( + `Executing task-master ${command} with args: ${JSON.stringify( + args + )} in directory: ${cwd}` + ); - // Prepare full arguments array - const fullArgs = [command, ...args]; + // Prepare full arguments array + const fullArgs = [command, ...args]; - // Common options for spawn - const spawnOptions = { - encoding: "utf8", - cwd: cwd, - }; + // Common options for spawn + const spawnOptions = { + encoding: 'utf8', + cwd: cwd + }; - // Execute the command using the global task-master CLI or local script - // Try the global CLI first - let result = spawnSync("task-master", fullArgs, spawnOptions); + // Execute the command using the global task-master CLI or local script + // Try the global CLI first + let result = spawnSync('task-master', fullArgs, spawnOptions); - // If global CLI is not available, try fallback to the local script - if (result.error && result.error.code === "ENOENT") { - log.info("Global task-master not found, falling back to local script"); - result = spawnSync("node", ["scripts/dev.js", ...fullArgs], spawnOptions); - } + // If global CLI is not available, try fallback to the local script + if (result.error && result.error.code === 'ENOENT') { + log.info('Global task-master not found, falling back to local script'); + result = spawnSync('node', ['scripts/dev.js', ...fullArgs], spawnOptions); + } - if (result.error) { - throw new Error(`Command execution error: ${result.error.message}`); - } + if (result.error) { + throw new Error(`Command execution error: ${result.error.message}`); + } - if (result.status !== 0) { - // Improve error handling by combining stderr and stdout if stderr is empty - const errorOutput = result.stderr - ? result.stderr.trim() - : result.stdout - ? result.stdout.trim() - : "Unknown error"; - throw new Error( - `Command failed with exit code ${result.status}: ${errorOutput}` - ); - } + if (result.status !== 0) { + // Improve error handling by combining stderr and stdout if stderr is empty + const errorOutput = result.stderr + ? result.stderr.trim() + : result.stdout + ? result.stdout.trim() + : 'Unknown error'; + throw new Error( + `Command failed with exit code ${result.status}: ${errorOutput}` + ); + } - return { - success: true, - stdout: result.stdout, - stderr: result.stderr, - }; - } catch (error) { - log.error(`Error executing task-master command: ${error.message}`); - return { - success: false, - error: error.message, - }; - } + return { + success: true, + stdout: result.stdout, + stderr: result.stderr + }; + } catch (error) { + log.error(`Error executing task-master command: ${error.message}`); + return { + success: false, + error: error.message + }; + } } /** @@ -144,40 +151,44 @@ export function executeTaskMasterCommand( * Format: { success: boolean, data?: any, error?: { code: string, message: string }, fromCache: boolean } */ export async function getCachedOrExecute({ cacheKey, actionFn, log }) { - // Check cache first - const cachedResult = contextManager.getCachedData(cacheKey); - - if (cachedResult !== undefined) { - log.info(`Cache hit for key: ${cacheKey}`); - // Return the cached data in the same structure as a fresh result - return { - ...cachedResult, // Spread the cached result to maintain its structure - fromCache: true // Just add the fromCache flag - }; - } + // Check cache first + const cachedResult = contextManager.getCachedData(cacheKey); - log.info(`Cache miss for key: ${cacheKey}. Executing action function.`); - - // Execute the action function if cache missed - const result = await actionFn(); - - // If the action was successful, cache the result (but without fromCache flag) - if (result.success && result.data !== undefined) { - log.info(`Action successful. Caching result for key: ${cacheKey}`); - // Cache the entire result structure (minus the fromCache flag) - const { fromCache, ...resultToCache } = result; - contextManager.setCachedData(cacheKey, resultToCache); - } else if (!result.success) { - log.warn(`Action failed for cache key ${cacheKey}. Result not cached. Error: ${result.error?.message}`); - } else { - log.warn(`Action for cache key ${cacheKey} succeeded but returned no data. Result not cached.`); - } - - // Return the fresh result, indicating it wasn't from cache - return { - ...result, - fromCache: false - }; + if (cachedResult !== undefined) { + log.info(`Cache hit for key: ${cacheKey}`); + // Return the cached data in the same structure as a fresh result + return { + ...cachedResult, // Spread the cached result to maintain its structure + fromCache: true // Just add the fromCache flag + }; + } + + log.info(`Cache miss for key: ${cacheKey}. Executing action function.`); + + // Execute the action function if cache missed + const result = await actionFn(); + + // If the action was successful, cache the result (but without fromCache flag) + if (result.success && result.data !== undefined) { + log.info(`Action successful. Caching result for key: ${cacheKey}`); + // Cache the entire result structure (minus the fromCache flag) + const { fromCache, ...resultToCache } = result; + contextManager.setCachedData(cacheKey, resultToCache); + } else if (!result.success) { + log.warn( + `Action failed for cache key ${cacheKey}. Result not cached. Error: ${result.error?.message}` + ); + } else { + log.warn( + `Action for cache key ${cacheKey} succeeded but returned no data. Result not cached.` + ); + } + + // Return the fresh result, indicating it wasn't from cache + return { + ...result, + fromCache: false + }; } /** @@ -194,79 +205,92 @@ export async function getCachedOrExecute({ cacheKey, actionFn, log }) { * @returns {Promise} - Standardized response for FastMCP. */ export async function executeMCPToolAction({ - actionFn, - args, - log, - actionName, - cacheKeyGenerator, // Note: We decided not to use this for listTasks for now - processResult = processMCPResponseData + actionFn, + args, + log, + actionName, + cacheKeyGenerator, // Note: We decided not to use this for listTasks for now + processResult = processMCPResponseData }) { - try { - // Log the action start - log.info(`${actionName} with args: ${JSON.stringify(args)}`); + try { + // Log the action start + log.info(`${actionName} with args: ${JSON.stringify(args)}`); - // Normalize project root path - common to almost all tools - const projectRootRaw = args.projectRoot || process.cwd(); - const projectRoot = path.isAbsolute(projectRootRaw) - ? projectRootRaw - : path.resolve(process.cwd(), projectRootRaw); + // Normalize project root path - common to almost all tools + const projectRootRaw = args.projectRoot || process.cwd(); + const projectRoot = path.isAbsolute(projectRootRaw) + ? projectRootRaw + : path.resolve(process.cwd(), projectRootRaw); - log.info(`Using project root: ${projectRoot}`); - const executionArgs = { ...args, projectRoot }; + log.info(`Using project root: ${projectRoot}`); + const executionArgs = { ...args, projectRoot }; - let result; - const cacheKey = cacheKeyGenerator ? cacheKeyGenerator(executionArgs) : null; + let result; + const cacheKey = cacheKeyGenerator + ? cacheKeyGenerator(executionArgs) + : null; - if (cacheKey) { - // Use caching utility - log.info(`Caching enabled for ${actionName} with key: ${cacheKey}`); - const cacheWrappedAction = async () => await actionFn(executionArgs, log); - result = await getCachedOrExecute({ - cacheKey, - actionFn: cacheWrappedAction, - log - }); - } else { - // Execute directly without caching - log.info(`Caching disabled for ${actionName}. Executing directly.`); - // We need to ensure the result from actionFn has a fromCache field - // Let's assume actionFn now consistently returns { success, data/error, fromCache } - // The current listTasksDirect does this if it calls getCachedOrExecute internally. - result = await actionFn(executionArgs, log); - // If the action function itself doesn't determine caching (like our original listTasksDirect refactor attempt), - // we'd set it here: - // result.fromCache = false; - } + if (cacheKey) { + // Use caching utility + log.info(`Caching enabled for ${actionName} with key: ${cacheKey}`); + const cacheWrappedAction = async () => await actionFn(executionArgs, log); + result = await getCachedOrExecute({ + cacheKey, + actionFn: cacheWrappedAction, + log + }); + } else { + // Execute directly without caching + log.info(`Caching disabled for ${actionName}. Executing directly.`); + // We need to ensure the result from actionFn has a fromCache field + // Let's assume actionFn now consistently returns { success, data/error, fromCache } + // The current listTasksDirect does this if it calls getCachedOrExecute internally. + result = await actionFn(executionArgs, log); + // If the action function itself doesn't determine caching (like our original listTasksDirect refactor attempt), + // we'd set it here: + // result.fromCache = false; + } - // Handle error case - if (!result.success) { - const errorMsg = result.error?.message || `Unknown error during ${actionName.toLowerCase()}`; - // Include fromCache in error logs too, might be useful - log.error(`Error during ${actionName.toLowerCase()}: ${errorMsg}. From cache: ${result.fromCache}`); - return createErrorResponse(errorMsg); - } + // Handle error case + if (!result.success) { + const errorMsg = + result.error?.message || + `Unknown error during ${actionName.toLowerCase()}`; + // Include fromCache in error logs too, might be useful + log.error( + `Error during ${actionName.toLowerCase()}: ${errorMsg}. From cache: ${result.fromCache}` + ); + return createErrorResponse(errorMsg); + } - // Log success - log.info(`Successfully completed ${actionName.toLowerCase()}. From cache: ${result.fromCache}`); + // Log success + log.info( + `Successfully completed ${actionName.toLowerCase()}. From cache: ${result.fromCache}` + ); - // Process the result data if needed - const processedData = processResult ? processResult(result.data) : result.data; + // Process the result data if needed + const processedData = processResult + ? processResult(result.data) + : result.data; - // Create a new object that includes both the processed data and the fromCache flag - const responsePayload = { - fromCache: result.fromCache, // Include the flag here - data: processedData // Embed the actual data under a 'data' key - }; - - // Pass this combined payload to createContentResponse - return createContentResponse(responsePayload); + // Create a new object that includes both the processed data and the fromCache flag + const responsePayload = { + fromCache: result.fromCache, // Include the flag here + data: processedData // Embed the actual data under a 'data' key + }; - } catch (error) { - // Handle unexpected errors during the execution wrapper itself - log.error(`Unexpected error during ${actionName.toLowerCase()} execution wrapper: ${error.message}`); - console.error(error.stack); // Log stack for debugging wrapper errors - return createErrorResponse(`Internal server error during ${actionName.toLowerCase()}: ${error.message}`); - } + // Pass this combined payload to createContentResponse + return createContentResponse(responsePayload); + } catch (error) { + // Handle unexpected errors during the execution wrapper itself + log.error( + `Unexpected error during ${actionName.toLowerCase()} execution wrapper: ${error.message}` + ); + console.error(error.stack); // Log stack for debugging wrapper errors + return createErrorResponse( + `Internal server error during ${actionName.toLowerCase()}: ${error.message}` + ); + } } /** @@ -276,56 +300,68 @@ export async function executeMCPToolAction({ * @param {string[]} fieldsToRemove - An array of field names to remove. * @returns {Object|Array} - The processed data with specified fields removed. */ -export function processMCPResponseData(taskOrData, fieldsToRemove = ['details', 'testStrategy']) { - if (!taskOrData) { - return taskOrData; - } +export function processMCPResponseData( + taskOrData, + fieldsToRemove = ['details', 'testStrategy'] +) { + if (!taskOrData) { + return taskOrData; + } - // Helper function to process a single task object - const processSingleTask = (task) => { - if (typeof task !== 'object' || task === null) { - return task; - } - - const processedTask = { ...task }; - - // Remove specified fields from the task - fieldsToRemove.forEach(field => { - delete processedTask[field]; - }); + // Helper function to process a single task object + const processSingleTask = (task) => { + if (typeof task !== 'object' || task === null) { + return task; + } - // Recursively process subtasks if they exist and are an array - if (processedTask.subtasks && Array.isArray(processedTask.subtasks)) { - // Use processArrayOfTasks to handle the subtasks array - processedTask.subtasks = processArrayOfTasks(processedTask.subtasks); - } - - return processedTask; - }; - - // Helper function to process an array of tasks - const processArrayOfTasks = (tasks) => { - return tasks.map(processSingleTask); - }; + const processedTask = { ...task }; - // Check if the input is a data structure containing a 'tasks' array (like from listTasks) - if (typeof taskOrData === 'object' && taskOrData !== null && Array.isArray(taskOrData.tasks)) { - return { - ...taskOrData, // Keep other potential fields like 'stats', 'filter' - tasks: processArrayOfTasks(taskOrData.tasks), - }; - } - // Check if the input is likely a single task object (add more checks if needed) - else if (typeof taskOrData === 'object' && taskOrData !== null && 'id' in taskOrData && 'title' in taskOrData) { - return processSingleTask(taskOrData); - } - // Check if the input is an array of tasks directly (less common but possible) - else if (Array.isArray(taskOrData)) { - return processArrayOfTasks(taskOrData); - } - - // If it doesn't match known task structures, return it as is - return taskOrData; + // Remove specified fields from the task + fieldsToRemove.forEach((field) => { + delete processedTask[field]; + }); + + // Recursively process subtasks if they exist and are an array + if (processedTask.subtasks && Array.isArray(processedTask.subtasks)) { + // Use processArrayOfTasks to handle the subtasks array + processedTask.subtasks = processArrayOfTasks(processedTask.subtasks); + } + + return processedTask; + }; + + // Helper function to process an array of tasks + const processArrayOfTasks = (tasks) => { + return tasks.map(processSingleTask); + }; + + // Check if the input is a data structure containing a 'tasks' array (like from listTasks) + if ( + typeof taskOrData === 'object' && + taskOrData !== null && + Array.isArray(taskOrData.tasks) + ) { + return { + ...taskOrData, // Keep other potential fields like 'stats', 'filter' + tasks: processArrayOfTasks(taskOrData.tasks) + }; + } + // Check if the input is likely a single task object (add more checks if needed) + else if ( + typeof taskOrData === 'object' && + taskOrData !== null && + 'id' in taskOrData && + 'title' in taskOrData + ) { + return processSingleTask(taskOrData); + } + // Check if the input is an array of tasks directly (less common but possible) + else if (Array.isArray(taskOrData)) { + return processArrayOfTasks(taskOrData); + } + + // If it doesn't match known task structures, return it as is + return taskOrData; } /** @@ -334,19 +370,20 @@ export function processMCPResponseData(taskOrData, fieldsToRemove = ['details', * @returns {Object} - Content response object in FastMCP format */ export function createContentResponse(content) { - // FastMCP requires text type, so we format objects as JSON strings - return { - content: [ - { - type: "text", - text: typeof content === 'object' ? - // Format JSON nicely with indentation - JSON.stringify(content, null, 2) : - // Keep other content types as-is - String(content) - } - ] - }; + // FastMCP requires text type, so we format objects as JSON strings + return { + content: [ + { + type: 'text', + text: + typeof content === 'object' + ? // Format JSON nicely with indentation + JSON.stringify(content, null, 2) + : // Keep other content types as-is + String(content) + } + ] + }; } /** @@ -355,13 +392,13 @@ export function createContentResponse(content) { * @returns {Object} - Error content response object in FastMCP format */ export function createErrorResponse(errorMessage) { - return { - content: [ - { - type: "text", - text: `Error: ${errorMessage}` - } - ], - isError: true - }; + return { + content: [ + { + type: 'text', + text: `Error: ${errorMessage}` + } + ], + isError: true + }; } diff --git a/output.json b/output.json index 12181324..f8f3de13 100644 --- a/output.json +++ b/output.json @@ -1,6 +1,6 @@ { - "key": "value", - "nested": { - "prop": true - } -} \ No newline at end of file + "key": "value", + "nested": { + "prop": true + } +} diff --git a/package.json b/package.json index 048dfe9f..b4007c30 100644 --- a/package.json +++ b/package.json @@ -1,92 +1,94 @@ { - "name": "task-master-ai", - "version": "0.10.1", - "description": "A task management system for ambitious AI-driven development that doesn't overwhelm and confuse Cursor.", - "main": "index.js", - "type": "module", - "bin": { - "task-master": "bin/task-master.js", - "task-master-init": "bin/task-master-init.js", - "task-master-mcp-server": "mcp-server/server.js" - }, - "scripts": { - "test": "node --experimental-vm-modules node_modules/.bin/jest", - "test:fails": "node --experimental-vm-modules node_modules/.bin/jest --onlyFailures", - "test:watch": "node --experimental-vm-modules node_modules/.bin/jest --watch", - "test:coverage": "node --experimental-vm-modules node_modules/.bin/jest --coverage", - "prepare-package": "node scripts/prepare-package.js", - "prepublishOnly": "npm run prepare-package", - "prepare": "chmod +x bin/task-master.js bin/task-master-init.js", - "changeset": "changeset", - "release": "changeset publish", - "inspector": "CLIENT_PORT=8888 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node mcp-server/server.js" - }, - "keywords": [ - "claude", - "task", - "management", - "ai", - "development", - "cursor", - "anthropic", - "llm", - "mcp", - "context" - ], - "author": "Eyal Toledano", - "license": "MIT WITH Commons-Clause", - "dependencies": { - "@anthropic-ai/sdk": "^0.39.0", - "boxen": "^8.0.1", - "chalk": "^4.1.2", - "cli-table3": "^0.6.5", - "commander": "^11.1.0", - "cors": "^2.8.5", - "dotenv": "^16.3.1", - "express": "^4.21.2", - "fastmcp": "^1.20.5", - "figlet": "^1.8.0", - "fuse.js": "^7.0.0", - "gradient-string": "^3.0.0", - "helmet": "^8.1.0", - "jsonwebtoken": "^9.0.2", - "lru-cache": "^10.2.0", - "openai": "^4.89.0", - "ora": "^8.2.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/eyaltoledano/claude-task-master.git" - }, - "homepage": "https://github.com/eyaltoledano/claude-task-master#readme", - "bugs": { - "url": "https://github.com/eyaltoledano/claude-task-master/issues" - }, - "files": [ - "scripts/init.js", - "scripts/dev.js", - "scripts/modules/**", - "assets/**", - ".cursor/**", - "README-task-master.md", - "index.js", - "bin/**", - "mcp-server/**" - ], - "overrides": { - "node-fetch": "^3.3.2", - "whatwg-url": "^11.0.0" - }, - "devDependencies": { - "@changesets/changelog-github": "^0.5.1", - "@changesets/cli": "^2.28.1", - "@types/jest": "^29.5.14", - "jest": "^29.7.0", - "jest-environment-node": "^29.7.0", - "mock-fs": "^5.5.0", - "supertest": "^7.1.0" - } + "name": "task-master-ai", + "version": "0.10.1", + "description": "A task management system for ambitious AI-driven development that doesn't overwhelm and confuse Cursor.", + "main": "index.js", + "type": "module", + "bin": { + "task-master": "bin/task-master.js", + "task-master-init": "bin/task-master-init.js", + "task-master-mcp-server": "mcp-server/server.js" + }, + "scripts": { + "test": "node --experimental-vm-modules node_modules/.bin/jest", + "test:fails": "node --experimental-vm-modules node_modules/.bin/jest --onlyFailures", + "test:watch": "node --experimental-vm-modules node_modules/.bin/jest --watch", + "test:coverage": "node --experimental-vm-modules node_modules/.bin/jest --coverage", + "prepare-package": "node scripts/prepare-package.js", + "prepublishOnly": "npm run prepare-package", + "prepare": "chmod +x bin/task-master.js bin/task-master-init.js", + "changeset": "changeset", + "release": "changeset publish", + "inspector": "CLIENT_PORT=8888 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node mcp-server/server.js", + "format-check": "prettier --check .", + "format": "prettier --write ." + }, + "keywords": [ + "claude", + "task", + "management", + "ai", + "development", + "cursor", + "anthropic", + "llm", + "mcp", + "context" + ], + "author": "Eyal Toledano", + "license": "MIT WITH Commons-Clause", + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "boxen": "^8.0.1", + "chalk": "^4.1.2", + "cli-table3": "^0.6.5", + "commander": "^11.1.0", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.21.2", + "fastmcp": "^1.20.5", + "figlet": "^1.8.0", + "fuse.js": "^7.0.0", + "gradient-string": "^3.0.0", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.2", + "lru-cache": "^10.2.0", + "openai": "^4.89.0", + "ora": "^8.2.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/eyaltoledano/claude-task-master.git" + }, + "homepage": "https://github.com/eyaltoledano/claude-task-master#readme", + "bugs": { + "url": "https://github.com/eyaltoledano/claude-task-master/issues" + }, + "files": [ + "scripts/init.js", + "scripts/dev.js", + "scripts/modules/**", + "assets/**", + ".cursor/**", + "README-task-master.md", + "index.js", + "bin/**", + "mcp-server/**" + ], + "overrides": { + "node-fetch": "^3.3.2", + "whatwg-url": "^11.0.0" + }, + "devDependencies": { + "@changesets/changelog-github": "^0.5.1", + "@changesets/cli": "^2.28.1", + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "jest-environment-node": "^29.7.0", + "mock-fs": "^5.5.0", + "supertest": "^7.1.0" + } } diff --git a/scripts/README.md b/scripts/README.md index 231bc8de..640703e4 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -21,9 +21,11 @@ In an AI-driven development process—particularly with tools like [Cursor](http The script can be configured through environment variables in a `.env` file at the root of the project: ### Required Configuration + - `ANTHROPIC_API_KEY`: Your Anthropic API key for Claude ### Optional Configuration + - `MODEL`: Specify which Claude model to use (default: "claude-3-7-sonnet-20250219") - `MAX_TOKENS`: Maximum tokens for model responses (default: 4000) - `TEMPERATURE`: Temperature for model responses (default: 0.7) @@ -38,9 +40,10 @@ The script can be configured through environment variables in a `.env` file at t ## How It Works -1. **`tasks.json`**: - - A JSON file at the project root containing an array of tasks (each with `id`, `title`, `description`, `status`, etc.). - - The `meta` field can store additional info like the project's name, version, or reference to the PRD. +1. **`tasks.json`**: + + - A JSON file at the project root containing an array of tasks (each with `id`, `title`, `description`, `status`, etc.). + - The `meta` field can store additional info like the project's name, version, or reference to the PRD. - Tasks can have `subtasks` for more detailed implementation steps. - Dependencies are displayed with status indicators (✅ for completed, ⏱️ for pending) to easily track progress. @@ -102,6 +105,7 @@ node scripts/dev.js update --file=custom-tasks.json --from=5 --prompt="Change da ``` Notes: + - The `--prompt` parameter is required and should explain the changes or new context - Only tasks that aren't marked as 'done' will be updated - Tasks with ID >= the specified --from value will be updated @@ -120,6 +124,7 @@ node scripts/dev.js update-task --id=4 --prompt="Use JWT for authentication" --r ``` This command: + - Updates only the specified task rather than a range of tasks - Provides detailed validation with helpful error messages - Checks for required API keys when using research mode @@ -146,6 +151,7 @@ node scripts/dev.js set-status --id=1,2,3 --status=done ``` Notes: + - When marking a parent task as "done", all of its subtasks will automatically be marked as "done" as well - Common status values are 'done', 'pending', and 'deferred', but any string is accepted - You can specify multiple task IDs by separating them with commas @@ -195,6 +201,7 @@ node scripts/dev.js clear-subtasks --all ``` Notes: + - After clearing subtasks, task files are automatically regenerated - This is useful when you want to regenerate subtasks with a different approach - Can be combined with the `expand` command to immediately generate new subtasks @@ -210,6 +217,7 @@ The script integrates with two AI services: The Perplexity integration uses the OpenAI client to connect to Perplexity's API, which provides enhanced research capabilities for generating more informed subtasks. If the Perplexity API is unavailable or encounters an error, the script will automatically fall back to using Anthropic's Claude. To use the Perplexity integration: + 1. Obtain a Perplexity API key 2. Add `PERPLEXITY_API_KEY` to your `.env` file 3. Optionally specify `PERPLEXITY_MODEL` in your `.env` file (default: "sonar-medium-online") @@ -218,6 +226,7 @@ To use the Perplexity integration: ## Logging The script supports different logging levels controlled by the `LOG_LEVEL` environment variable: + - `debug`: Detailed information, typically useful for troubleshooting - `info`: Confirmation that things are working as expected (default) - `warn`: Warning messages that don't prevent execution @@ -240,17 +249,20 @@ node scripts/dev.js remove-dependency --id= --depends-on= These commands: 1. **Allow precise dependency management**: + - Add dependencies between tasks with automatic validation - Remove dependencies when they're no longer needed - Update task files automatically after changes 2. **Include validation checks**: + - Prevent circular dependencies (a task depending on itself) - Prevent duplicate dependencies - Verify that both tasks exist before adding/removing dependencies - Check if dependencies exist before attempting to remove them 3. **Provide clear feedback**: + - Success messages confirm when dependencies are added/removed - Error messages explain why operations failed (if applicable) @@ -275,6 +287,7 @@ node scripts/dev.js validate-dependencies --file=custom-tasks.json ``` This command: + - Scans all tasks and subtasks for non-existent dependencies - Identifies potential self-dependencies (tasks referencing themselves) - Reports all found issues without modifying files @@ -296,6 +309,7 @@ node scripts/dev.js fix-dependencies --file=custom-tasks.json ``` This command: + 1. **Validates all dependencies** across tasks and subtasks 2. **Automatically removes**: - References to non-existent tasks and subtasks @@ -333,6 +347,7 @@ node scripts/dev.js analyze-complexity --research ``` Notes: + - The command uses Claude to analyze each task's complexity (or Perplexity with --research flag) - Tasks are scored on a scale of 1-10 - Each task receives a recommended number of subtasks based on DEFAULT_SUBTASKS configuration @@ -357,33 +372,35 @@ node scripts/dev.js expand --id=8 --num=5 --prompt="Custom prompt" ``` When a complexity report exists: + - The `expand` command will use the recommended subtask count from the report (unless overridden) - It will use the tailored expansion prompt from the report (unless a custom prompt is provided) - When using `--all`, tasks are sorted by complexity score (highest first) - The `--research` flag is preserved from the complexity analysis to expansion The output report structure is: + ```json { - "meta": { - "generatedAt": "2023-06-15T12:34:56.789Z", - "tasksAnalyzed": 20, - "thresholdScore": 5, - "projectName": "Your Project Name", - "usedResearch": true - }, - "complexityAnalysis": [ - { - "taskId": 8, - "taskTitle": "Develop Implementation Drift Handling", - "complexityScore": 9.5, - "recommendedSubtasks": 6, - "expansionPrompt": "Create subtasks that handle detecting...", - "reasoning": "This task requires sophisticated logic...", - "expansionCommand": "node scripts/dev.js expand --id=8 --num=6 --prompt=\"Create subtasks...\" --research" - }, - // More tasks sorted by complexity score (highest first) - ] + "meta": { + "generatedAt": "2023-06-15T12:34:56.789Z", + "tasksAnalyzed": 20, + "thresholdScore": 5, + "projectName": "Your Project Name", + "usedResearch": true + }, + "complexityAnalysis": [ + { + "taskId": 8, + "taskTitle": "Develop Implementation Drift Handling", + "complexityScore": 9.5, + "recommendedSubtasks": 6, + "expansionPrompt": "Create subtasks that handle detecting...", + "reasoning": "This task requires sophisticated logic...", + "expansionCommand": "node scripts/dev.js expand --id=8 --num=6 --prompt=\"Create subtasks...\" --research" + } + // More tasks sorted by complexity score (highest first) + ] } ``` @@ -457,16 +474,19 @@ This command is particularly useful when you need to examine a specific task in The script now includes improved error handling throughout all commands: 1. **Detailed Validation**: + - Required parameters (like task IDs and prompts) are validated early - File existence is checked with customized errors for common scenarios - Parameter type conversion is handled with clear error messages 2. **Contextual Error Messages**: + - Task not found errors include suggestions to run the list command - API key errors include reminders to check environment variables - Invalid ID format errors show the expected format 3. **Command-Specific Help Displays**: + - When validation fails, detailed help for the specific command is shown - Help displays include usage examples and parameter descriptions - Formatted in clear, color-coded boxes with examples @@ -481,11 +501,13 @@ The script now includes improved error handling throughout all commands: The script now automatically checks for updates without slowing down execution: 1. **Background Version Checking**: + - Non-blocking version checks run in the background while commands execute - Actual command execution isn't delayed by version checking - Update notifications appear after command completion 2. **Update Notifications**: + - When a newer version is available, a notification is displayed - Notifications include current version, latest version, and update command - Formatted in an attention-grabbing box with clear instructions @@ -516,6 +538,7 @@ node scripts/dev.js add-subtask --parent=5 --title="Login API route" --skip-gene ``` Key features: + - Create new subtasks with detailed properties or convert existing tasks - Define dependencies between subtasks - Set custom status for new subtasks @@ -538,7 +561,8 @@ node scripts/dev.js remove-subtask --id=5.2 --skip-generate ``` Key features: + - Remove subtasks individually or in batches - Optionally convert subtasks to standalone tasks - Control whether task files are regenerated -- Provides detailed success messages and next steps \ No newline at end of file +- Provides detailed success messages and next steps diff --git a/scripts/dev.js b/scripts/dev.js index 8d2aad73..7bc6a039 100755 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -3,17 +3,17 @@ /** * dev.js * Task Master CLI - AI-driven development task management - * + * * This is the refactored entry point that uses the modular architecture. * It imports functionality from the modules directory and provides a CLI. */ // Add at the very beginning of the file if (process.env.DEBUG === '1') { - console.error('DEBUG - dev.js received args:', process.argv.slice(2)); + console.error('DEBUG - dev.js received args:', process.argv.slice(2)); } import { runCLI } from './modules/commands.js'; // Run the CLI with the process arguments -runCLI(process.argv); \ No newline at end of file +runCLI(process.argv); diff --git a/scripts/init.js b/scripts/init.js index eabda983..611911a2 100755 --- a/scripts/init.js +++ b/scripts/init.js @@ -40,47 +40,52 @@ const __dirname = dirname(__filename); // Configure the CLI program const program = new Command(); program - .name('task-master-init') - .description('Initialize a new Claude Task Master project') - .version('1.0.0') // Will be replaced by prepare-package script - .option('-y, --yes', 'Skip prompts and use default values') - .option('-n, --name ', 'Project name') - .option('-my_name ', 'Project name (alias for --name)') - .option('-d, --description ', 'Project description') - .option('-my_description ', 'Project description (alias for --description)') - .option('-v, --version ', 'Project version') - .option('-my_version ', 'Project version (alias for --version)') - .option('--my_name ', 'Project name (alias for --name)') - .option('-a, --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)') - .parse(process.argv); + .name('task-master-init') + .description('Initialize a new Claude Task Master project') + .version('1.0.0') // Will be replaced by prepare-package script + .option('-y, --yes', 'Skip prompts and use default values') + .option('-n, --name ', 'Project name') + .option('-my_name ', 'Project name (alias for --name)') + .option('-d, --description ', 'Project description') + .option( + '-my_description ', + 'Project description (alias for --description)' + ) + .option('-v, --version ', 'Project version') + .option('-my_version ', 'Project version (alias for --version)') + .option('--my_name ', 'Project name (alias for --name)') + .option('-a, --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)') + .parse(process.argv); const options = program.opts(); // Map custom aliases to standard options if (options.my_name && !options.name) { - options.name = options.my_name; + options.name = options.my_name; } if (options.my_description && !options.description) { - options.description = options.my_description; + options.description = options.my_description; } if (options.my_version && !options.version) { - options.version = options.my_version; + options.version = options.my_version; } // Define log levels const LOG_LEVELS = { - debug: 0, - info: 1, - warn: 2, - error: 3, - success: 4 + debug: 0, + info: 1, + warn: 2, + error: 3, + success: 4 }; // Get log level from environment or default to info -const LOG_LEVEL = process.env.LOG_LEVEL ? LOG_LEVELS[process.env.LOG_LEVEL.toLowerCase()] : LOG_LEVELS.info; +const LOG_LEVEL = process.env.LOG_LEVEL + ? LOG_LEVELS[process.env.LOG_LEVEL.toLowerCase()] + : LOG_LEVELS.info; // Create a color gradient for the banner const coolGradient = gradient(['#00b4d8', '#0077b6', '#03045e']); @@ -88,673 +93,863 @@ const warmGradient = gradient(['#fb8b24', '#e36414', '#9a031e']); // Display a fancy banner function displayBanner() { - console.clear(); - const bannerText = figlet.textSync('Task Master AI', { - font: 'Standard', - horizontalLayout: 'default', - verticalLayout: 'default' - }); - - console.log(coolGradient(bannerText)); - - // Add creator credit line below the banner - console.log(chalk.dim('by ') + chalk.cyan.underline('https://x.com/eyaltoledano')); - - console.log(boxen(chalk.white(`${chalk.bold('Initializing')} your new project`), { - padding: 1, - margin: { top: 0, bottom: 1 }, - borderStyle: 'round', - borderColor: 'cyan' - })); + console.clear(); + const bannerText = figlet.textSync('Task Master AI', { + font: 'Standard', + horizontalLayout: 'default', + verticalLayout: 'default' + }); + + console.log(coolGradient(bannerText)); + + // Add creator credit line below the banner + console.log( + chalk.dim('by ') + chalk.cyan.underline('https://x.com/eyaltoledano') + ); + + console.log( + boxen(chalk.white(`${chalk.bold('Initializing')} your new project`), { + padding: 1, + margin: { top: 0, bottom: 1 }, + borderStyle: 'round', + borderColor: 'cyan' + }) + ); } // Logging function with icons and colors 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_LEVEL) { - const icon = icons[level] || ''; - - if (level === 'error') { - console.error(icon, chalk.red(...args)); - } else if (level === 'warn') { - console.warn(icon, chalk.yellow(...args)); - } else if (level === 'success') { - console.log(icon, chalk.green(...args)); - } else if (level === 'info') { - console.log(icon, chalk.blue(...args)); - } else { - console.log(icon, ...args); - } - } - - // Write to debug log if DEBUG=true - if (process.env.DEBUG === 'true') { - const logMessage = `[${level.toUpperCase()}] ${args.join(' ')}\n`; - fs.appendFileSync('init-debug.log', logMessage); - } + const icons = { + debug: chalk.gray('🔍'), + info: chalk.blue('ℹ️'), + warn: chalk.yellow('⚠️'), + error: chalk.red('❌'), + success: chalk.green('✅') + }; + + if (LOG_LEVELS[level] >= LOG_LEVEL) { + const icon = icons[level] || ''; + + if (level === 'error') { + console.error(icon, chalk.red(...args)); + } else if (level === 'warn') { + console.warn(icon, chalk.yellow(...args)); + } else if (level === 'success') { + console.log(icon, chalk.green(...args)); + } else if (level === 'info') { + console.log(icon, chalk.blue(...args)); + } else { + console.log(icon, ...args); + } + } + + // Write to debug log if DEBUG=true + if (process.env.DEBUG === 'true') { + const logMessage = `[${level.toUpperCase()}] ${args.join(' ')}\n`; + fs.appendFileSync('init-debug.log', logMessage); + } } // Function to create directory if it doesn't exist function ensureDirectoryExists(dirPath) { - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - log('info', `Created directory: ${dirPath}`); - } + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + log('info', `Created directory: ${dirPath}`); + } } // Function to add shell aliases to the user's shell configuration function addShellAliases() { - const homeDir = process.env.HOME || process.env.USERPROFILE; - let shellConfigFile; - - // Determine which shell config file to use - if (process.env.SHELL?.includes('zsh')) { - shellConfigFile = path.join(homeDir, '.zshrc'); - } else if (process.env.SHELL?.includes('bash')) { - shellConfigFile = path.join(homeDir, '.bashrc'); - } else { - log('warn', 'Could not determine shell type. Aliases not added.'); - return false; - } - - try { - // Check if file exists - if (!fs.existsSync(shellConfigFile)) { - log('warn', `Shell config file ${shellConfigFile} not found. Aliases not added.`); - return false; - } - - // Check if aliases already exist - const configContent = fs.readFileSync(shellConfigFile, 'utf8'); - if (configContent.includes('alias tm=\'task-master\'')) { - log('info', 'Task Master aliases already exist in shell config.'); - return true; - } - - // Add aliases to the shell config file - const aliasBlock = ` + const homeDir = process.env.HOME || process.env.USERPROFILE; + let shellConfigFile; + + // Determine which shell config file to use + if (process.env.SHELL?.includes('zsh')) { + shellConfigFile = path.join(homeDir, '.zshrc'); + } else if (process.env.SHELL?.includes('bash')) { + shellConfigFile = path.join(homeDir, '.bashrc'); + } else { + log('warn', 'Could not determine shell type. Aliases not added.'); + return false; + } + + try { + // Check if file exists + if (!fs.existsSync(shellConfigFile)) { + log( + 'warn', + `Shell config file ${shellConfigFile} not found. Aliases not added.` + ); + return false; + } + + // Check if aliases already exist + const configContent = fs.readFileSync(shellConfigFile, 'utf8'); + if (configContent.includes("alias tm='task-master'")) { + log('info', 'Task Master aliases already exist in shell config.'); + return true; + } + + // Add aliases to the shell config file + const aliasBlock = ` # Task Master aliases added on ${new Date().toLocaleDateString()} alias tm='task-master' alias taskmaster='task-master' `; - - fs.appendFileSync(shellConfigFile, aliasBlock); - log('success', `Added Task Master aliases to ${shellConfigFile}`); - log('info', 'To use the aliases in your current terminal, run: source ' + shellConfigFile); - - return true; - } catch (error) { - log('error', `Failed to add aliases: ${error.message}`); - return false; - } + + fs.appendFileSync(shellConfigFile, aliasBlock); + log('success', `Added Task Master aliases to ${shellConfigFile}`); + log( + 'info', + 'To use the aliases in your current terminal, run: source ' + + shellConfigFile + ); + + return true; + } catch (error) { + log('error', `Failed to add aliases: ${error.message}`); + return false; + } } // Function to copy a file from the package to the target directory function copyTemplateFile(templateName, targetPath, replacements = {}) { - // Get the file content from the appropriate source directory - let sourcePath; - - // Map template names to their actual source paths - switch(templateName) { - case 'dev.js': - sourcePath = path.join(__dirname, 'dev.js'); - break; - case 'scripts_README.md': - sourcePath = path.join(__dirname, '..', 'assets', 'scripts_README.md'); - break; - case 'dev_workflow.mdc': - sourcePath = path.join(__dirname, '..', '.cursor', 'rules', 'dev_workflow.mdc'); - break; - case 'cursor_rules.mdc': - sourcePath = path.join(__dirname, '..', '.cursor', 'rules', 'cursor_rules.mdc'); - break; - case 'self_improve.mdc': - sourcePath = path.join(__dirname, '..', '.cursor', 'rules', 'self_improve.mdc'); - break; - case 'README-task-master.md': - sourcePath = path.join(__dirname, '..', 'README-task-master.md'); - break; - case 'windsurfrules': - sourcePath = path.join(__dirname, '..', 'assets', '.windsurfrules'); - break; - default: - // For other files like env.example, gitignore, etc. that don't have direct equivalents - sourcePath = path.join(__dirname, '..', 'assets', templateName); - } - - // Check if the source file exists - if (!fs.existsSync(sourcePath)) { - // Fall back to templates directory for files that might not have been moved yet - sourcePath = path.join(__dirname, '..', 'assets', templateName); - if (!fs.existsSync(sourcePath)) { - log('error', `Source file not found: ${sourcePath}`); - return; - } - } - - let content = fs.readFileSync(sourcePath, 'utf8'); - - // Replace placeholders with actual values - Object.entries(replacements).forEach(([key, value]) => { - const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g'); - content = content.replace(regex, value); - }); - - // Handle special files that should be merged instead of overwritten - if (fs.existsSync(targetPath)) { - const filename = path.basename(targetPath); - - // Handle .gitignore - append lines that don't exist - if (filename === '.gitignore') { - log('info', `${targetPath} already exists, merging content...`); - const existingContent = fs.readFileSync(targetPath, 'utf8'); - const existingLines = new Set(existingContent.split('\n').map(line => line.trim())); - const newLines = content.split('\n').filter(line => !existingLines.has(line.trim())); - - if (newLines.length > 0) { - // Add a comment to separate the original content from our additions - const updatedContent = existingContent.trim() + - '\n\n# Added by Claude Task Master\n' + - newLines.join('\n'); - fs.writeFileSync(targetPath, updatedContent); - log('success', `Updated ${targetPath} with additional entries`); - } else { - log('info', `No new content to add to ${targetPath}`); - } - return; - } - - // Handle .windsurfrules - append the entire content - if (filename === '.windsurfrules') { - log('info', `${targetPath} already exists, appending content instead of overwriting...`); - const existingContent = fs.readFileSync(targetPath, 'utf8'); - - // Add a separator comment before appending our content - const updatedContent = existingContent.trim() + - '\n\n# Added by Task Master - Development Workflow Rules\n\n' + - content; - fs.writeFileSync(targetPath, updatedContent); - log('success', `Updated ${targetPath} with additional rules`); - return; - } - - // Handle package.json - merge dependencies - if (filename === 'package.json') { - log('info', `${targetPath} already exists, merging dependencies...`); - try { - const existingPackageJson = JSON.parse(fs.readFileSync(targetPath, 'utf8')); - const newPackageJson = JSON.parse(content); - - // Merge dependencies, preferring existing versions in case of conflicts - existingPackageJson.dependencies = { - ...newPackageJson.dependencies, - ...existingPackageJson.dependencies - }; - - // Add our scripts if they don't already exist - existingPackageJson.scripts = { - ...existingPackageJson.scripts, - ...Object.fromEntries( - Object.entries(newPackageJson.scripts) - .filter(([key]) => !existingPackageJson.scripts[key]) - ) - }; - - // Preserve existing type if present - if (!existingPackageJson.type && newPackageJson.type) { - existingPackageJson.type = newPackageJson.type; - } - - fs.writeFileSync( - targetPath, - JSON.stringify(existingPackageJson, null, 2) - ); - log('success', `Updated ${targetPath} with required dependencies and scripts`); - } catch (error) { - log('error', `Failed to merge package.json: ${error.message}`); - // Fallback to writing a backup of the existing file and creating a new one - const backupPath = `${targetPath}.backup-${Date.now()}`; - fs.copyFileSync(targetPath, backupPath); - log('info', `Created backup of existing package.json at ${backupPath}`); - fs.writeFileSync(targetPath, content); - log('warn', `Replaced ${targetPath} with new content (due to JSON parsing error)`); - } - return; - } - - // Handle README.md - offer to preserve or create a different file - if (filename === 'README.md') { - log('info', `${targetPath} already exists`); - // Create a separate README file specifically for this project - const taskMasterReadmePath = path.join(path.dirname(targetPath), 'README-task-master.md'); - fs.writeFileSync(taskMasterReadmePath, content); - log('success', `Created ${taskMasterReadmePath} (preserved original README.md)`); - return; - } - - // For other files, warn and prompt before overwriting - log('warn', `${targetPath} already exists. Skipping file creation to avoid overwriting existing content.`); - return; - } - - // If the file doesn't exist, create it normally - fs.writeFileSync(targetPath, content); - log('info', `Created file: ${targetPath}`); + // Get the file content from the appropriate source directory + let sourcePath; + + // Map template names to their actual source paths + switch (templateName) { + case 'dev.js': + sourcePath = path.join(__dirname, 'dev.js'); + break; + case 'scripts_README.md': + sourcePath = path.join(__dirname, '..', 'assets', 'scripts_README.md'); + break; + case 'dev_workflow.mdc': + sourcePath = path.join( + __dirname, + '..', + '.cursor', + 'rules', + 'dev_workflow.mdc' + ); + break; + case 'cursor_rules.mdc': + sourcePath = path.join( + __dirname, + '..', + '.cursor', + 'rules', + 'cursor_rules.mdc' + ); + break; + case 'self_improve.mdc': + sourcePath = path.join( + __dirname, + '..', + '.cursor', + 'rules', + 'self_improve.mdc' + ); + break; + case 'README-task-master.md': + sourcePath = path.join(__dirname, '..', 'README-task-master.md'); + break; + case 'windsurfrules': + sourcePath = path.join(__dirname, '..', 'assets', '.windsurfrules'); + break; + default: + // For other files like env.example, gitignore, etc. that don't have direct equivalents + sourcePath = path.join(__dirname, '..', 'assets', templateName); + } + + // Check if the source file exists + if (!fs.existsSync(sourcePath)) { + // Fall back to templates directory for files that might not have been moved yet + sourcePath = path.join(__dirname, '..', 'assets', templateName); + if (!fs.existsSync(sourcePath)) { + log('error', `Source file not found: ${sourcePath}`); + return; + } + } + + let content = fs.readFileSync(sourcePath, 'utf8'); + + // Replace placeholders with actual values + Object.entries(replacements).forEach(([key, value]) => { + const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g'); + content = content.replace(regex, value); + }); + + // Handle special files that should be merged instead of overwritten + if (fs.existsSync(targetPath)) { + const filename = path.basename(targetPath); + + // Handle .gitignore - append lines that don't exist + if (filename === '.gitignore') { + log('info', `${targetPath} already exists, merging content...`); + const existingContent = fs.readFileSync(targetPath, 'utf8'); + const existingLines = new Set( + existingContent.split('\n').map((line) => line.trim()) + ); + const newLines = content + .split('\n') + .filter((line) => !existingLines.has(line.trim())); + + if (newLines.length > 0) { + // Add a comment to separate the original content from our additions + const updatedContent = + existingContent.trim() + + '\n\n# Added by Claude Task Master\n' + + newLines.join('\n'); + fs.writeFileSync(targetPath, updatedContent); + log('success', `Updated ${targetPath} with additional entries`); + } else { + log('info', `No new content to add to ${targetPath}`); + } + return; + } + + // Handle .windsurfrules - append the entire content + if (filename === '.windsurfrules') { + log( + 'info', + `${targetPath} already exists, appending content instead of overwriting...` + ); + const existingContent = fs.readFileSync(targetPath, 'utf8'); + + // Add a separator comment before appending our content + const updatedContent = + existingContent.trim() + + '\n\n# Added by Task Master - Development Workflow Rules\n\n' + + content; + fs.writeFileSync(targetPath, updatedContent); + log('success', `Updated ${targetPath} with additional rules`); + return; + } + + // Handle package.json - merge dependencies + if (filename === 'package.json') { + log('info', `${targetPath} already exists, merging dependencies...`); + try { + const existingPackageJson = JSON.parse( + fs.readFileSync(targetPath, 'utf8') + ); + const newPackageJson = JSON.parse(content); + + // Merge dependencies, preferring existing versions in case of conflicts + existingPackageJson.dependencies = { + ...newPackageJson.dependencies, + ...existingPackageJson.dependencies + }; + + // Add our scripts if they don't already exist + existingPackageJson.scripts = { + ...existingPackageJson.scripts, + ...Object.fromEntries( + Object.entries(newPackageJson.scripts).filter( + ([key]) => !existingPackageJson.scripts[key] + ) + ) + }; + + // Preserve existing type if present + if (!existingPackageJson.type && newPackageJson.type) { + existingPackageJson.type = newPackageJson.type; + } + + fs.writeFileSync( + targetPath, + JSON.stringify(existingPackageJson, null, 2) + ); + log( + 'success', + `Updated ${targetPath} with required dependencies and scripts` + ); + } catch (error) { + log('error', `Failed to merge package.json: ${error.message}`); + // Fallback to writing a backup of the existing file and creating a new one + const backupPath = `${targetPath}.backup-${Date.now()}`; + fs.copyFileSync(targetPath, backupPath); + log('info', `Created backup of existing package.json at ${backupPath}`); + fs.writeFileSync(targetPath, content); + log( + 'warn', + `Replaced ${targetPath} with new content (due to JSON parsing error)` + ); + } + return; + } + + // Handle README.md - offer to preserve or create a different file + if (filename === 'README.md') { + log('info', `${targetPath} already exists`); + // Create a separate README file specifically for this project + const taskMasterReadmePath = path.join( + path.dirname(targetPath), + 'README-task-master.md' + ); + fs.writeFileSync(taskMasterReadmePath, content); + log( + 'success', + `Created ${taskMasterReadmePath} (preserved original README.md)` + ); + return; + } + + // For other files, warn and prompt before overwriting + log( + 'warn', + `${targetPath} already exists. Skipping file creation to avoid overwriting existing content.` + ); + return; + } + + // If the file doesn't exist, create it normally + fs.writeFileSync(targetPath, content); + log('info', `Created file: ${targetPath}`); } // Main function to initialize a new project async function initializeProject(options = {}) { - // Display the banner - displayBanner(); - - // If options are provided, use them directly without prompting - if (options.projectName && options.projectDescription) { - const projectName = options.projectName; - const projectDescription = options.projectDescription; - const projectVersion = options.projectVersion || '1.0.0'; - const authorName = options.authorName || ''; - const dryRun = options.dryRun || false; - const skipInstall = options.skipInstall || false; - const addAliases = options.addAliases || false; - - if (dryRun) { - log('info', 'DRY RUN MODE: No files will be modified'); - log('info', `Would initialize project: ${projectName} (${projectVersion})`); - log('info', `Description: ${projectDescription}`); - log('info', `Author: ${authorName || 'Not specified'}`); - log('info', 'Would create/update necessary project files'); - if (addAliases) { - log('info', 'Would add shell aliases for task-master'); - } - if (!skipInstall) { - log('info', 'Would install dependencies'); - } - return { - projectName, - projectDescription, - projectVersion, - authorName, - dryRun: true - }; - } - - createProjectStructure(projectName, projectDescription, projectVersion, authorName, skipInstall, addAliases); - return { - projectName, - projectDescription, - projectVersion, - authorName - }; - } - - // Otherwise, prompt the user for input - // Create readline interface only when needed - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); - - try { - const projectName = await promptQuestion(rl, chalk.cyan('Enter project name: ')); - const projectDescription = await promptQuestion(rl, chalk.cyan('Enter project description: ')); - const projectVersionInput = await promptQuestion(rl, chalk.cyan('Enter project version (default: 1.0.0): ')); - const authorName = await promptQuestion(rl, chalk.cyan('Enter your name: ')); - - // Ask about shell aliases - const addAliasesInput = await promptQuestion(rl, chalk.cyan('Add shell aliases for task-master? (Y/n): ')); - const addAliases = addAliasesInput.trim().toLowerCase() !== 'n'; - - // Set default version if not provided - const projectVersion = projectVersionInput.trim() ? projectVersionInput : '1.0.0'; - - // Confirm settings - console.log('\nProject settings:'); - console.log(chalk.blue('Name:'), chalk.white(projectName)); - console.log(chalk.blue('Description:'), chalk.white(projectDescription)); - console.log(chalk.blue('Version:'), chalk.white(projectVersion)); - console.log(chalk.blue('Author:'), chalk.white(authorName || 'Not specified')); - console.log(chalk.blue('Add shell aliases:'), chalk.white(addAliases ? 'Yes' : 'No')); - - const confirmInput = await promptQuestion(rl, chalk.yellow('\nDo you want to continue with these settings? (Y/n): ')); - const shouldContinue = confirmInput.trim().toLowerCase() !== 'n'; - - // Close the readline interface - rl.close(); - - if (!shouldContinue) { - log('info', 'Project initialization cancelled by user'); - return null; - } - - const dryRun = options.dryRun || false; - const skipInstall = options.skipInstall || false; - - if (dryRun) { - log('info', 'DRY RUN MODE: No files will be modified'); - log('info', 'Would create/update necessary project files'); - if (addAliases) { - log('info', 'Would add shell aliases for task-master'); - } - if (!skipInstall) { - log('info', 'Would install dependencies'); - } - return { - projectName, - projectDescription, - projectVersion, - authorName, - dryRun: true - }; - } - - // Create the project structure - createProjectStructure(projectName, projectDescription, projectVersion, authorName, skipInstall, addAliases); - - return { - projectName, - projectDescription, - projectVersion, - authorName - }; - } catch (error) { - // Make sure to close readline on error - rl.close(); - throw error; - } + // Display the banner + displayBanner(); + + // If options are provided, use them directly without prompting + if (options.projectName && options.projectDescription) { + const projectName = options.projectName; + const projectDescription = options.projectDescription; + const projectVersion = options.projectVersion || '1.0.0'; + const authorName = options.authorName || ''; + const dryRun = options.dryRun || false; + const skipInstall = options.skipInstall || false; + const addAliases = options.addAliases || false; + + if (dryRun) { + log('info', 'DRY RUN MODE: No files will be modified'); + log( + 'info', + `Would initialize project: ${projectName} (${projectVersion})` + ); + log('info', `Description: ${projectDescription}`); + log('info', `Author: ${authorName || 'Not specified'}`); + log('info', 'Would create/update necessary project files'); + if (addAliases) { + log('info', 'Would add shell aliases for task-master'); + } + if (!skipInstall) { + log('info', 'Would install dependencies'); + } + return { + projectName, + projectDescription, + projectVersion, + authorName, + dryRun: true + }; + } + + createProjectStructure( + projectName, + projectDescription, + projectVersion, + authorName, + skipInstall, + addAliases + ); + return { + projectName, + projectDescription, + projectVersion, + authorName + }; + } + + // Otherwise, prompt the user for input + // Create readline interface only when needed + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + try { + const projectName = await promptQuestion( + rl, + chalk.cyan('Enter project name: ') + ); + const projectDescription = await promptQuestion( + rl, + chalk.cyan('Enter project description: ') + ); + const projectVersionInput = await promptQuestion( + rl, + chalk.cyan('Enter project version (default: 1.0.0): ') + ); + const authorName = await promptQuestion( + rl, + chalk.cyan('Enter your name: ') + ); + + // Ask about shell aliases + const addAliasesInput = await promptQuestion( + rl, + chalk.cyan('Add shell aliases for task-master? (Y/n): ') + ); + const addAliases = addAliasesInput.trim().toLowerCase() !== 'n'; + + // Set default version if not provided + const projectVersion = projectVersionInput.trim() + ? projectVersionInput + : '1.0.0'; + + // Confirm settings + console.log('\nProject settings:'); + console.log(chalk.blue('Name:'), chalk.white(projectName)); + console.log(chalk.blue('Description:'), chalk.white(projectDescription)); + console.log(chalk.blue('Version:'), chalk.white(projectVersion)); + console.log( + chalk.blue('Author:'), + chalk.white(authorName || 'Not specified') + ); + console.log( + chalk.blue('Add shell aliases:'), + chalk.white(addAliases ? 'Yes' : 'No') + ); + + const confirmInput = await promptQuestion( + rl, + chalk.yellow('\nDo you want to continue with these settings? (Y/n): ') + ); + const shouldContinue = confirmInput.trim().toLowerCase() !== 'n'; + + // Close the readline interface + rl.close(); + + if (!shouldContinue) { + log('info', 'Project initialization cancelled by user'); + return null; + } + + const dryRun = options.dryRun || false; + const skipInstall = options.skipInstall || false; + + if (dryRun) { + log('info', 'DRY RUN MODE: No files will be modified'); + log('info', 'Would create/update necessary project files'); + if (addAliases) { + log('info', 'Would add shell aliases for task-master'); + } + if (!skipInstall) { + log('info', 'Would install dependencies'); + } + return { + projectName, + projectDescription, + projectVersion, + authorName, + dryRun: true + }; + } + + // Create the project structure + createProjectStructure( + projectName, + projectDescription, + projectVersion, + authorName, + skipInstall, + addAliases + ); + + return { + projectName, + projectDescription, + projectVersion, + authorName + }; + } catch (error) { + // Make sure to close readline on error + rl.close(); + throw error; + } } // Helper function to promisify readline question function promptQuestion(rl, question) { - return new Promise((resolve) => { - rl.question(question, (answer) => { - resolve(answer); - }); - }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + resolve(answer); + }); + }); } // Function to create the project structure -function createProjectStructure(projectName, projectDescription, projectVersion, authorName, skipInstall, addAliases) { - const targetDir = process.cwd(); - log('info', `Initializing project in ${targetDir}`); - - // Create directories - ensureDirectoryExists(path.join(targetDir, '.cursor', 'rules')); - ensureDirectoryExists(path.join(targetDir, 'scripts')); - ensureDirectoryExists(path.join(targetDir, 'tasks')); - - // Define our package.json content - const packageJson = { - name: projectName.toLowerCase().replace(/\s+/g, '-'), - version: projectVersion, - description: projectDescription, - author: authorName, - type: "module", - scripts: { - "dev": "node scripts/dev.js", - "list": "node scripts/dev.js list", - "generate": "node scripts/dev.js generate", - "parse-prd": "node scripts/dev.js parse-prd" - }, - dependencies: { - "@anthropic-ai/sdk": "^0.39.0", - "chalk": "^5.3.0", - "commander": "^11.1.0", - "dotenv": "^16.3.1", - "openai": "^4.86.1", - "figlet": "^1.7.0", - "boxen": "^7.1.1", - "gradient-string": "^2.0.2", - "cli-table3": "^0.6.3", - "ora": "^7.0.1" - } - }; - - // Check if package.json exists and merge if it does - const packageJsonPath = path.join(targetDir, 'package.json'); - if (fs.existsSync(packageJsonPath)) { - log('info', 'package.json already exists, merging content...'); - try { - const existingPackageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - - // Preserve existing fields but add our required ones - const mergedPackageJson = { - ...existingPackageJson, - scripts: { - ...existingPackageJson.scripts, - ...Object.fromEntries( - Object.entries(packageJson.scripts) - .filter(([key]) => !existingPackageJson.scripts || !existingPackageJson.scripts[key]) - ) - }, - dependencies: { - ...existingPackageJson.dependencies || {}, - ...Object.fromEntries( - Object.entries(packageJson.dependencies) - .filter(([key]) => !existingPackageJson.dependencies || !existingPackageJson.dependencies[key]) - ) - } - }; - - // Ensure type is set if not already present - if (!mergedPackageJson.type && packageJson.type) { - mergedPackageJson.type = packageJson.type; - } - - fs.writeFileSync(packageJsonPath, JSON.stringify(mergedPackageJson, null, 2)); - log('success', 'Updated package.json with required fields'); - } catch (error) { - log('error', `Failed to merge package.json: ${error.message}`); - // Create a backup before potentially modifying - const backupPath = `${packageJsonPath}.backup-${Date.now()}`; - fs.copyFileSync(packageJsonPath, backupPath); - log('info', `Created backup of existing package.json at ${backupPath}`); - fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); - log('warn', 'Created new package.json (backup of original file was created)'); - } - } else { - // If package.json doesn't exist, create it - fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); - log('success', 'Created package.json'); - } - - // Setup MCP configuration for integration with Cursor - setupMCPConfiguration(targetDir, packageJson.name); - - // Copy template files with replacements - const replacements = { - projectName, - projectDescription, - projectVersion, - authorName, - year: new Date().getFullYear() - }; - - // Copy .env.example - copyTemplateFile('env.example', path.join(targetDir, '.env.example'), replacements); - - // Copy .gitignore - copyTemplateFile('gitignore', path.join(targetDir, '.gitignore')); - - // Copy dev_workflow.mdc - copyTemplateFile('dev_workflow.mdc', path.join(targetDir, '.cursor', 'rules', 'dev_workflow.mdc')); - - // Copy cursor_rules.mdc - copyTemplateFile('cursor_rules.mdc', path.join(targetDir, '.cursor', 'rules', 'cursor_rules.mdc')); - - // Copy self_improve.mdc - copyTemplateFile('self_improve.mdc', path.join(targetDir, '.cursor', 'rules', 'self_improve.mdc')); - - // Copy .windsurfrules - copyTemplateFile('windsurfrules', path.join(targetDir, '.windsurfrules')); - - // Copy scripts/dev.js - copyTemplateFile('dev.js', path.join(targetDir, 'scripts', 'dev.js')); - - // Copy scripts/README.md - copyTemplateFile('scripts_README.md', path.join(targetDir, 'scripts', 'README.md')); - - // Copy example_prd.txt - copyTemplateFile('example_prd.txt', path.join(targetDir, 'scripts', 'example_prd.txt')); - - // Create main README.md - copyTemplateFile('README-task-master.md', path.join(targetDir, 'README.md'), replacements); - - // Initialize git repository if git is available - try { - if (!fs.existsSync(path.join(targetDir, '.git'))) { - log('info', 'Initializing git repository...'); - execSync('git init', { stdio: 'ignore' }); - log('success', 'Git repository initialized'); - } - } catch (error) { - log('warn', 'Git not available, skipping repository initialization'); - } - - // Run npm install automatically - console.log(boxen(chalk.cyan('Installing dependencies...'), { - padding: 0.5, - margin: 0.5, - borderStyle: 'round', - borderColor: 'blue' - })); - - try { - if (!skipInstall) { - execSync('npm install', { stdio: 'inherit', cwd: targetDir }); - log('success', 'Dependencies installed successfully!'); - } else { - log('info', 'Dependencies installation skipped'); - } - } catch (error) { - log('error', 'Failed to install dependencies:', error.message); - log('error', 'Please run npm install manually'); - } - - // Display success message - console.log(boxen( - warmGradient.multiline(figlet.textSync('Success!', { font: 'Standard' })) + - '\n' + chalk.green('Project initialized successfully!'), - { - padding: 1, - margin: 1, - borderStyle: 'double', - borderColor: 'green' - } - )); - - // Add shell aliases if requested - if (addAliases) { - addShellAliases(); - } - - // Display next steps in a nice box - console.log(boxen( - chalk.cyan.bold('Things you can now do:') + '\n\n' + - chalk.white('1. ') + chalk.yellow('Rename .env.example to .env and add your ANTHROPIC_API_KEY and PERPLEXITY_API_KEY') + '\n' + - chalk.white('2. ') + chalk.yellow('Discuss your idea with AI, and once ready ask for a PRD using the example_prd.txt file, and save what you get to scripts/PRD.txt') + '\n' + - chalk.white('3. ') + chalk.yellow('Ask Cursor Agent to parse your PRD.txt and generate tasks') + '\n' + - chalk.white(' └─ ') + chalk.dim('You can also run ') + chalk.cyan('task-master parse-prd ') + '\n' + - chalk.white('4. ') + chalk.yellow('Ask Cursor to analyze the complexity of your tasks') + '\n' + - chalk.white('5. ') + chalk.yellow('Ask Cursor which task is next to determine where to start') + '\n' + - chalk.white('6. ') + chalk.yellow('Ask Cursor to expand any complex tasks that are too large or complex.') + '\n' + - chalk.white('7. ') + chalk.yellow('Ask Cursor to set the status of a task, or multiple tasks. Use the task id from the task lists.') + '\n' + - chalk.white('8. ') + chalk.yellow('Ask Cursor to update all tasks from a specific task id based on new learnings or pivots in your project.') + '\n' + - chalk.white('9. ') + chalk.green.bold('Ship it!') + '\n\n' + - chalk.dim('* Review the README.md file to learn how to use other commands via Cursor Agent.'), - { - padding: 1, - margin: 1, - borderStyle: 'round', - borderColor: 'yellow', - title: 'Getting Started', - titleAlignment: 'center' - } - )); +function createProjectStructure( + projectName, + projectDescription, + projectVersion, + authorName, + skipInstall, + addAliases +) { + const targetDir = process.cwd(); + log('info', `Initializing project in ${targetDir}`); + + // Create directories + ensureDirectoryExists(path.join(targetDir, '.cursor', 'rules')); + ensureDirectoryExists(path.join(targetDir, 'scripts')); + ensureDirectoryExists(path.join(targetDir, 'tasks')); + + // Define our package.json content + const packageJson = { + name: projectName.toLowerCase().replace(/\s+/g, '-'), + version: projectVersion, + description: projectDescription, + author: authorName, + type: 'module', + scripts: { + dev: 'node scripts/dev.js', + list: 'node scripts/dev.js list', + generate: 'node scripts/dev.js generate', + 'parse-prd': 'node scripts/dev.js parse-prd' + }, + dependencies: { + '@anthropic-ai/sdk': '^0.39.0', + chalk: '^5.3.0', + commander: '^11.1.0', + dotenv: '^16.3.1', + openai: '^4.86.1', + figlet: '^1.7.0', + boxen: '^7.1.1', + 'gradient-string': '^2.0.2', + 'cli-table3': '^0.6.3', + ora: '^7.0.1' + } + }; + + // Check if package.json exists and merge if it does + const packageJsonPath = path.join(targetDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + log('info', 'package.json already exists, merging content...'); + try { + const existingPackageJson = JSON.parse( + fs.readFileSync(packageJsonPath, 'utf8') + ); + + // Preserve existing fields but add our required ones + const mergedPackageJson = { + ...existingPackageJson, + scripts: { + ...existingPackageJson.scripts, + ...Object.fromEntries( + Object.entries(packageJson.scripts).filter( + ([key]) => + !existingPackageJson.scripts || + !existingPackageJson.scripts[key] + ) + ) + }, + dependencies: { + ...(existingPackageJson.dependencies || {}), + ...Object.fromEntries( + Object.entries(packageJson.dependencies).filter( + ([key]) => + !existingPackageJson.dependencies || + !existingPackageJson.dependencies[key] + ) + ) + } + }; + + // Ensure type is set if not already present + if (!mergedPackageJson.type && packageJson.type) { + mergedPackageJson.type = packageJson.type; + } + + fs.writeFileSync( + packageJsonPath, + JSON.stringify(mergedPackageJson, null, 2) + ); + log('success', 'Updated package.json with required fields'); + } catch (error) { + log('error', `Failed to merge package.json: ${error.message}`); + // Create a backup before potentially modifying + const backupPath = `${packageJsonPath}.backup-${Date.now()}`; + fs.copyFileSync(packageJsonPath, backupPath); + log('info', `Created backup of existing package.json at ${backupPath}`); + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); + log( + 'warn', + 'Created new package.json (backup of original file was created)' + ); + } + } else { + // If package.json doesn't exist, create it + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); + log('success', 'Created package.json'); + } + + // Setup MCP configuration for integration with Cursor + setupMCPConfiguration(targetDir, packageJson.name); + + // Copy template files with replacements + const replacements = { + projectName, + projectDescription, + projectVersion, + authorName, + year: new Date().getFullYear() + }; + + // Copy .env.example + copyTemplateFile( + 'env.example', + path.join(targetDir, '.env.example'), + replacements + ); + + // Copy .gitignore + copyTemplateFile('gitignore', path.join(targetDir, '.gitignore')); + + // Copy dev_workflow.mdc + copyTemplateFile( + 'dev_workflow.mdc', + path.join(targetDir, '.cursor', 'rules', 'dev_workflow.mdc') + ); + + // Copy cursor_rules.mdc + copyTemplateFile( + 'cursor_rules.mdc', + path.join(targetDir, '.cursor', 'rules', 'cursor_rules.mdc') + ); + + // Copy self_improve.mdc + copyTemplateFile( + 'self_improve.mdc', + path.join(targetDir, '.cursor', 'rules', 'self_improve.mdc') + ); + + // Copy .windsurfrules + copyTemplateFile('windsurfrules', path.join(targetDir, '.windsurfrules')); + + // Copy scripts/dev.js + copyTemplateFile('dev.js', path.join(targetDir, 'scripts', 'dev.js')); + + // Copy scripts/README.md + copyTemplateFile( + 'scripts_README.md', + path.join(targetDir, 'scripts', 'README.md') + ); + + // Copy example_prd.txt + copyTemplateFile( + 'example_prd.txt', + path.join(targetDir, 'scripts', 'example_prd.txt') + ); + + // Create main README.md + copyTemplateFile( + 'README-task-master.md', + path.join(targetDir, 'README.md'), + replacements + ); + + // Initialize git repository if git is available + try { + if (!fs.existsSync(path.join(targetDir, '.git'))) { + log('info', 'Initializing git repository...'); + execSync('git init', { stdio: 'ignore' }); + log('success', 'Git repository initialized'); + } + } catch (error) { + log('warn', 'Git not available, skipping repository initialization'); + } + + // Run npm install automatically + console.log( + boxen(chalk.cyan('Installing dependencies...'), { + padding: 0.5, + margin: 0.5, + borderStyle: 'round', + borderColor: 'blue' + }) + ); + + try { + if (!skipInstall) { + execSync('npm install', { stdio: 'inherit', cwd: targetDir }); + log('success', 'Dependencies installed successfully!'); + } else { + log('info', 'Dependencies installation skipped'); + } + } catch (error) { + log('error', 'Failed to install dependencies:', error.message); + log('error', 'Please run npm install manually'); + } + + // Display success message + console.log( + boxen( + warmGradient.multiline( + figlet.textSync('Success!', { font: 'Standard' }) + ) + + '\n' + + chalk.green('Project initialized successfully!'), + { + padding: 1, + margin: 1, + borderStyle: 'double', + borderColor: 'green' + } + ) + ); + + // Add shell aliases if requested + if (addAliases) { + addShellAliases(); + } + + // Display next steps in a nice box + console.log( + boxen( + chalk.cyan.bold('Things you can now do:') + + '\n\n' + + chalk.white('1. ') + + chalk.yellow( + 'Rename .env.example to .env and add your ANTHROPIC_API_KEY and PERPLEXITY_API_KEY' + ) + + '\n' + + chalk.white('2. ') + + chalk.yellow( + 'Discuss your idea with AI, and once ready ask for a PRD using the example_prd.txt file, and save what you get to scripts/PRD.txt' + ) + + '\n' + + chalk.white('3. ') + + chalk.yellow( + 'Ask Cursor Agent to parse your PRD.txt and generate tasks' + ) + + '\n' + + chalk.white(' └─ ') + + chalk.dim('You can also run ') + + chalk.cyan('task-master parse-prd ') + + '\n' + + chalk.white('4. ') + + chalk.yellow('Ask Cursor to analyze the complexity of your tasks') + + '\n' + + chalk.white('5. ') + + chalk.yellow( + 'Ask Cursor which task is next to determine where to start' + ) + + '\n' + + chalk.white('6. ') + + chalk.yellow( + 'Ask Cursor to expand any complex tasks that are too large or complex.' + ) + + '\n' + + chalk.white('7. ') + + chalk.yellow( + 'Ask Cursor to set the status of a task, or multiple tasks. Use the task id from the task lists.' + ) + + '\n' + + chalk.white('8. ') + + chalk.yellow( + 'Ask Cursor to update all tasks from a specific task id based on new learnings or pivots in your project.' + ) + + '\n' + + chalk.white('9. ') + + chalk.green.bold('Ship it!') + + '\n\n' + + chalk.dim( + '* Review the README.md file to learn how to use other commands via Cursor Agent.' + ), + { + padding: 1, + margin: 1, + borderStyle: 'round', + borderColor: 'yellow', + title: 'Getting Started', + titleAlignment: 'center' + } + ) + ); } // Function to setup MCP configuration for Cursor integration function setupMCPConfiguration(targetDir, projectName) { - const mcpDirPath = path.join(targetDir, '.cursor'); - const mcpJsonPath = path.join(mcpDirPath, 'mcp.json'); - - log('info', 'Setting up MCP configuration for Cursor integration...'); - - // Create .cursor directory if it doesn't exist - ensureDirectoryExists(mcpDirPath); - - // New MCP config to be added - references the installed package - const newMCPServer = { - "task-master-ai": { - "command": "npx", - "args": [ - "task-master-ai", - "mcp-server" - ] - } - }; - - // Check if mcp.json already exists - if (fs.existsSync(mcpJsonPath)) { - log('info', 'MCP configuration file already exists, updating...'); - try { - // Read existing config - const mcpConfig = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf8')); - - // Initialize mcpServers if it doesn't exist - if (!mcpConfig.mcpServers) { - mcpConfig.mcpServers = {}; - } - - // Add the task-master-ai server if it doesn't exist - if (!mcpConfig.mcpServers["task-master-ai"]) { - mcpConfig.mcpServers["task-master-ai"] = newMCPServer["task-master-ai"]; - log('info', 'Added task-master-ai server to existing MCP configuration'); - } else { - log('info', 'task-master-ai server already configured in mcp.json'); - } - - // Write the updated configuration - fs.writeFileSync( - mcpJsonPath, - JSON.stringify(mcpConfig, null, 4) - ); - log('success', 'Updated MCP configuration file'); - } catch (error) { - log('error', `Failed to update MCP configuration: ${error.message}`); - // Create a backup before potentially modifying - const backupPath = `${mcpJsonPath}.backup-${Date.now()}`; - if (fs.existsSync(mcpJsonPath)) { - fs.copyFileSync(mcpJsonPath, backupPath); - log('info', `Created backup of existing mcp.json at ${backupPath}`); - } - - // Create new configuration - const newMCPConfig = { - "mcpServers": newMCPServer - }; - - fs.writeFileSync(mcpJsonPath, JSON.stringify(newMCPConfig, null, 4)); - log('warn', 'Created new MCP configuration file (backup of original file was created if it existed)'); - } - } else { - // If mcp.json doesn't exist, create it - const newMCPConfig = { - "mcpServers": newMCPServer - }; - - fs.writeFileSync(mcpJsonPath, JSON.stringify(newMCPConfig, null, 4)); - log('success', 'Created MCP configuration file for Cursor integration'); - } - - // Add note to console about MCP integration - log('info', 'MCP server will use the installed task-master-ai package'); + const mcpDirPath = path.join(targetDir, '.cursor'); + const mcpJsonPath = path.join(mcpDirPath, 'mcp.json'); + + log('info', 'Setting up MCP configuration for Cursor integration...'); + + // Create .cursor directory if it doesn't exist + ensureDirectoryExists(mcpDirPath); + + // New MCP config to be added - references the installed package + const newMCPServer = { + 'task-master-ai': { + command: 'npx', + args: ['task-master-ai', 'mcp-server'] + } + }; + + // Check if mcp.json already exists + if (fs.existsSync(mcpJsonPath)) { + log('info', 'MCP configuration file already exists, updating...'); + try { + // Read existing config + const mcpConfig = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf8')); + + // Initialize mcpServers if it doesn't exist + if (!mcpConfig.mcpServers) { + mcpConfig.mcpServers = {}; + } + + // Add the task-master-ai server if it doesn't exist + if (!mcpConfig.mcpServers['task-master-ai']) { + mcpConfig.mcpServers['task-master-ai'] = newMCPServer['task-master-ai']; + log( + 'info', + 'Added task-master-ai server to existing MCP configuration' + ); + } else { + log('info', 'task-master-ai server already configured in mcp.json'); + } + + // Write the updated configuration + fs.writeFileSync(mcpJsonPath, JSON.stringify(mcpConfig, null, 4)); + log('success', 'Updated MCP configuration file'); + } catch (error) { + log('error', `Failed to update MCP configuration: ${error.message}`); + // Create a backup before potentially modifying + const backupPath = `${mcpJsonPath}.backup-${Date.now()}`; + if (fs.existsSync(mcpJsonPath)) { + fs.copyFileSync(mcpJsonPath, backupPath); + log('info', `Created backup of existing mcp.json at ${backupPath}`); + } + + // Create new configuration + const newMCPConfig = { + mcpServers: newMCPServer + }; + + fs.writeFileSync(mcpJsonPath, JSON.stringify(newMCPConfig, null, 4)); + log( + 'warn', + 'Created new MCP configuration file (backup of original file was created if it existed)' + ); + } + } else { + // If mcp.json doesn't exist, create it + const newMCPConfig = { + mcpServers: newMCPServer + }; + + fs.writeFileSync(mcpJsonPath, JSON.stringify(newMCPConfig, null, 4)); + log('success', 'Created MCP configuration file for Cursor integration'); + } + + // Add note to console about MCP integration + log('info', 'MCP server will use the installed task-master-ai package'); } // Run the initialization if this script is executed directly @@ -768,42 +963,40 @@ console.log('process.argv:', process.argv); // Always run initialization when this file is loaded directly // This works with both direct node execution and npx/global commands (async function main() { - try { - console.log('Starting initialization...'); - - // Check if we should use the CLI options or prompt for input - if (options.yes || (options.name && options.description)) { - // When using --yes flag or providing name and description, use CLI options - await initializeProject({ - projectName: options.name || 'task-master-project', - projectDescription: options.description || 'A task management system for AI-driven development', - projectVersion: options.version || '1.0.0', - authorName: options.author || '', - dryRun: options.dryRun || false, - skipInstall: options.skipInstall || false, - addAliases: options.aliases || false - }); - } else { - // Otherwise, prompt for input normally - await initializeProject({ - dryRun: options.dryRun || false, - skipInstall: options.skipInstall || false - }); - } - - // Process should exit naturally after completion - console.log('Initialization completed, exiting...'); - process.exit(0); - } catch (error) { - console.error('Failed to initialize project:', error); - log('error', 'Failed to initialize project:', error); - process.exit(1); - } + try { + console.log('Starting initialization...'); + + // Check if we should use the CLI options or prompt for input + if (options.yes || (options.name && options.description)) { + // When using --yes flag or providing name and description, use CLI options + await initializeProject({ + projectName: options.name || 'task-master-project', + projectDescription: + options.description || + 'A task management system for AI-driven development', + projectVersion: options.version || '1.0.0', + authorName: options.author || '', + dryRun: options.dryRun || false, + skipInstall: options.skipInstall || false, + addAliases: options.aliases || false + }); + } else { + // Otherwise, prompt for input normally + await initializeProject({ + dryRun: options.dryRun || false, + skipInstall: options.skipInstall || false + }); + } + + // Process should exit naturally after completion + console.log('Initialization completed, exiting...'); + process.exit(0); + } catch (error) { + console.error('Failed to initialize project:', error); + log('error', 'Failed to initialize project:', error); + process.exit(1); + } })(); // Export functions for programmatic use -export { - initializeProject, - createProjectStructure, - log -}; \ No newline at end of file +export { initializeProject, createProjectStructure, log }; diff --git a/scripts/modules/ai-services.js b/scripts/modules/ai-services.js index 4850fb97..597ae74b 100644 --- a/scripts/modules/ai-services.js +++ b/scripts/modules/ai-services.js @@ -17,11 +17,11 @@ dotenv.config(); // Configure Anthropic client const anthropic = new Anthropic({ - apiKey: process.env.ANTHROPIC_API_KEY, - // Add beta header for 128k token output - defaultHeaders: { - 'anthropic-beta': 'output-128k-2025-02-19' - } + apiKey: process.env.ANTHROPIC_API_KEY, + // Add beta header for 128k token output + defaultHeaders: { + 'anthropic-beta': 'output-128k-2025-02-19' + } }); // Lazy-loaded Perplexity client @@ -32,16 +32,18 @@ let perplexity = null; * @returns {OpenAI} Perplexity client */ function getPerplexityClient() { - if (!perplexity) { - if (!process.env.PERPLEXITY_API_KEY) { - throw new Error("PERPLEXITY_API_KEY environment variable is missing. Set it to use research-backed features."); - } - perplexity = new OpenAI({ - apiKey: process.env.PERPLEXITY_API_KEY, - baseURL: 'https://api.perplexity.ai', - }); - } - return perplexity; + if (!perplexity) { + if (!process.env.PERPLEXITY_API_KEY) { + throw new Error( + 'PERPLEXITY_API_KEY environment variable is missing. Set it to use research-backed features.' + ); + } + perplexity = new OpenAI({ + apiKey: process.env.PERPLEXITY_API_KEY, + baseURL: 'https://api.perplexity.ai' + }); + } + return perplexity; } /** @@ -52,46 +54,51 @@ function getPerplexityClient() { * @returns {Object} Selected model info with type and client */ function getAvailableAIModel(options = {}) { - const { claudeOverloaded = false, requiresResearch = false } = options; - - // First choice: Perplexity if research is required and it's available - if (requiresResearch && process.env.PERPLEXITY_API_KEY) { - try { - const client = getPerplexityClient(); - return { type: 'perplexity', client }; - } catch (error) { - log('warn', `Perplexity not available: ${error.message}`); - // Fall through to Claude - } - } - - // Second choice: Claude if not overloaded - if (!claudeOverloaded && process.env.ANTHROPIC_API_KEY) { - return { type: 'claude', client: anthropic }; - } - - // Third choice: Perplexity as Claude fallback (even if research not required) - if (process.env.PERPLEXITY_API_KEY) { - try { - const client = getPerplexityClient(); - log('info', 'Claude is overloaded, falling back to Perplexity'); - return { type: 'perplexity', client }; - } catch (error) { - log('warn', `Perplexity fallback not available: ${error.message}`); - // Fall through to Claude anyway with warning - } - } - - // Last resort: Use Claude even if overloaded (might fail) - if (process.env.ANTHROPIC_API_KEY) { - if (claudeOverloaded) { - log('warn', 'Claude is overloaded but no alternatives are available. Proceeding with Claude anyway.'); - } - return { type: 'claude', client: anthropic }; - } - - // No models available - throw new Error('No AI models available. Please set ANTHROPIC_API_KEY and/or PERPLEXITY_API_KEY.'); + const { claudeOverloaded = false, requiresResearch = false } = options; + + // First choice: Perplexity if research is required and it's available + if (requiresResearch && process.env.PERPLEXITY_API_KEY) { + try { + const client = getPerplexityClient(); + return { type: 'perplexity', client }; + } catch (error) { + log('warn', `Perplexity not available: ${error.message}`); + // Fall through to Claude + } + } + + // Second choice: Claude if not overloaded + if (!claudeOverloaded && process.env.ANTHROPIC_API_KEY) { + return { type: 'claude', client: anthropic }; + } + + // Third choice: Perplexity as Claude fallback (even if research not required) + if (process.env.PERPLEXITY_API_KEY) { + try { + const client = getPerplexityClient(); + log('info', 'Claude is overloaded, falling back to Perplexity'); + return { type: 'perplexity', client }; + } catch (error) { + log('warn', `Perplexity fallback not available: ${error.message}`); + // Fall through to Claude anyway with warning + } + } + + // Last resort: Use Claude even if overloaded (might fail) + if (process.env.ANTHROPIC_API_KEY) { + if (claudeOverloaded) { + log( + 'warn', + 'Claude is overloaded but no alternatives are available. Proceeding with Claude anyway.' + ); + } + return { type: 'claude', client: anthropic }; + } + + // No models available + throw new Error( + 'No AI models available. Please set ANTHROPIC_API_KEY and/or PERPLEXITY_API_KEY.' + ); } /** @@ -100,34 +107,34 @@ function getAvailableAIModel(options = {}) { * @returns {string} User-friendly error message */ function handleClaudeError(error) { - // Check if it's a structured error response - if (error.type === 'error' && error.error) { - switch (error.error.type) { - case 'overloaded_error': - // Check if we can use Perplexity as a fallback - if (process.env.PERPLEXITY_API_KEY) { - return 'Claude is currently overloaded. Trying to fall back to Perplexity AI.'; - } - return 'Claude is currently experiencing high demand and is overloaded. Please wait a few minutes and try again.'; - case 'rate_limit_error': - return 'You have exceeded the rate limit. Please wait a few minutes before making more requests.'; - case 'invalid_request_error': - return 'There was an issue with the request format. If this persists, please report it as a bug.'; - default: - return `Claude API error: ${error.error.message}`; - } - } - - // Check for network/timeout errors - if (error.message?.toLowerCase().includes('timeout')) { - return 'The request to Claude timed out. Please try again.'; - } - if (error.message?.toLowerCase().includes('network')) { - return 'There was a network error connecting to Claude. Please check your internet connection and try again.'; - } - - // Default error message - return `Error communicating with Claude: ${error.message}`; + // Check if it's a structured error response + if (error.type === 'error' && error.error) { + switch (error.error.type) { + case 'overloaded_error': + // Check if we can use Perplexity as a fallback + if (process.env.PERPLEXITY_API_KEY) { + return 'Claude is currently overloaded. Trying to fall back to Perplexity AI.'; + } + return 'Claude is currently experiencing high demand and is overloaded. Please wait a few minutes and try again.'; + case 'rate_limit_error': + return 'You have exceeded the rate limit. Please wait a few minutes before making more requests.'; + case 'invalid_request_error': + return 'There was an issue with the request format. If this persists, please report it as a bug.'; + default: + return `Claude API error: ${error.error.message}`; + } + } + + // Check for network/timeout errors + if (error.message?.toLowerCase().includes('timeout')) { + return 'The request to Claude timed out. Please try again.'; + } + if (error.message?.toLowerCase().includes('network')) { + return 'There was a network error connecting to Claude. Please check your internet connection and try again.'; + } + + // Default error message + return `Error communicating with Claude: ${error.message}`; } /** @@ -139,11 +146,11 @@ function handleClaudeError(error) { * @returns {Object} Claude's response */ async function callClaude(prdContent, prdPath, numTasks, retryCount = 0) { - try { - log('info', 'Calling Claude...'); - - // Build the system prompt - const systemPrompt = `You are an AI assistant helping to break down a Product Requirements Document (PRD) into a set of sequential development tasks. + try { + log('info', 'Calling Claude...'); + + // Build the system prompt + const systemPrompt = `You are an AI assistant helping to break down a Product Requirements Document (PRD) into a set of sequential development tasks. Your goal is to create ${numTasks} well-structured, actionable development tasks based on the PRD provided. Each task should follow this JSON structure: @@ -189,32 +196,42 @@ Expected output format: Important: Your response must be valid JSON only, with no additional explanation or comments.`; - // Use streaming request to handle large responses and show progress - return await handleStreamingRequest(prdContent, prdPath, numTasks, CONFIG.maxTokens, systemPrompt); - } catch (error) { - // Get user-friendly error message - const userMessage = handleClaudeError(error); - log('error', userMessage); + // Use streaming request to handle large responses and show progress + return await handleStreamingRequest( + prdContent, + prdPath, + numTasks, + CONFIG.maxTokens, + systemPrompt + ); + } catch (error) { + // Get user-friendly error message + const userMessage = handleClaudeError(error); + log('error', userMessage); - // Retry logic for certain errors - if (retryCount < 2 && ( - error.error?.type === 'overloaded_error' || - error.error?.type === 'rate_limit_error' || - error.message?.toLowerCase().includes('timeout') || - error.message?.toLowerCase().includes('network') - )) { - const waitTime = (retryCount + 1) * 5000; // 5s, then 10s - log('info', `Waiting ${waitTime/1000} seconds before retry ${retryCount + 1}/2...`); - await new Promise(resolve => setTimeout(resolve, waitTime)); - return await callClaude(prdContent, prdPath, numTasks, retryCount + 1); - } else { - console.error(chalk.red(userMessage)); - if (CONFIG.debug) { - log('debug', 'Full error:', error); - } - throw new Error(userMessage); - } - } + // Retry logic for certain errors + if ( + retryCount < 2 && + (error.error?.type === 'overloaded_error' || + error.error?.type === 'rate_limit_error' || + error.message?.toLowerCase().includes('timeout') || + error.message?.toLowerCase().includes('network')) + ) { + const waitTime = (retryCount + 1) * 5000; // 5s, then 10s + log( + 'info', + `Waiting ${waitTime / 1000} seconds before retry ${retryCount + 1}/2...` + ); + await new Promise((resolve) => setTimeout(resolve, waitTime)); + return await callClaude(prdContent, prdPath, numTasks, retryCount + 1); + } else { + console.error(chalk.red(userMessage)); + if (CONFIG.debug) { + log('debug', 'Full error:', error); + } + throw new Error(userMessage); + } + } } /** @@ -226,64 +243,80 @@ Important: Your response must be valid JSON only, with no additional explanation * @param {string} systemPrompt - System prompt * @returns {Object} Claude's response */ -async function handleStreamingRequest(prdContent, prdPath, numTasks, maxTokens, systemPrompt) { - const loadingIndicator = startLoadingIndicator('Generating tasks from PRD...'); - let responseText = ''; - let streamingInterval = null; - - try { - // Use streaming for handling large responses - const stream = await anthropic.messages.create({ - model: CONFIG.model, - max_tokens: maxTokens, - temperature: CONFIG.temperature, - system: systemPrompt, - messages: [ - { - role: 'user', - content: `Here's the Product Requirements Document (PRD) to break down into ${numTasks} tasks:\n\n${prdContent}` - } - ], - stream: true - }); - - // Update loading indicator to show streaming progress - let dotCount = 0; - const readline = await import('readline'); - streamingInterval = setInterval(() => { - readline.cursorTo(process.stdout, 0); - process.stdout.write(`Receiving streaming response from Claude${'.'.repeat(dotCount)}`); - dotCount = (dotCount + 1) % 4; - }, 500); - - // Process the stream - for await (const chunk of stream) { - if (chunk.type === 'content_block_delta' && chunk.delta.text) { - responseText += chunk.delta.text; - } - } - - if (streamingInterval) clearInterval(streamingInterval); - stopLoadingIndicator(loadingIndicator); - - log('info', "Completed streaming response from Claude API!"); - - return processClaudeResponse(responseText, numTasks, 0, prdContent, prdPath); - } catch (error) { - if (streamingInterval) clearInterval(streamingInterval); - stopLoadingIndicator(loadingIndicator); - - // Get user-friendly error message - const userMessage = handleClaudeError(error); - log('error', userMessage); - console.error(chalk.red(userMessage)); - - if (CONFIG.debug) { - log('debug', 'Full error:', error); - } - - throw new Error(userMessage); - } +async function handleStreamingRequest( + prdContent, + prdPath, + numTasks, + maxTokens, + systemPrompt +) { + const loadingIndicator = startLoadingIndicator( + 'Generating tasks from PRD...' + ); + let responseText = ''; + let streamingInterval = null; + + try { + // Use streaming for handling large responses + const stream = await anthropic.messages.create({ + model: CONFIG.model, + max_tokens: maxTokens, + temperature: CONFIG.temperature, + system: systemPrompt, + messages: [ + { + role: 'user', + content: `Here's the Product Requirements Document (PRD) to break down into ${numTasks} tasks:\n\n${prdContent}` + } + ], + stream: true + }); + + // Update loading indicator to show streaming progress + let dotCount = 0; + const readline = await import('readline'); + streamingInterval = setInterval(() => { + readline.cursorTo(process.stdout, 0); + process.stdout.write( + `Receiving streaming response from Claude${'.'.repeat(dotCount)}` + ); + dotCount = (dotCount + 1) % 4; + }, 500); + + // Process the stream + for await (const chunk of stream) { + if (chunk.type === 'content_block_delta' && chunk.delta.text) { + responseText += chunk.delta.text; + } + } + + if (streamingInterval) clearInterval(streamingInterval); + stopLoadingIndicator(loadingIndicator); + + log('info', 'Completed streaming response from Claude API!'); + + return processClaudeResponse( + responseText, + numTasks, + 0, + prdContent, + prdPath + ); + } catch (error) { + if (streamingInterval) clearInterval(streamingInterval); + stopLoadingIndicator(loadingIndicator); + + // Get user-friendly error message + const userMessage = handleClaudeError(error); + log('error', userMessage); + console.error(chalk.red(userMessage)); + + if (CONFIG.debug) { + log('debug', 'Full error:', error); + } + + throw new Error(userMessage); + } } /** @@ -295,58 +328,73 @@ async function handleStreamingRequest(prdContent, prdPath, numTasks, maxTokens, * @param {string} prdPath - Path to the PRD file * @returns {Object} Processed response */ -function processClaudeResponse(textContent, numTasks, retryCount, prdContent, prdPath) { - try { - // Attempt to parse the JSON response - let jsonStart = textContent.indexOf('{'); - let jsonEnd = textContent.lastIndexOf('}'); - - if (jsonStart === -1 || jsonEnd === -1) { - throw new Error("Could not find valid JSON in Claude's response"); - } - - let jsonContent = textContent.substring(jsonStart, jsonEnd + 1); - let parsedData = JSON.parse(jsonContent); - - // Validate the structure of the generated tasks - if (!parsedData.tasks || !Array.isArray(parsedData.tasks)) { - throw new Error("Claude's response does not contain a valid tasks array"); - } - - // Ensure we have the correct number of tasks - if (parsedData.tasks.length !== numTasks) { - log('warn', `Expected ${numTasks} tasks, but received ${parsedData.tasks.length}`); - } - - // Add metadata if missing - if (!parsedData.metadata) { - parsedData.metadata = { - projectName: "PRD Implementation", - totalTasks: parsedData.tasks.length, - sourceFile: prdPath, - generatedAt: new Date().toISOString().split('T')[0] - }; - } - - return parsedData; - } catch (error) { - log('error', "Error processing Claude's response:", error.message); - - // Retry logic - if (retryCount < 2) { - log('info', `Retrying to parse response (${retryCount + 1}/2)...`); - - // Try again with Claude for a cleaner response - if (retryCount === 1) { - log('info', "Calling Claude again for a cleaner response..."); - return callClaude(prdContent, prdPath, numTasks, retryCount + 1); - } - - return processClaudeResponse(textContent, numTasks, retryCount + 1, prdContent, prdPath); - } else { - throw error; - } - } +function processClaudeResponse( + textContent, + numTasks, + retryCount, + prdContent, + prdPath +) { + try { + // Attempt to parse the JSON response + let jsonStart = textContent.indexOf('{'); + let jsonEnd = textContent.lastIndexOf('}'); + + if (jsonStart === -1 || jsonEnd === -1) { + throw new Error("Could not find valid JSON in Claude's response"); + } + + let jsonContent = textContent.substring(jsonStart, jsonEnd + 1); + let parsedData = JSON.parse(jsonContent); + + // Validate the structure of the generated tasks + if (!parsedData.tasks || !Array.isArray(parsedData.tasks)) { + throw new Error("Claude's response does not contain a valid tasks array"); + } + + // Ensure we have the correct number of tasks + if (parsedData.tasks.length !== numTasks) { + log( + 'warn', + `Expected ${numTasks} tasks, but received ${parsedData.tasks.length}` + ); + } + + // Add metadata if missing + if (!parsedData.metadata) { + parsedData.metadata = { + projectName: 'PRD Implementation', + totalTasks: parsedData.tasks.length, + sourceFile: prdPath, + generatedAt: new Date().toISOString().split('T')[0] + }; + } + + return parsedData; + } catch (error) { + log('error', "Error processing Claude's response:", error.message); + + // Retry logic + if (retryCount < 2) { + log('info', `Retrying to parse response (${retryCount + 1}/2)...`); + + // Try again with Claude for a cleaner response + if (retryCount === 1) { + log('info', 'Calling Claude again for a cleaner response...'); + return callClaude(prdContent, prdPath, numTasks, retryCount + 1); + } + + return processClaudeResponse( + textContent, + numTasks, + retryCount + 1, + prdContent, + prdPath + ); + } else { + throw error; + } + } } /** @@ -357,15 +405,25 @@ function processClaudeResponse(textContent, numTasks, retryCount, prdContent, pr * @param {string} additionalContext - Additional context * @returns {Array} Generated subtasks */ -async function generateSubtasks(task, numSubtasks, nextSubtaskId, additionalContext = '') { - try { - log('info', `Generating ${numSubtasks} subtasks for task ${task.id}: ${task.title}`); - - const loadingIndicator = startLoadingIndicator(`Generating subtasks for task ${task.id}...`); - let streamingInterval = null; - let responseText = ''; - - const systemPrompt = `You are an AI assistant helping with task breakdown for software development. +async function generateSubtasks( + task, + numSubtasks, + nextSubtaskId, + additionalContext = '' +) { + try { + log( + 'info', + `Generating ${numSubtasks} subtasks for task ${task.id}: ${task.title}` + ); + + const loadingIndicator = startLoadingIndicator( + `Generating subtasks for task ${task.id}...` + ); + let streamingInterval = null; + let responseText = ''; + + const systemPrompt = `You are an AI assistant helping with task breakdown for software development. You need to break down a high-level task into ${numSubtasks} specific subtasks that can be implemented one by one. Subtasks should: @@ -384,10 +442,11 @@ For each subtask, provide: Each subtask should be implementable in a focused coding session.`; - const contextPrompt = additionalContext ? - `\n\nAdditional context to consider: ${additionalContext}` : ''; - - const userPrompt = `Please break down this task into ${numSubtasks} specific, actionable subtasks: + const contextPrompt = additionalContext + ? `\n\nAdditional context to consider: ${additionalContext}` + : ''; + + const userPrompt = `Please break down this task into ${numSubtasks} specific, actionable subtasks: Task ID: ${task.id} Title: ${task.title} @@ -409,53 +468,60 @@ Return exactly ${numSubtasks} subtasks with the following JSON structure: Note on dependencies: Subtasks can depend on other subtasks with lower IDs. Use an empty array if there are no dependencies.`; - try { - // Update loading indicator to show streaming progress - let dotCount = 0; - const readline = await import('readline'); - streamingInterval = setInterval(() => { - readline.cursorTo(process.stdout, 0); - process.stdout.write(`Generating subtasks for task ${task.id}${'.'.repeat(dotCount)}`); - dotCount = (dotCount + 1) % 4; - }, 500); - - // Use streaming API call - const stream = await anthropic.messages.create({ - model: CONFIG.model, - max_tokens: CONFIG.maxTokens, - temperature: CONFIG.temperature, - system: systemPrompt, - messages: [ - { - role: 'user', - content: userPrompt - } - ], - stream: true - }); - - // Process the stream - for await (const chunk of stream) { - if (chunk.type === 'content_block_delta' && chunk.delta.text) { - responseText += chunk.delta.text; - } - } - - if (streamingInterval) clearInterval(streamingInterval); - stopLoadingIndicator(loadingIndicator); - - log('info', `Completed generating subtasks for task ${task.id}`); - - return parseSubtasksFromText(responseText, nextSubtaskId, numSubtasks, task.id); - } catch (error) { - if (streamingInterval) clearInterval(streamingInterval); - stopLoadingIndicator(loadingIndicator); - throw error; - } - } catch (error) { - log('error', `Error generating subtasks: ${error.message}`); - throw error; - } + try { + // Update loading indicator to show streaming progress + let dotCount = 0; + const readline = await import('readline'); + streamingInterval = setInterval(() => { + readline.cursorTo(process.stdout, 0); + process.stdout.write( + `Generating subtasks for task ${task.id}${'.'.repeat(dotCount)}` + ); + dotCount = (dotCount + 1) % 4; + }, 500); + + // Use streaming API call + const stream = await anthropic.messages.create({ + model: CONFIG.model, + max_tokens: CONFIG.maxTokens, + temperature: CONFIG.temperature, + system: systemPrompt, + messages: [ + { + role: 'user', + content: userPrompt + } + ], + stream: true + }); + + // Process the stream + for await (const chunk of stream) { + if (chunk.type === 'content_block_delta' && chunk.delta.text) { + responseText += chunk.delta.text; + } + } + + if (streamingInterval) clearInterval(streamingInterval); + stopLoadingIndicator(loadingIndicator); + + log('info', `Completed generating subtasks for task ${task.id}`); + + return parseSubtasksFromText( + responseText, + nextSubtaskId, + numSubtasks, + task.id + ); + } catch (error) { + if (streamingInterval) clearInterval(streamingInterval); + stopLoadingIndicator(loadingIndicator); + throw error; + } + } catch (error) { + log('error', `Error generating subtasks: ${error.message}`); + throw error; + } } /** @@ -466,50 +532,64 @@ Note on dependencies: Subtasks can depend on other subtasks with lower IDs. Use * @param {string} additionalContext - Additional context * @returns {Array} Generated subtasks */ -async function generateSubtasksWithPerplexity(task, numSubtasks = 3, nextSubtaskId = 1, additionalContext = '') { - try { - // First, perform research to get context - log('info', `Researching context for task ${task.id}: ${task.title}`); - const perplexityClient = getPerplexityClient(); - - const PERPLEXITY_MODEL = process.env.PERPLEXITY_MODEL || 'sonar-pro'; - const researchLoadingIndicator = startLoadingIndicator('Researching best practices with Perplexity AI...'); - - // Formulate research query based on task - const researchQuery = `I need to implement "${task.title}" which involves: "${task.description}". +async function generateSubtasksWithPerplexity( + task, + numSubtasks = 3, + nextSubtaskId = 1, + additionalContext = '' +) { + try { + // First, perform research to get context + log('info', `Researching context for task ${task.id}: ${task.title}`); + const perplexityClient = getPerplexityClient(); + + const PERPLEXITY_MODEL = process.env.PERPLEXITY_MODEL || 'sonar-pro'; + const researchLoadingIndicator = startLoadingIndicator( + 'Researching best practices with Perplexity AI...' + ); + + // Formulate research query based on task + const researchQuery = `I need to implement "${task.title}" which involves: "${task.description}". What are current best practices, libraries, design patterns, and implementation approaches? Include concrete code examples and technical considerations where relevant.`; - - // Query Perplexity for research - const researchResponse = await perplexityClient.chat.completions.create({ - model: PERPLEXITY_MODEL, - messages: [{ - role: 'user', - content: researchQuery - }], - temperature: 0.1 // Lower temperature for more factual responses - }); - - const researchResult = researchResponse.choices[0].message.content; - - stopLoadingIndicator(researchLoadingIndicator); - log('info', 'Research completed, now generating subtasks with additional context'); - - // Use the research result as additional context for Claude to generate subtasks - const combinedContext = ` + + // Query Perplexity for research + const researchResponse = await perplexityClient.chat.completions.create({ + model: PERPLEXITY_MODEL, + messages: [ + { + role: 'user', + content: researchQuery + } + ], + temperature: 0.1 // Lower temperature for more factual responses + }); + + const researchResult = researchResponse.choices[0].message.content; + + stopLoadingIndicator(researchLoadingIndicator); + log( + 'info', + 'Research completed, now generating subtasks with additional context' + ); + + // Use the research result as additional context for Claude to generate subtasks + const combinedContext = ` RESEARCH FINDINGS: ${researchResult} ADDITIONAL CONTEXT PROVIDED BY USER: -${additionalContext || "No additional context provided."} +${additionalContext || 'No additional context provided.'} `; - - // Now generate subtasks with Claude - const loadingIndicator = startLoadingIndicator(`Generating research-backed subtasks for task ${task.id}...`); - let streamingInterval = null; - let responseText = ''; - - const systemPrompt = `You are an AI assistant helping with task breakdown for software development. + + // Now generate subtasks with Claude + const loadingIndicator = startLoadingIndicator( + `Generating research-backed subtasks for task ${task.id}...` + ); + let streamingInterval = null; + let responseText = ''; + + const systemPrompt = `You are an AI assistant helping with task breakdown for software development. You need to break down a high-level task into ${numSubtasks} specific subtasks that can be implemented one by one. You have been provided with research on current best practices and implementation approaches. @@ -531,7 +611,7 @@ For each subtask, provide: Each subtask should be implementable in a focused coding session.`; - const userPrompt = `Please break down this task into ${numSubtasks} specific, well-researched, actionable subtasks: + const userPrompt = `Please break down this task into ${numSubtasks} specific, well-researched, actionable subtasks: Task ID: ${task.id} Title: ${task.title} @@ -554,53 +634,63 @@ Return exactly ${numSubtasks} subtasks with the following JSON structure: Note on dependencies: Subtasks can depend on other subtasks with lower IDs. Use an empty array if there are no dependencies.`; - try { - // Update loading indicator to show streaming progress - let dotCount = 0; - const readline = await import('readline'); - streamingInterval = setInterval(() => { - readline.cursorTo(process.stdout, 0); - process.stdout.write(`Generating research-backed subtasks for task ${task.id}${'.'.repeat(dotCount)}`); - dotCount = (dotCount + 1) % 4; - }, 500); - - // Use streaming API call - const stream = await anthropic.messages.create({ - model: CONFIG.model, - max_tokens: CONFIG.maxTokens, - temperature: CONFIG.temperature, - system: systemPrompt, - messages: [ - { - role: 'user', - content: userPrompt - } - ], - stream: true - }); - - // Process the stream - for await (const chunk of stream) { - if (chunk.type === 'content_block_delta' && chunk.delta.text) { - responseText += chunk.delta.text; - } - } - - if (streamingInterval) clearInterval(streamingInterval); - stopLoadingIndicator(loadingIndicator); - - log('info', `Completed generating research-backed subtasks for task ${task.id}`); - - return parseSubtasksFromText(responseText, nextSubtaskId, numSubtasks, task.id); - } catch (error) { - if (streamingInterval) clearInterval(streamingInterval); - stopLoadingIndicator(loadingIndicator); - throw error; - } - } catch (error) { - log('error', `Error generating research-backed subtasks: ${error.message}`); - throw error; - } + try { + // Update loading indicator to show streaming progress + let dotCount = 0; + const readline = await import('readline'); + streamingInterval = setInterval(() => { + readline.cursorTo(process.stdout, 0); + process.stdout.write( + `Generating research-backed subtasks for task ${task.id}${'.'.repeat(dotCount)}` + ); + dotCount = (dotCount + 1) % 4; + }, 500); + + // Use streaming API call + const stream = await anthropic.messages.create({ + model: CONFIG.model, + max_tokens: CONFIG.maxTokens, + temperature: CONFIG.temperature, + system: systemPrompt, + messages: [ + { + role: 'user', + content: userPrompt + } + ], + stream: true + }); + + // Process the stream + for await (const chunk of stream) { + if (chunk.type === 'content_block_delta' && chunk.delta.text) { + responseText += chunk.delta.text; + } + } + + if (streamingInterval) clearInterval(streamingInterval); + stopLoadingIndicator(loadingIndicator); + + log( + 'info', + `Completed generating research-backed subtasks for task ${task.id}` + ); + + return parseSubtasksFromText( + responseText, + nextSubtaskId, + numSubtasks, + task.id + ); + } catch (error) { + if (streamingInterval) clearInterval(streamingInterval); + stopLoadingIndicator(loadingIndicator); + throw error; + } + } catch (error) { + log('error', `Error generating research-backed subtasks: ${error.message}`); + throw error; + } } /** @@ -612,78 +702,89 @@ Note on dependencies: Subtasks can depend on other subtasks with lower IDs. Use * @returns {Array} Parsed subtasks */ function parseSubtasksFromText(text, startId, expectedCount, parentTaskId) { - try { - // Locate JSON array in the text - const jsonStartIndex = text.indexOf('['); - const jsonEndIndex = text.lastIndexOf(']'); - - if (jsonStartIndex === -1 || jsonEndIndex === -1 || jsonEndIndex < jsonStartIndex) { - throw new Error("Could not locate valid JSON array in the response"); - } - - // Extract and parse the JSON - const jsonText = text.substring(jsonStartIndex, jsonEndIndex + 1); - let subtasks = JSON.parse(jsonText); - - // Validate - if (!Array.isArray(subtasks)) { - throw new Error("Parsed content is not an array"); - } - - // Log warning if count doesn't match expected - if (subtasks.length !== expectedCount) { - log('warn', `Expected ${expectedCount} subtasks, but parsed ${subtasks.length}`); - } - - // Normalize subtask IDs if they don't match - subtasks = subtasks.map((subtask, index) => { - // Assign the correct ID if it doesn't match - if (subtask.id !== startId + index) { - log('warn', `Correcting subtask ID from ${subtask.id} to ${startId + index}`); - subtask.id = startId + index; - } - - // Convert dependencies to numbers if they are strings - if (subtask.dependencies && Array.isArray(subtask.dependencies)) { - subtask.dependencies = subtask.dependencies.map(dep => { - return typeof dep === 'string' ? parseInt(dep, 10) : dep; - }); - } else { - subtask.dependencies = []; - } - - // Ensure status is 'pending' - subtask.status = 'pending'; - - // Add parentTaskId - subtask.parentTaskId = parentTaskId; - - return subtask; - }); - - return subtasks; - } catch (error) { - log('error', `Error parsing subtasks: ${error.message}`); - - // Create a fallback array of empty subtasks if parsing fails - log('warn', 'Creating fallback subtasks'); - - const fallbackSubtasks = []; - - for (let i = 0; i < expectedCount; i++) { - fallbackSubtasks.push({ - id: startId + i, - title: `Subtask ${startId + i}`, - description: "Auto-generated fallback subtask", - dependencies: [], - details: "This is a fallback subtask created because parsing failed. Please update with real details.", - status: 'pending', - parentTaskId: parentTaskId - }); - } - - return fallbackSubtasks; - } + try { + // Locate JSON array in the text + const jsonStartIndex = text.indexOf('['); + const jsonEndIndex = text.lastIndexOf(']'); + + if ( + jsonStartIndex === -1 || + jsonEndIndex === -1 || + jsonEndIndex < jsonStartIndex + ) { + throw new Error('Could not locate valid JSON array in the response'); + } + + // Extract and parse the JSON + const jsonText = text.substring(jsonStartIndex, jsonEndIndex + 1); + let subtasks = JSON.parse(jsonText); + + // Validate + if (!Array.isArray(subtasks)) { + throw new Error('Parsed content is not an array'); + } + + // Log warning if count doesn't match expected + if (subtasks.length !== expectedCount) { + log( + 'warn', + `Expected ${expectedCount} subtasks, but parsed ${subtasks.length}` + ); + } + + // Normalize subtask IDs if they don't match + subtasks = subtasks.map((subtask, index) => { + // Assign the correct ID if it doesn't match + if (subtask.id !== startId + index) { + log( + 'warn', + `Correcting subtask ID from ${subtask.id} to ${startId + index}` + ); + subtask.id = startId + index; + } + + // Convert dependencies to numbers if they are strings + if (subtask.dependencies && Array.isArray(subtask.dependencies)) { + subtask.dependencies = subtask.dependencies.map((dep) => { + return typeof dep === 'string' ? parseInt(dep, 10) : dep; + }); + } else { + subtask.dependencies = []; + } + + // Ensure status is 'pending' + subtask.status = 'pending'; + + // Add parentTaskId + subtask.parentTaskId = parentTaskId; + + return subtask; + }); + + return subtasks; + } catch (error) { + log('error', `Error parsing subtasks: ${error.message}`); + + // Create a fallback array of empty subtasks if parsing fails + log('warn', 'Creating fallback subtasks'); + + const fallbackSubtasks = []; + + for (let i = 0; i < expectedCount; i++) { + fallbackSubtasks.push({ + id: startId + i, + title: `Subtask ${startId + i}`, + description: 'Auto-generated fallback subtask', + dependencies: [], + details: + 'This is a fallback subtask created because parsing failed. Please update with real details.', + status: 'pending', + parentTaskId: parentTaskId + }); + } + + return fallbackSubtasks; + } } /** @@ -692,16 +793,20 @@ function parseSubtasksFromText(text, startId, expectedCount, parentTaskId) { * @returns {string} Generated prompt */ function generateComplexityAnalysisPrompt(tasksData) { - return `Analyze the complexity of the following tasks and provide recommendations for subtask breakdown: + return `Analyze the complexity of the following tasks and provide recommendations for subtask breakdown: -${tasksData.tasks.map(task => ` +${tasksData.tasks + .map( + (task) => ` Task ID: ${task.id} Title: ${task.title} Description: ${task.description} Details: ${task.details} Dependencies: ${JSON.stringify(task.dependencies || [])} Priority: ${task.priority || 'medium'} -`).join('\n---\n')} +` + ) + .join('\n---\n')} Analyze each task and return a JSON array with the following structure for each task: [ @@ -722,14 +827,14 @@ IMPORTANT: Make sure to include an analysis for EVERY task listed above, with th // Export AI service functions export { - getPerplexityClient, - callClaude, - handleStreamingRequest, - processClaudeResponse, - generateSubtasks, - generateSubtasksWithPerplexity, - parseSubtasksFromText, - generateComplexityAnalysisPrompt, - handleClaudeError, - getAvailableAIModel -}; \ No newline at end of file + getPerplexityClient, + callClaude, + handleStreamingRequest, + processClaudeResponse, + generateSubtasks, + generateSubtasksWithPerplexity, + parseSubtasksFromText, + generateComplexityAnalysisPrompt, + handleClaudeError, + getAvailableAIModel +}; diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index 5fbf6327..caac218e 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -12,37 +12,37 @@ import https from 'https'; import { CONFIG, log, readJSON } from './utils.js'; import { - parsePRD, - updateTasks, - generateTaskFiles, - setTaskStatus, - listTasks, - expandTask, - expandAllTasks, - clearSubtasks, - addTask, - addSubtask, - removeSubtask, - analyzeTaskComplexity, - updateTaskById, - updateSubtaskById + parsePRD, + updateTasks, + generateTaskFiles, + setTaskStatus, + listTasks, + expandTask, + expandAllTasks, + clearSubtasks, + addTask, + addSubtask, + removeSubtask, + analyzeTaskComplexity, + updateTaskById, + updateSubtaskById } from './task-manager.js'; import { - addDependency, - removeDependency, - validateDependenciesCommand, - fixDependenciesCommand + addDependency, + removeDependency, + validateDependenciesCommand, + fixDependenciesCommand } from './dependency-manager.js'; import { - displayBanner, - displayHelp, - displayNextTask, - displayTaskById, - displayComplexityReport, - getStatusWithColor, - confirmTaskOverwrite + displayBanner, + displayHelp, + displayNextTask, + displayTaskById, + displayComplexityReport, + getStatusWithColor, + confirmTaskOverwrite } from './ui.js'; /** @@ -50,823 +50,1256 @@ import { * @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); - }); + // 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); + }); - // update command - programInstance - .command('update') - .description('Update tasks 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); - const prompt = options.prompt; - const useResearch = options.research || false; - - 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')); - } - - await updateTasks(tasksPath, fromId, prompt, useResearch); - }); + // Default help + programInstance.on('--help', function () { + displayHelp(); + }); - // update-task command - programInstance - .command('update-task') - .description('Update a single task by ID with new information') - .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 (!process.env.PERPLEXITY_API_KEY) { - 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.')); - } - - if (CONFIG.debug) { - console.error(error); - } - - process.exit(1); - } - }); + // 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; - // 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 (!process.env.PERPLEXITY_API_KEY) { - 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.')); - } - - if (CONFIG.debug) { - console.error(error); - } - - process.exit(1); - } - }); + // 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; + } - // 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); - }); + // 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}`)); - // 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); - }); + // Check for existing tasks.json before proceeding + if (!(await confirmOverwriteIfNeeded())) return; - // 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); - }); + console.log(chalk.blue(`Generating ${numTasks} tasks...`)); + await parsePRD(defaultPrdPath, outputPath, numTasks); + return; + } - // expand command - programInstance - .command('expand') - .description('Break down tasks into detailed subtasks') - .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') - .option('-i, --id ', 'Task ID to expand') - .option('-a, --all', 'Expand all tasks') - .option('-n, --num ', 'Number of subtasks to generate', CONFIG.defaultSubtasks.toString()) - .option('--research', 'Enable Perplexity AI for research-backed subtask generation') - .option('-p, --prompt ', 'Additional context to guide subtask generation') - .option('--force', 'Force regeneration of subtasks for tasks that already have them') - .action(async (options) => { - const tasksPath = options.file; - const idArg = options.id ? parseInt(options.id, 10) : null; - const allFlag = options.all; - const numSubtasks = parseInt(options.num, 10); - const forceFlag = options.force; - const useResearch = options.research === true; - const additionalContext = options.prompt || ''; - - // Debug log to verify the value - log('debug', `Research enabled: ${useResearch}`); - - if (allFlag) { - console.log(chalk.blue(`Expanding all tasks with ${numSubtasks} subtasks each...`)); - if (useResearch) { - console.log(chalk.blue('Using Perplexity AI for research-backed subtask generation')); - } else { - console.log(chalk.yellow('Research-backed subtask generation disabled')); - } - if (additionalContext) { - console.log(chalk.blue(`Additional context: "${additionalContext}"`)); - } - await expandAllTasks(numSubtasks, useResearch, additionalContext, forceFlag); - } else if (idArg) { - console.log(chalk.blue(`Expanding task ${idArg} with ${numSubtasks} subtasks...`)); - if (useResearch) { - console.log(chalk.blue('Using Perplexity AI for research-backed subtask generation')); - } else { - console.log(chalk.yellow('Research-backed subtask generation disabled')); - } - if (additionalContext) { - console.log(chalk.blue(`Additional context: "${additionalContext}"`)); - } - await expandTask(idArg, numSubtasks, useResearch, additionalContext); - } else { - console.error(chalk.red('Error: Please specify a task ID with --id= or use --all to expand all tasks.')); - } - }); + 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; + } - // 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); - }); + // Check for existing tasks.json before proceeding with specified input file + if (!(await confirmOverwriteIfNeeded())) return; - // 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; + console.log(chalk.blue(`Parsing PRD file: ${inputFile}`)); + console.log(chalk.blue(`Generating ${numTasks} tasks...`)); - if (!taskIds && !all) { - console.error(chalk.red('Error: Please specify task IDs with --id= or use --all to clear all tasks')); - process.exit(1); - } + await parsePRD(inputFile, outputPath, numTasks); + }); - 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); - } - }); + // update command + programInstance + .command('update') + .description( + 'Update tasks 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); + const prompt = options.prompt; + const useResearch = options.research || false; - // add-task command - programInstance - .command('add-task') - .description('Add a new task using AI') - .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') - .option('-p, --prompt ', 'Description of the task to add (required)') - .option('-d, --dependencies ', 'Comma-separated list of task IDs this task depends on') - .option('--priority ', 'Task priority (high, medium, low)', 'medium') - .action(async (options) => { - const tasksPath = options.file; - const prompt = options.prompt; - const dependencies = options.dependencies ? options.dependencies.split(',').map(id => parseInt(id.trim(), 10)) : []; - const priority = options.priority; - - if (!prompt) { - console.error(chalk.red('Error: --prompt parameter is required. Please provide a task description.')); - process.exit(1); - } - - console.log(chalk.blue(`Adding new task with description: "${prompt}"`)); - console.log(chalk.blue(`Dependencies: ${dependencies.length > 0 ? dependencies.join(', ') : 'None'}`)); - console.log(chalk.blue(`Priority: ${priority}`)); - - await addTask(tasksPath, prompt, dependencies, priority); - }); + if (!prompt) { + console.error( + chalk.red( + 'Error: --prompt parameter is required. Please provide information about the changes.' + ) + ); + 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 ', 'Path to the tasks file', 'tasks/tasks.json') - .action(async (options) => { - const tasksPath = options.file; - await displayNextTask(tasksPath); - }); + console.log( + chalk.blue( + `Updating tasks from ID >= ${fromId} with prompt: "${prompt}"` + ) + ); + console.log(chalk.blue(`Tasks file: ${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 ', 'Task ID to show') - .option('-f, --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); - }); + if (useResearch) { + console.log( + chalk.blue('Using Perplexity AI for research-backed task updates') + ); + } - // add-dependency command - programInstance - .command('add-dependency') - .description('Add a dependency to a task') - .option('-i, --id ', 'Task ID to add dependency to') - .option('-d, --depends-on ', 'Task ID that will become a dependency') - .option('-f, --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); - }); + await updateTasks(tasksPath, fromId, prompt, useResearch); + }); - // remove-dependency command - programInstance - .command('remove-dependency') - .description('Remove a dependency from a task') - .option('-i, --id ', 'Task ID to remove dependency from') - .option('-d, --depends-on ', 'Task ID to remove as a dependency') - .option('-f, --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); - }); + // update-task command + programInstance + .command('update-task') + .description('Update a single task by ID with new information') + .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-dependencies command - programInstance - .command('validate-dependencies') - .description(`Identify invalid dependencies without fixing them${chalk.reset('')}`) - .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') - .action(async (options) => { - await validateDependenciesCommand(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); + } - // fix-dependencies command - programInstance - .command('fix-dependencies') - .description(`Fix invalid dependencies automatically${chalk.reset('')}`) - .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') - .action(async (options) => { - await fixDependenciesCommand(options.file); - }); + // 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); + } - // complexity-report command - programInstance - .command('complexity-report') - .description(`Display the complexity analysis report${chalk.reset('')}`) - .option('-f, --file ', 'Path to the report file', 'scripts/task-complexity-report.json') - .action(async (options) => { - await displayComplexityReport(options.file); - }); + 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); + } - // add-subtask command - programInstance - .command('add-subtask') - .description('Add a subtask to an existing task') - .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') - .option('-p, --parent ', 'Parent task ID (required)') - .option('-i, --task-id ', 'Existing task ID to convert to subtask') - .option('-t, --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); - }); + const prompt = options.prompt; + const useResearch = options.research || false; - // 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' } - )); - } + // 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); + } - // 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' } - )); - } + console.log( + chalk.blue(`Updating task ${taskId} with prompt: "${prompt}"`) + ); + console.log(chalk.blue(`Tasks file: ${tasksPath}`)); - // init command (documentation only, implementation is in init.js) - programInstance - .command('init') - .description('Initialize a new project with Task Master structure') - .option('-n, --name <name>', 'Project name') - .option('-my_name <name>', 'Project name (alias for --name)') - .option('--my_name <name>', 'Project name (alias for --name)') - .option('-d, --description <description>', 'Project description') - .option('-my_description <description>', 'Project description (alias for --description)') - .option('-v, --version <version>', 'Project version') - .option('-my_version <version>', 'Project version (alias for --version)') - .option('-a, --author <author>', 'Author name') - .option('-y, --yes', 'Skip prompts and use default values') - .option('--skip-install', 'Skip installing dependencies') - .action(() => { - console.log(chalk.yellow('The init command must be run as a standalone command: task-master init')); - console.log(chalk.cyan('Example usage:')); - console.log(chalk.white(' task-master init -n "My Project" -d "Project description"')); - console.log(chalk.white(' task-master init -my_name "My Project" -my_description "Project description"')); - console.log(chalk.white(' task-master init -y')); - process.exit(0); - }); - - // Add more commands as needed... - - return programInstance; + if (useResearch) { + // Verify Perplexity API key exists if using research + if (!process.env.PERPLEXITY_API_KEY) { + 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.' + ) + ); + } + + if (CONFIG.debug) { + 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 <file>', 'Path to the tasks file', 'tasks/tasks.json') + .option( + '-i, --id <id>', + 'Subtask ID to update in format "parentId.subtaskId" (required)' + ) + .option( + '-p, --prompt <text>', + '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 (!process.env.PERPLEXITY_API_KEY) { + 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.' + ) + ); + } + + if (CONFIG.debug) { + console.error(error); + } + + process.exit(1); + } + }); + + // generate command + programInstance + .command('generate') + .description('Generate task files from tasks.json') + .option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json') + .option('-o, --output <dir>', '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 <id>', + 'Task ID (can be comma-separated for multiple tasks)' + ) + .option( + '-s, --status <status>', + 'New status (todo, in-progress, review, done)' + ) + .option('-f, --file <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 <file>', 'Path to the tasks file', 'tasks/tasks.json') + .option('-s, --status <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('Break down tasks into detailed subtasks') + .option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json') + .option('-i, --id <id>', 'Task ID to expand') + .option('-a, --all', 'Expand all tasks') + .option( + '-n, --num <number>', + 'Number of subtasks to generate', + CONFIG.defaultSubtasks.toString() + ) + .option( + '--research', + 'Enable Perplexity AI for research-backed subtask generation' + ) + .option( + '-p, --prompt <text>', + 'Additional context to guide subtask generation' + ) + .option( + '--force', + 'Force regeneration of subtasks for tasks that already have them' + ) + .action(async (options) => { + const tasksPath = options.file; + const idArg = options.id ? parseInt(options.id, 10) : null; + const allFlag = options.all; + const numSubtasks = parseInt(options.num, 10); + const forceFlag = options.force; + const useResearch = options.research === true; + const additionalContext = options.prompt || ''; + + // Debug log to verify the value + log('debug', `Research enabled: ${useResearch}`); + + if (allFlag) { + console.log( + chalk.blue(`Expanding all tasks with ${numSubtasks} subtasks each...`) + ); + if (useResearch) { + console.log( + chalk.blue( + 'Using Perplexity AI for research-backed subtask generation' + ) + ); + } else { + console.log( + chalk.yellow('Research-backed subtask generation disabled') + ); + } + if (additionalContext) { + console.log(chalk.blue(`Additional context: "${additionalContext}"`)); + } + await expandAllTasks( + numSubtasks, + useResearch, + additionalContext, + forceFlag + ); + } else if (idArg) { + console.log( + chalk.blue(`Expanding task ${idArg} with ${numSubtasks} subtasks...`) + ); + if (useResearch) { + console.log( + chalk.blue( + 'Using Perplexity AI for research-backed subtask generation' + ) + ); + } else { + console.log( + chalk.yellow('Research-backed subtask generation disabled') + ); + } + if (additionalContext) { + console.log(chalk.blue(`Additional context: "${additionalContext}"`)); + } + await expandTask(idArg, numSubtasks, useResearch, additionalContext); + } else { + console.error( + chalk.red( + 'Error: Please specify a task ID with --id=<id> or use --all to expand all tasks.' + ) + ); + } + }); + + // analyze-complexity command + programInstance + .command('analyze-complexity') + .description( + `Analyze tasks and generate expansion recommendations${chalk.reset('')}` + ) + .option( + '-o, --output <file>', + 'Output file path for the report', + 'scripts/task-complexity-report.json' + ) + .option( + '-m, --model <model>', + 'LLM model to use for analysis (defaults to configured model)' + ) + .option( + '-t, --threshold <number>', + 'Minimum complexity score to recommend expansion (1-10)', + '5' + ) + .option('-f, --file <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 <file>', 'Path to the tasks file', 'tasks/tasks.json') + .option( + '-i, --id <ids>', + '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=<ids> 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') + .option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json') + .option('-p, --prompt <text>', 'Description of the task to add (required)') + .option( + '-d, --dependencies <ids>', + 'Comma-separated list of task IDs this task depends on' + ) + .option( + '--priority <priority>', + 'Task priority (high, medium, low)', + 'medium' + ) + .action(async (options) => { + const tasksPath = options.file; + const prompt = options.prompt; + const dependencies = options.dependencies + ? options.dependencies.split(',').map((id) => parseInt(id.trim(), 10)) + : []; + const priority = options.priority; + + if (!prompt) { + console.error( + chalk.red( + 'Error: --prompt parameter is required. Please provide a task description.' + ) + ); + process.exit(1); + } + + console.log(chalk.blue(`Adding new task with description: "${prompt}"`)); + console.log( + chalk.blue( + `Dependencies: ${dependencies.length > 0 ? dependencies.join(', ') : 'None'}` + ) + ); + console.log(chalk.blue(`Priority: ${priority}`)); + + await addTask(tasksPath, prompt, dependencies, priority); + }); + + // 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' } + ) + ); + } + + // init command (documentation only, implementation is in init.js) + programInstance + .command('init') + .description('Initialize a new project with Task Master structure') + .option('-n, --name <name>', 'Project name') + .option('-my_name <name>', 'Project name (alias for --name)') + .option('--my_name <name>', 'Project name (alias for --name)') + .option('-d, --description <description>', 'Project description') + .option( + '-my_description <description>', + 'Project description (alias for --description)' + ) + .option('-v, --version <version>', 'Project version') + .option('-my_version <version>', 'Project version (alias for --version)') + .option('-a, --author <author>', 'Author name') + .option('-y, --yes', 'Skip prompts and use default values') + .option('--skip-install', 'Skip installing dependencies') + .action(() => { + console.log( + chalk.yellow( + 'The init command must be run as a standalone command: task-master init' + ) + ); + console.log(chalk.cyan('Example usage:')); + console.log( + chalk.white( + ' task-master init -n "My Project" -d "Project description"' + ) + ); + console.log( + chalk.white( + ' task-master init -my_name "My Project" -my_description "Project description"' + ) + ); + console.log(chalk.white(' task-master init -y')); + process.exit(0); + }); + + // Add more commands as needed... + + return programInstance; } /** @@ -874,43 +1307,45 @@ function registerCommands(programInstance) { * @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 - 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 default version - } - return CONFIG.projectVersion; // Default fallback - }) - .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; + // Create a new program instance + const programInstance = program + .name('dev') + .description('AI-driven development task management') + .version(() => { + // Read version directly from package.json + 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 default version + } + return CONFIG.projectVersion; // Default fallback + }) + .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; } /** @@ -918,84 +1353,90 @@ function setupCLI() { * @returns {Promise<{currentVersion: string, latestVersion: string, needsUpdate: boolean}>} */ async function checkForUpdate() { - // Get current version from package.json - let currentVersion = CONFIG.projectVersion; - try { - // Try to get the version from the installed package - const packageJsonPath = path.join(process.cwd(), 'node_modules', 'task-master-ai', '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}`); - } + // Get current version from package.json + let currentVersion = CONFIG.projectVersion; + try { + // Try to get the version from the installed package + const packageJsonPath = path.join( + process.cwd(), + 'node_modules', + 'task-master-ai', + '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 - } - }; + 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(); - }); + 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(); + }); } /** @@ -1005,18 +1446,18 @@ async function checkForUpdate() { * @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; + 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; } /** @@ -1025,18 +1466,18 @@ function compareVersions(v1, v2) { * @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); + 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); } /** @@ -1044,46 +1485,49 @@ function displayUpgradeNotification(currentVersion, latestVersion) { * @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 - 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) { - console.error(chalk.red(`Error: ${error.message}`)); - - if (CONFIG.debug) { - console.error(error); - } - - process.exit(1); - } + 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 + 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) { + console.error(chalk.red(`Error: ${error.message}`)); + + if (CONFIG.debug) { + console.error(error); + } + + process.exit(1); + } } export { - registerCommands, - setupCLI, - runCLI, - checkForUpdate, - compareVersions, - displayUpgradeNotification -}; \ No newline at end of file + registerCommands, + setupCLI, + runCLI, + checkForUpdate, + compareVersions, + displayUpgradeNotification +}; diff --git a/scripts/modules/dependency-manager.js b/scripts/modules/dependency-manager.js index dc86fac9..b4b1121b 100644 --- a/scripts/modules/dependency-manager.js +++ b/scripts/modules/dependency-manager.js @@ -8,25 +8,24 @@ import chalk from 'chalk'; import boxen from 'boxen'; import { Anthropic } from '@anthropic-ai/sdk'; -import { - log, - readJSON, - writeJSON, - taskExists, - formatTaskId, - findCycles - } from './utils.js'; - +import { + log, + readJSON, + writeJSON, + taskExists, + formatTaskId, + findCycles +} from './utils.js'; + import { displayBanner } from './ui.js'; import { generateTaskFiles } from './task-manager.js'; - + // Initialize Anthropic client const anthropic = new Anthropic({ - apiKey: process.env.ANTHROPIC_API_KEY, + apiKey: process.env.ANTHROPIC_API_KEY }); - /** * Add a dependency to a task * @param {string} tasksPath - Path to the tasks.json file @@ -34,1091 +33,1251 @@ const anthropic = new Anthropic({ * @param {number|string} dependencyId - ID of the task to add as dependency */ async function addDependency(tasksPath, taskId, dependencyId) { - log('info', `Adding dependency ${dependencyId} to task ${taskId}...`); - - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - log('error', 'No valid tasks found in tasks.json'); - process.exit(1); - } - - // Format the task and dependency IDs correctly - const formattedTaskId = typeof taskId === 'string' && taskId.includes('.') - ? taskId : parseInt(taskId, 10); - - const formattedDependencyId = formatTaskId(dependencyId); - - // Check if the dependency task or subtask actually exists - if (!taskExists(data.tasks, formattedDependencyId)) { - log('error', `Dependency target ${formattedDependencyId} does not exist in tasks.json`); - process.exit(1); - } - - // Find the task to update - let targetTask = null; - let isSubtask = false; - - if (typeof formattedTaskId === 'string' && formattedTaskId.includes('.')) { - // Handle dot notation for subtasks (e.g., "1.2") - const [parentId, subtaskId] = formattedTaskId.split('.').map(id => parseInt(id, 10)); - const parentTask = data.tasks.find(t => t.id === parentId); - - if (!parentTask) { - log('error', `Parent task ${parentId} not found.`); - process.exit(1); - } - - if (!parentTask.subtasks) { - log('error', `Parent task ${parentId} has no subtasks.`); - process.exit(1); - } - - targetTask = parentTask.subtasks.find(s => s.id === subtaskId); - isSubtask = true; - - if (!targetTask) { - log('error', `Subtask ${formattedTaskId} not found.`); - process.exit(1); - } - } else { - // Regular task (not a subtask) - targetTask = data.tasks.find(t => t.id === formattedTaskId); - - if (!targetTask) { - log('error', `Task ${formattedTaskId} not found.`); - process.exit(1); - } - } - - // Initialize dependencies array if it doesn't exist - if (!targetTask.dependencies) { - targetTask.dependencies = []; - } - - // Check if dependency already exists - if (targetTask.dependencies.some(d => { - // Convert both to strings for comparison to handle both numeric and string IDs - return String(d) === String(formattedDependencyId); - })) { - log('warn', `Dependency ${formattedDependencyId} already exists in task ${formattedTaskId}.`); - return; - } - - // Check if the task is trying to depend on itself - compare full IDs (including subtask parts) - if (String(formattedTaskId) === String(formattedDependencyId)) { - log('error', `Task ${formattedTaskId} cannot depend on itself.`); - process.exit(1); - } - - // For subtasks of the same parent, we need to make sure we're not treating it as a self-dependency - // Check if we're dealing with subtasks with the same parent task - let isSelfDependency = false; - - if (typeof formattedTaskId === 'string' && typeof formattedDependencyId === 'string' && - formattedTaskId.includes('.') && formattedDependencyId.includes('.')) { - const [taskParentId] = formattedTaskId.split('.'); - const [depParentId] = formattedDependencyId.split('.'); - - // Only treat it as a self-dependency if both the parent ID and subtask ID are identical - isSelfDependency = formattedTaskId === formattedDependencyId; - - // Log for debugging - log('debug', `Adding dependency between subtasks: ${formattedTaskId} depends on ${formattedDependencyId}`); - log('debug', `Parent IDs: ${taskParentId} and ${depParentId}, Self-dependency check: ${isSelfDependency}`); - } - - if (isSelfDependency) { - log('error', `Subtask ${formattedTaskId} cannot depend on itself.`); - process.exit(1); - } - - // Check for circular dependencies - let dependencyChain = [formattedTaskId]; - if (!isCircularDependency(data.tasks, formattedDependencyId, dependencyChain)) { - // Add the dependency - targetTask.dependencies.push(formattedDependencyId); - - // Sort dependencies numerically or by parent task ID first, then subtask ID - targetTask.dependencies.sort((a, b) => { - if (typeof a === 'number' && typeof b === 'number') { - return a - b; - } else if (typeof a === 'string' && typeof b === 'string') { - const [aParent, aChild] = a.split('.').map(Number); - const [bParent, bChild] = b.split('.').map(Number); - return aParent !== bParent ? aParent - bParent : aChild - bChild; - } else if (typeof a === 'number') { - return -1; // Numbers come before strings - } else { - return 1; // Strings come after numbers - } - }); - - // Save changes - writeJSON(tasksPath, data); - log('success', `Added dependency ${formattedDependencyId} to task ${formattedTaskId}`); - - // Display a more visually appealing success message - console.log(boxen( - chalk.green(`Successfully added dependency:\n\n`) + - `Task ${chalk.bold(formattedTaskId)} now depends on ${chalk.bold(formattedDependencyId)}`, - { padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1 } } - )); - - // Generate updated task files - await generateTaskFiles(tasksPath, 'tasks'); - - log('info', 'Task files regenerated with updated dependencies.'); - } else { - log('error', `Cannot add dependency ${formattedDependencyId} to task ${formattedTaskId} as it would create a circular dependency.`); - process.exit(1); - } - } - - /** - * Remove a dependency from a task - * @param {string} tasksPath - Path to the tasks.json file - * @param {number|string} taskId - ID of the task to remove dependency from - * @param {number|string} dependencyId - ID of the task to remove as dependency - */ - async function removeDependency(tasksPath, taskId, dependencyId) { - log('info', `Removing dependency ${dependencyId} from task ${taskId}...`); - - // Read tasks file - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - log('error', "No valid tasks found."); - process.exit(1); - } - - // Format the task and dependency IDs correctly - const formattedTaskId = typeof taskId === 'string' && taskId.includes('.') - ? taskId : parseInt(taskId, 10); - - const formattedDependencyId = formatTaskId(dependencyId); - - // Find the task to update - let targetTask = null; - let isSubtask = false; - - if (typeof formattedTaskId === 'string' && formattedTaskId.includes('.')) { - // Handle dot notation for subtasks (e.g., "1.2") - const [parentId, subtaskId] = formattedTaskId.split('.').map(id => parseInt(id, 10)); - const parentTask = data.tasks.find(t => t.id === parentId); - - if (!parentTask) { - log('error', `Parent task ${parentId} not found.`); - process.exit(1); - } - - if (!parentTask.subtasks) { - log('error', `Parent task ${parentId} has no subtasks.`); - process.exit(1); - } - - targetTask = parentTask.subtasks.find(s => s.id === subtaskId); - isSubtask = true; - - if (!targetTask) { - log('error', `Subtask ${formattedTaskId} not found.`); - process.exit(1); - } - } else { - // Regular task (not a subtask) - targetTask = data.tasks.find(t => t.id === formattedTaskId); - - if (!targetTask) { - log('error', `Task ${formattedTaskId} not found.`); - process.exit(1); - } - } - - // Check if the task has any dependencies - if (!targetTask.dependencies || targetTask.dependencies.length === 0) { - log('info', `Task ${formattedTaskId} has no dependencies, nothing to remove.`); - return; - } - - // Normalize the dependency ID for comparison to handle different formats - const normalizedDependencyId = String(formattedDependencyId); - - // Check if the dependency exists by comparing string representations - const dependencyIndex = targetTask.dependencies.findIndex(dep => { - // Convert both to strings for comparison - let depStr = String(dep); - - // Special handling for numeric IDs that might be subtask references - if (typeof dep === 'number' && dep < 100 && isSubtask) { - // It's likely a reference to another subtask in the same parent task - // Convert to full format for comparison (e.g., 2 -> "1.2" for a subtask in task 1) - const [parentId] = formattedTaskId.split('.'); - depStr = `${parentId}.${dep}`; - } - - return depStr === normalizedDependencyId; - }); - - if (dependencyIndex === -1) { - log('info', `Task ${formattedTaskId} does not depend on ${formattedDependencyId}, no changes made.`); - return; - } - - // Remove the dependency - targetTask.dependencies.splice(dependencyIndex, 1); - - // Save the updated tasks - writeJSON(tasksPath, data); - - // Success message - log('success', `Removed dependency: Task ${formattedTaskId} no longer depends on ${formattedDependencyId}`); - - // Display a more visually appealing success message - console.log(boxen( - chalk.green(`Successfully removed dependency:\n\n`) + - `Task ${chalk.bold(formattedTaskId)} no longer depends on ${chalk.bold(formattedDependencyId)}`, - { padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1 } } - )); - - // Regenerate task files - await generateTaskFiles(tasksPath, 'tasks'); - } - - /** - * Check if adding a dependency would create a circular dependency - * @param {Array} tasks - Array of all tasks - * @param {number|string} taskId - ID of task to check - * @param {Array} chain - Chain of dependencies to check - * @returns {boolean} True if circular dependency would be created - */ - function isCircularDependency(tasks, taskId, chain = []) { - // Convert taskId to string for comparison - const taskIdStr = String(taskId); - - // If we've seen this task before in the chain, we have a circular dependency - if (chain.some(id => String(id) === taskIdStr)) { - return true; - } - - // Find the task or subtask - let task = null; - - // Check if this is a subtask reference (e.g., "1.2") - if (taskIdStr.includes('.')) { - const [parentId, subtaskId] = taskIdStr.split('.').map(Number); - const parentTask = tasks.find(t => t.id === parentId); - - if (parentTask && parentTask.subtasks) { - task = parentTask.subtasks.find(st => st.id === subtaskId); - } - } else { - // Regular task - task = tasks.find(t => String(t.id) === taskIdStr); - } - - if (!task) { - return false; // Task doesn't exist, can't create circular dependency - } - - // No dependencies, can't create circular dependency - if (!task.dependencies || task.dependencies.length === 0) { - return false; - } - - // Check each dependency recursively - const newChain = [...chain, taskId]; - return task.dependencies.some(depId => isCircularDependency(tasks, depId, newChain)); - } - - /** - * Validate task dependencies - * @param {Array} tasks - Array of all tasks - * @returns {Object} Validation result with valid flag and issues array - */ - function validateTaskDependencies(tasks) { - const issues = []; - - // Check each task's dependencies - tasks.forEach(task => { - if (!task.dependencies) { - return; // No dependencies to validate - } - - task.dependencies.forEach(depId => { - // Check for self-dependencies - if (String(depId) === String(task.id)) { - issues.push({ - type: 'self', - taskId: task.id, - message: `Task ${task.id} depends on itself` - }); - return; - } - - // Check if dependency exists - if (!taskExists(tasks, depId)) { - issues.push({ - type: 'missing', - taskId: task.id, - dependencyId: depId, - message: `Task ${task.id} depends on non-existent task ${depId}` - }); - } - }); - - // Check for circular dependencies - if (isCircularDependency(tasks, task.id)) { - issues.push({ - type: 'circular', - taskId: task.id, - message: `Task ${task.id} is part of a circular dependency chain` - }); - } - - // Check subtask dependencies if they exist - if (task.subtasks && task.subtasks.length > 0) { - task.subtasks.forEach(subtask => { - if (!subtask.dependencies) { - return; // No dependencies to validate - } - - // Create a full subtask ID for reference - const fullSubtaskId = `${task.id}.${subtask.id}`; - - subtask.dependencies.forEach(depId => { - // Check for self-dependencies in subtasks - if (String(depId) === String(fullSubtaskId) || - (typeof depId === 'number' && depId === subtask.id)) { - issues.push({ - type: 'self', - taskId: fullSubtaskId, - message: `Subtask ${fullSubtaskId} depends on itself` - }); - return; - } - - // Check if dependency exists - if (!taskExists(tasks, depId)) { - issues.push({ - type: 'missing', - taskId: fullSubtaskId, - dependencyId: depId, - message: `Subtask ${fullSubtaskId} depends on non-existent task/subtask ${depId}` - }); - } - }); - - // Check for circular dependencies in subtasks - if (isCircularDependency(tasks, fullSubtaskId)) { - issues.push({ - type: 'circular', - taskId: fullSubtaskId, - message: `Subtask ${fullSubtaskId} is part of a circular dependency chain` - }); - } - }); - } - }); - - return { - valid: issues.length === 0, - issues - }; - } - - /** - * Remove duplicate dependencies from tasks - * @param {Object} tasksData - Tasks data object with tasks array - * @returns {Object} Updated tasks data with duplicates removed - */ - function removeDuplicateDependencies(tasksData) { - const tasks = tasksData.tasks.map(task => { - if (!task.dependencies) { - return task; - } - - // Convert to Set and back to array to remove duplicates - const uniqueDeps = [...new Set(task.dependencies)]; - return { - ...task, - dependencies: uniqueDeps - }; - }); - - return { - ...tasksData, - tasks - }; - } - - /** - * Clean up invalid subtask dependencies - * @param {Object} tasksData - Tasks data object with tasks array - * @returns {Object} Updated tasks data with invalid subtask dependencies removed - */ - function cleanupSubtaskDependencies(tasksData) { - const tasks = tasksData.tasks.map(task => { - // Handle task's own dependencies - if (task.dependencies) { - task.dependencies = task.dependencies.filter(depId => { - // Keep only dependencies that exist - return taskExists(tasksData.tasks, depId); - }); - } - - // Handle subtask dependencies - if (task.subtasks) { - task.subtasks = task.subtasks.map(subtask => { - if (!subtask.dependencies) { - return subtask; - } - - // Filter out dependencies to non-existent subtasks - subtask.dependencies = subtask.dependencies.filter(depId => { - return taskExists(tasksData.tasks, depId); - }); - - return subtask; - }); - } - - return task; - }); - - return { - ...tasksData, - tasks - }; - } - - /** - * Validate dependencies in task files - * @param {string} tasksPath - Path to tasks.json - */ - async function validateDependenciesCommand(tasksPath) { - displayBanner(); - - log('info', 'Checking for invalid dependencies in task files...'); - - // Read tasks data - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - log('error', 'No valid tasks found in tasks.json'); - process.exit(1); - } - - // Count of tasks and subtasks for reporting - const taskCount = data.tasks.length; - let subtaskCount = 0; - data.tasks.forEach(task => { - if (task.subtasks && Array.isArray(task.subtasks)) { - subtaskCount += task.subtasks.length; - } - }); - - log('info', `Analyzing dependencies for ${taskCount} tasks and ${subtaskCount} subtasks...`); - - // Track validation statistics - const stats = { - nonExistentDependenciesRemoved: 0, - selfDependenciesRemoved: 0, - tasksFixed: 0, - subtasksFixed: 0 - }; - - // Create a custom logger instead of reassigning the imported log function - const warnings = []; - const customLogger = function(level, ...args) { - if (level === 'warn') { - warnings.push(args.join(' ')); - - // Count the type of fix based on the warning message - const msg = args.join(' '); - if (msg.includes('self-dependency')) { - stats.selfDependenciesRemoved++; - } else if (msg.includes('invalid')) { - stats.nonExistentDependenciesRemoved++; - } - - // Count if it's a task or subtask being fixed - if (msg.includes('from subtask')) { - stats.subtasksFixed++; - } else if (msg.includes('from task')) { - stats.tasksFixed++; - } - } - // Call the original log function - return log(level, ...args); - }; - - // Run validation with custom logger - try { - // Temporarily save validateTaskDependencies function with normal log - const originalValidateTaskDependencies = validateTaskDependencies; - - // Create patched version that uses customLogger - const patchedValidateTaskDependencies = (tasks, tasksPath) => { - // Temporarily redirect log calls in this scope - const originalLog = log; - const logProxy = function(...args) { - return customLogger(...args); - }; - - // Call the original function in a context where log calls are intercepted - const result = (() => { - // Use Function.prototype.bind to create a new function that has logProxy available - return Function('tasks', 'tasksPath', 'log', 'customLogger', - `return (${originalValidateTaskDependencies.toString()})(tasks, tasksPath);` - )(tasks, tasksPath, logProxy, customLogger); - })(); - - return result; - }; - - const changesDetected = patchedValidateTaskDependencies(data.tasks, tasksPath); - - // Create a detailed report - if (changesDetected) { - log('success', 'Invalid dependencies were removed from tasks.json'); - - // Show detailed stats in a nice box - console.log(boxen( - chalk.green(`Dependency Validation Results:\n\n`) + - `${chalk.cyan('Tasks checked:')} ${taskCount}\n` + - `${chalk.cyan('Subtasks checked:')} ${subtaskCount}\n` + - `${chalk.cyan('Non-existent dependencies removed:')} ${stats.nonExistentDependenciesRemoved}\n` + - `${chalk.cyan('Self-dependencies removed:')} ${stats.selfDependenciesRemoved}\n` + - `${chalk.cyan('Tasks fixed:')} ${stats.tasksFixed}\n` + - `${chalk.cyan('Subtasks fixed:')} ${stats.subtasksFixed}`, - { padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1, bottom: 1 } } - )); - - // Show all warnings in a collapsible list if there are many - if (warnings.length > 0) { - console.log(chalk.yellow('\nDetailed fixes:')); - warnings.forEach(warning => { - console.log(` ${warning}`); - }); - } - - // Regenerate task files to reflect the changes - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); - log('info', 'Task files regenerated to reflect dependency changes'); - } else { - log('success', 'No invalid dependencies found - all dependencies are valid'); - - // Show validation summary - console.log(boxen( - chalk.green(`All Dependencies Are Valid\n\n`) + - `${chalk.cyan('Tasks checked:')} ${taskCount}\n` + - `${chalk.cyan('Subtasks checked:')} ${subtaskCount}\n` + - `${chalk.cyan('Total dependencies verified:')} ${countAllDependencies(data.tasks)}`, - { padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1, bottom: 1 } } - )); - } - } catch (error) { - log('error', 'Error validating dependencies:', error); - process.exit(1); - } - } - - /** - * Helper function to count all dependencies across tasks and subtasks - * @param {Array} tasks - All tasks - * @returns {number} - Total number of dependencies - */ - function countAllDependencies(tasks) { - let count = 0; - - tasks.forEach(task => { - // Count main task dependencies - if (task.dependencies && Array.isArray(task.dependencies)) { - count += task.dependencies.length; - } - - // Count subtask dependencies - if (task.subtasks && Array.isArray(task.subtasks)) { - task.subtasks.forEach(subtask => { - if (subtask.dependencies && Array.isArray(subtask.dependencies)) { - count += subtask.dependencies.length; - } - }); - } - }); - - return count; - } - - /** - * Fixes invalid dependencies in tasks.json - * @param {string} tasksPath - Path to tasks.json - */ - async function fixDependenciesCommand(tasksPath) { - displayBanner(); - - log('info', 'Checking for and fixing invalid dependencies in tasks.json...'); - - try { - // Read tasks data - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - log('error', 'No valid tasks found in tasks.json'); - process.exit(1); - } - - // Create a deep copy of the original data for comparison - const originalData = JSON.parse(JSON.stringify(data)); - - // Track fixes for reporting - const stats = { - nonExistentDependenciesRemoved: 0, - selfDependenciesRemoved: 0, - duplicateDependenciesRemoved: 0, - circularDependenciesFixed: 0, - tasksFixed: 0, - subtasksFixed: 0 - }; - - // First phase: Remove duplicate dependencies in tasks - data.tasks.forEach(task => { - if (task.dependencies && Array.isArray(task.dependencies)) { - const uniqueDeps = new Set(); - const originalLength = task.dependencies.length; - task.dependencies = task.dependencies.filter(depId => { - const depIdStr = String(depId); - if (uniqueDeps.has(depIdStr)) { - log('info', `Removing duplicate dependency from task ${task.id}: ${depId}`); - stats.duplicateDependenciesRemoved++; - return false; - } - uniqueDeps.add(depIdStr); - return true; - }); - if (task.dependencies.length < originalLength) { - stats.tasksFixed++; - } - } - - // Check for duplicates in subtasks - if (task.subtasks && Array.isArray(task.subtasks)) { - task.subtasks.forEach(subtask => { - if (subtask.dependencies && Array.isArray(subtask.dependencies)) { - const uniqueDeps = new Set(); - const originalLength = subtask.dependencies.length; - subtask.dependencies = subtask.dependencies.filter(depId => { - let depIdStr = String(depId); - if (typeof depId === 'number' && depId < 100) { - depIdStr = `${task.id}.${depId}`; - } - if (uniqueDeps.has(depIdStr)) { - log('info', `Removing duplicate dependency from subtask ${task.id}.${subtask.id}: ${depId}`); - stats.duplicateDependenciesRemoved++; - return false; - } - uniqueDeps.add(depIdStr); - return true; - }); - if (subtask.dependencies.length < originalLength) { - stats.subtasksFixed++; - } - } - }); - } - }); - - // Create validity maps for tasks and subtasks - const validTaskIds = new Set(data.tasks.map(t => t.id)); - const validSubtaskIds = new Set(); - data.tasks.forEach(task => { - if (task.subtasks && Array.isArray(task.subtasks)) { - task.subtasks.forEach(subtask => { - validSubtaskIds.add(`${task.id}.${subtask.id}`); - }); - } - }); - - // Second phase: Remove invalid task dependencies (non-existent tasks) - data.tasks.forEach(task => { - if (task.dependencies && Array.isArray(task.dependencies)) { - const originalLength = task.dependencies.length; - task.dependencies = task.dependencies.filter(depId => { - const isSubtask = typeof depId === 'string' && depId.includes('.'); - - if (isSubtask) { - // Check if the subtask exists - if (!validSubtaskIds.has(depId)) { - log('info', `Removing invalid subtask dependency from task ${task.id}: ${depId} (subtask does not exist)`); - stats.nonExistentDependenciesRemoved++; - return false; - } - return true; - } else { - // Check if the task exists - const numericId = typeof depId === 'string' ? parseInt(depId, 10) : depId; - if (!validTaskIds.has(numericId)) { - log('info', `Removing invalid task dependency from task ${task.id}: ${depId} (task does not exist)`); - stats.nonExistentDependenciesRemoved++; - return false; - } - return true; - } - }); - - if (task.dependencies.length < originalLength) { - stats.tasksFixed++; - } - } - - // Check subtask dependencies for invalid references - if (task.subtasks && Array.isArray(task.subtasks)) { - task.subtasks.forEach(subtask => { - if (subtask.dependencies && Array.isArray(subtask.dependencies)) { - const originalLength = subtask.dependencies.length; - const subtaskId = `${task.id}.${subtask.id}`; - - // First check for self-dependencies - const hasSelfDependency = subtask.dependencies.some(depId => { - if (typeof depId === 'string' && depId.includes('.')) { - return depId === subtaskId; - } else if (typeof depId === 'number' && depId < 100) { - return depId === subtask.id; - } - return false; - }); - - if (hasSelfDependency) { - subtask.dependencies = subtask.dependencies.filter(depId => { - const normalizedDepId = typeof depId === 'number' && depId < 100 - ? `${task.id}.${depId}` - : String(depId); - - if (normalizedDepId === subtaskId) { - log('info', `Removing self-dependency from subtask ${subtaskId}`); - stats.selfDependenciesRemoved++; - return false; - } - return true; - }); - } - - // Then check for non-existent dependencies - subtask.dependencies = subtask.dependencies.filter(depId => { - if (typeof depId === 'string' && depId.includes('.')) { - if (!validSubtaskIds.has(depId)) { - log('info', `Removing invalid subtask dependency from subtask ${subtaskId}: ${depId} (subtask does not exist)`); - stats.nonExistentDependenciesRemoved++; - return false; - } - return true; - } - - // Handle numeric dependencies - const numericId = typeof depId === 'number' ? depId : parseInt(depId, 10); - - // Small numbers likely refer to subtasks in the same task - if (numericId < 100) { - const fullSubtaskId = `${task.id}.${numericId}`; - - if (!validSubtaskIds.has(fullSubtaskId)) { - log('info', `Removing invalid subtask dependency from subtask ${subtaskId}: ${numericId}`); - stats.nonExistentDependenciesRemoved++; - return false; - } - - return true; - } - - // Otherwise it's a task reference - if (!validTaskIds.has(numericId)) { - log('info', `Removing invalid task dependency from subtask ${subtaskId}: ${numericId}`); - stats.nonExistentDependenciesRemoved++; - return false; - } - - return true; - }); - - if (subtask.dependencies.length < originalLength) { - stats.subtasksFixed++; - } - } - }); - } - }); - - // Third phase: Check for circular dependencies - log('info', 'Checking for circular dependencies...'); - - // Build the dependency map for subtasks - const subtaskDependencyMap = new Map(); - data.tasks.forEach(task => { - if (task.subtasks && Array.isArray(task.subtasks)) { - task.subtasks.forEach(subtask => { - const subtaskId = `${task.id}.${subtask.id}`; - - if (subtask.dependencies && Array.isArray(subtask.dependencies)) { - const normalizedDeps = subtask.dependencies.map(depId => { - if (typeof depId === 'string' && depId.includes('.')) { - return depId; - } else if (typeof depId === 'number' && depId < 100) { - return `${task.id}.${depId}`; - } - return String(depId); - }); - subtaskDependencyMap.set(subtaskId, normalizedDeps); - } else { - subtaskDependencyMap.set(subtaskId, []); - } - }); - } - }); - - // Check for and fix circular dependencies - for (const [subtaskId, dependencies] of subtaskDependencyMap.entries()) { - const visited = new Set(); - const recursionStack = new Set(); - - // Detect cycles - const cycleEdges = findCycles(subtaskId, subtaskDependencyMap, visited, recursionStack); - - if (cycleEdges.length > 0) { - const [taskId, subtaskNum] = subtaskId.split('.').map(part => Number(part)); - const task = data.tasks.find(t => t.id === taskId); - - if (task && task.subtasks) { - const subtask = task.subtasks.find(st => st.id === subtaskNum); - - if (subtask && subtask.dependencies) { - const originalLength = subtask.dependencies.length; - - const edgesToRemove = cycleEdges.map(edge => { - if (edge.includes('.')) { - const [depTaskId, depSubtaskId] = edge.split('.').map(part => Number(part)); - - if (depTaskId === taskId) { - return depSubtaskId; - } - - return edge; - } - - return Number(edge); - }); - - subtask.dependencies = subtask.dependencies.filter(depId => { - const normalizedDepId = typeof depId === 'number' && depId < 100 - ? `${taskId}.${depId}` - : String(depId); - - if (edgesToRemove.includes(depId) || edgesToRemove.includes(normalizedDepId)) { - log('info', `Breaking circular dependency: Removing ${normalizedDepId} from subtask ${subtaskId}`); - stats.circularDependenciesFixed++; - return false; - } - return true; - }); - - if (subtask.dependencies.length < originalLength) { - stats.subtasksFixed++; - } - } - } - } - } - - // Check if any changes were made by comparing with original data - const dataChanged = JSON.stringify(data) !== JSON.stringify(originalData); - - if (dataChanged) { - // Save the changes - writeJSON(tasksPath, data); - log('success', 'Fixed dependency issues in tasks.json'); - - // Regenerate task files - log('info', 'Regenerating task files to reflect dependency changes...'); - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); - } else { - log('info', 'No changes needed to fix dependencies'); - } - - // Show detailed statistics report - const totalFixedAll = stats.nonExistentDependenciesRemoved + - stats.selfDependenciesRemoved + - stats.duplicateDependenciesRemoved + - stats.circularDependenciesFixed; - - if (totalFixedAll > 0) { - log('success', `Fixed ${totalFixedAll} dependency issues in total!`); - - console.log(boxen( - chalk.green(`Dependency Fixes Summary:\n\n`) + - `${chalk.cyan('Invalid dependencies removed:')} ${stats.nonExistentDependenciesRemoved}\n` + - `${chalk.cyan('Self-dependencies removed:')} ${stats.selfDependenciesRemoved}\n` + - `${chalk.cyan('Duplicate dependencies removed:')} ${stats.duplicateDependenciesRemoved}\n` + - `${chalk.cyan('Circular dependencies fixed:')} ${stats.circularDependenciesFixed}\n\n` + - `${chalk.cyan('Tasks fixed:')} ${stats.tasksFixed}\n` + - `${chalk.cyan('Subtasks fixed:')} ${stats.subtasksFixed}\n`, - { padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1, bottom: 1 } } - )); - } else { - log('success', 'No dependency issues found - all dependencies are valid'); - - console.log(boxen( - chalk.green(`All Dependencies Are Valid\n\n`) + - `${chalk.cyan('Tasks checked:')} ${data.tasks.length}\n` + - `${chalk.cyan('Total dependencies verified:')} ${countAllDependencies(data.tasks)}`, - { padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1, bottom: 1 } } - )); - } - } catch (error) { - log('error', "Error in fix-dependencies command:", error); - process.exit(1); - } - } - - /** - * Ensure at least one subtask in each task has no dependencies - * @param {Object} tasksData - The tasks data object with tasks array - * @returns {boolean} - True if any changes were made - */ - function ensureAtLeastOneIndependentSubtask(tasksData) { - if (!tasksData || !tasksData.tasks || !Array.isArray(tasksData.tasks)) { - return false; - } - - let changesDetected = false; - - tasksData.tasks.forEach(task => { - if (!task.subtasks || !Array.isArray(task.subtasks) || task.subtasks.length === 0) { - return; - } - - // Check if any subtask has no dependencies - const hasIndependentSubtask = task.subtasks.some(st => - !st.dependencies || !Array.isArray(st.dependencies) || st.dependencies.length === 0 - ); - - if (!hasIndependentSubtask) { - // Find the first subtask and clear its dependencies - if (task.subtasks.length > 0) { - const firstSubtask = task.subtasks[0]; - log('debug', `Ensuring at least one independent subtask: Clearing dependencies for subtask ${task.id}.${firstSubtask.id}`); - firstSubtask.dependencies = []; - changesDetected = true; - } - } - }); - - return changesDetected; - } + log('info', `Adding dependency ${dependencyId} to task ${taskId}...`); - /** - * Validate and fix dependencies across all tasks and subtasks - * This function is designed to be called after any task modification - * @param {Object} tasksData - The tasks data object with tasks array - * @param {string} tasksPath - Optional path to save the changes - * @returns {boolean} - True if any changes were made - */ - function validateAndFixDependencies(tasksData, tasksPath = null) { - if (!tasksData || !tasksData.tasks || !Array.isArray(tasksData.tasks)) { - log('error', 'Invalid tasks data'); - return false; - } - - log('debug', 'Validating and fixing dependencies...'); - - // Create a deep copy for comparison - const originalData = JSON.parse(JSON.stringify(tasksData)); - - // 1. Remove duplicate dependencies from tasks and subtasks - tasksData.tasks = tasksData.tasks.map(task => { - // Handle task dependencies - if (task.dependencies) { - const uniqueDeps = [...new Set(task.dependencies)]; - task.dependencies = uniqueDeps; - } - - // Handle subtask dependencies - if (task.subtasks) { - task.subtasks = task.subtasks.map(subtask => { - if (subtask.dependencies) { - const uniqueDeps = [...new Set(subtask.dependencies)]; - subtask.dependencies = uniqueDeps; - } - return subtask; - }); - } - return task; - }); - - // 2. Remove invalid task dependencies (non-existent tasks) - tasksData.tasks.forEach(task => { - // Clean up task dependencies - if (task.dependencies) { - task.dependencies = task.dependencies.filter(depId => { - // Remove self-dependencies - if (String(depId) === String(task.id)) { - return false; - } - // Remove non-existent dependencies - return taskExists(tasksData.tasks, depId); - }); - } - - // Clean up subtask dependencies - if (task.subtasks) { - task.subtasks.forEach(subtask => { - if (subtask.dependencies) { - subtask.dependencies = subtask.dependencies.filter(depId => { - // Handle numeric subtask references - if (typeof depId === 'number' && depId < 100) { - const fullSubtaskId = `${task.id}.${depId}`; - return taskExists(tasksData.tasks, fullSubtaskId); - } - // Handle full task/subtask references - return taskExists(tasksData.tasks, depId); - }); - } - }); - } - }); - - // 3. Ensure at least one subtask has no dependencies in each task - tasksData.tasks.forEach(task => { - if (task.subtasks && task.subtasks.length > 0) { - const hasIndependentSubtask = task.subtasks.some(st => - !st.dependencies || !Array.isArray(st.dependencies) || st.dependencies.length === 0 - ); - - if (!hasIndependentSubtask) { - task.subtasks[0].dependencies = []; - } - } - }); - - // Check if any changes were made by comparing with original data - const changesDetected = JSON.stringify(tasksData) !== JSON.stringify(originalData); - - // Save changes if needed - if (tasksPath && changesDetected) { - try { - writeJSON(tasksPath, tasksData); - log('debug', 'Saved dependency fixes to tasks.json'); - } catch (error) { - log('error', 'Failed to save dependency fixes to tasks.json', error); - } - } - - return changesDetected; - } - - export { - addDependency, - removeDependency, - isCircularDependency, - validateTaskDependencies, - validateDependenciesCommand, - fixDependenciesCommand, - removeDuplicateDependencies, - cleanupSubtaskDependencies, - ensureAtLeastOneIndependentSubtask, - validateAndFixDependencies - } \ No newline at end of file + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + log('error', 'No valid tasks found in tasks.json'); + process.exit(1); + } + + // Format the task and dependency IDs correctly + const formattedTaskId = + typeof taskId === 'string' && taskId.includes('.') + ? taskId + : parseInt(taskId, 10); + + const formattedDependencyId = formatTaskId(dependencyId); + + // Check if the dependency task or subtask actually exists + if (!taskExists(data.tasks, formattedDependencyId)) { + log( + 'error', + `Dependency target ${formattedDependencyId} does not exist in tasks.json` + ); + process.exit(1); + } + + // Find the task to update + let targetTask = null; + let isSubtask = false; + + if (typeof formattedTaskId === 'string' && formattedTaskId.includes('.')) { + // Handle dot notation for subtasks (e.g., "1.2") + const [parentId, subtaskId] = formattedTaskId + .split('.') + .map((id) => parseInt(id, 10)); + const parentTask = data.tasks.find((t) => t.id === parentId); + + if (!parentTask) { + log('error', `Parent task ${parentId} not found.`); + process.exit(1); + } + + if (!parentTask.subtasks) { + log('error', `Parent task ${parentId} has no subtasks.`); + process.exit(1); + } + + targetTask = parentTask.subtasks.find((s) => s.id === subtaskId); + isSubtask = true; + + if (!targetTask) { + log('error', `Subtask ${formattedTaskId} not found.`); + process.exit(1); + } + } else { + // Regular task (not a subtask) + targetTask = data.tasks.find((t) => t.id === formattedTaskId); + + if (!targetTask) { + log('error', `Task ${formattedTaskId} not found.`); + process.exit(1); + } + } + + // Initialize dependencies array if it doesn't exist + if (!targetTask.dependencies) { + targetTask.dependencies = []; + } + + // Check if dependency already exists + if ( + targetTask.dependencies.some((d) => { + // Convert both to strings for comparison to handle both numeric and string IDs + return String(d) === String(formattedDependencyId); + }) + ) { + log( + 'warn', + `Dependency ${formattedDependencyId} already exists in task ${formattedTaskId}.` + ); + return; + } + + // Check if the task is trying to depend on itself - compare full IDs (including subtask parts) + if (String(formattedTaskId) === String(formattedDependencyId)) { + log('error', `Task ${formattedTaskId} cannot depend on itself.`); + process.exit(1); + } + + // For subtasks of the same parent, we need to make sure we're not treating it as a self-dependency + // Check if we're dealing with subtasks with the same parent task + let isSelfDependency = false; + + if ( + typeof formattedTaskId === 'string' && + typeof formattedDependencyId === 'string' && + formattedTaskId.includes('.') && + formattedDependencyId.includes('.') + ) { + const [taskParentId] = formattedTaskId.split('.'); + const [depParentId] = formattedDependencyId.split('.'); + + // Only treat it as a self-dependency if both the parent ID and subtask ID are identical + isSelfDependency = formattedTaskId === formattedDependencyId; + + // Log for debugging + log( + 'debug', + `Adding dependency between subtasks: ${formattedTaskId} depends on ${formattedDependencyId}` + ); + log( + 'debug', + `Parent IDs: ${taskParentId} and ${depParentId}, Self-dependency check: ${isSelfDependency}` + ); + } + + if (isSelfDependency) { + log('error', `Subtask ${formattedTaskId} cannot depend on itself.`); + process.exit(1); + } + + // Check for circular dependencies + let dependencyChain = [formattedTaskId]; + if ( + !isCircularDependency(data.tasks, formattedDependencyId, dependencyChain) + ) { + // Add the dependency + targetTask.dependencies.push(formattedDependencyId); + + // Sort dependencies numerically or by parent task ID first, then subtask ID + targetTask.dependencies.sort((a, b) => { + if (typeof a === 'number' && typeof b === 'number') { + return a - b; + } else if (typeof a === 'string' && typeof b === 'string') { + const [aParent, aChild] = a.split('.').map(Number); + const [bParent, bChild] = b.split('.').map(Number); + return aParent !== bParent ? aParent - bParent : aChild - bChild; + } else if (typeof a === 'number') { + return -1; // Numbers come before strings + } else { + return 1; // Strings come after numbers + } + }); + + // Save changes + writeJSON(tasksPath, data); + log( + 'success', + `Added dependency ${formattedDependencyId} to task ${formattedTaskId}` + ); + + // Display a more visually appealing success message + console.log( + boxen( + chalk.green(`Successfully added dependency:\n\n`) + + `Task ${chalk.bold(formattedTaskId)} now depends on ${chalk.bold(formattedDependencyId)}`, + { + padding: 1, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + + // Generate updated task files + await generateTaskFiles(tasksPath, 'tasks'); + + log('info', 'Task files regenerated with updated dependencies.'); + } else { + log( + 'error', + `Cannot add dependency ${formattedDependencyId} to task ${formattedTaskId} as it would create a circular dependency.` + ); + process.exit(1); + } +} + +/** + * Remove a dependency from a task + * @param {string} tasksPath - Path to the tasks.json file + * @param {number|string} taskId - ID of the task to remove dependency from + * @param {number|string} dependencyId - ID of the task to remove as dependency + */ +async function removeDependency(tasksPath, taskId, dependencyId) { + log('info', `Removing dependency ${dependencyId} from task ${taskId}...`); + + // Read tasks file + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + log('error', 'No valid tasks found.'); + process.exit(1); + } + + // Format the task and dependency IDs correctly + const formattedTaskId = + typeof taskId === 'string' && taskId.includes('.') + ? taskId + : parseInt(taskId, 10); + + const formattedDependencyId = formatTaskId(dependencyId); + + // Find the task to update + let targetTask = null; + let isSubtask = false; + + if (typeof formattedTaskId === 'string' && formattedTaskId.includes('.')) { + // Handle dot notation for subtasks (e.g., "1.2") + const [parentId, subtaskId] = formattedTaskId + .split('.') + .map((id) => parseInt(id, 10)); + const parentTask = data.tasks.find((t) => t.id === parentId); + + if (!parentTask) { + log('error', `Parent task ${parentId} not found.`); + process.exit(1); + } + + if (!parentTask.subtasks) { + log('error', `Parent task ${parentId} has no subtasks.`); + process.exit(1); + } + + targetTask = parentTask.subtasks.find((s) => s.id === subtaskId); + isSubtask = true; + + if (!targetTask) { + log('error', `Subtask ${formattedTaskId} not found.`); + process.exit(1); + } + } else { + // Regular task (not a subtask) + targetTask = data.tasks.find((t) => t.id === formattedTaskId); + + if (!targetTask) { + log('error', `Task ${formattedTaskId} not found.`); + process.exit(1); + } + } + + // Check if the task has any dependencies + if (!targetTask.dependencies || targetTask.dependencies.length === 0) { + log( + 'info', + `Task ${formattedTaskId} has no dependencies, nothing to remove.` + ); + return; + } + + // Normalize the dependency ID for comparison to handle different formats + const normalizedDependencyId = String(formattedDependencyId); + + // Check if the dependency exists by comparing string representations + const dependencyIndex = targetTask.dependencies.findIndex((dep) => { + // Convert both to strings for comparison + let depStr = String(dep); + + // Special handling for numeric IDs that might be subtask references + if (typeof dep === 'number' && dep < 100 && isSubtask) { + // It's likely a reference to another subtask in the same parent task + // Convert to full format for comparison (e.g., 2 -> "1.2" for a subtask in task 1) + const [parentId] = formattedTaskId.split('.'); + depStr = `${parentId}.${dep}`; + } + + return depStr === normalizedDependencyId; + }); + + if (dependencyIndex === -1) { + log( + 'info', + `Task ${formattedTaskId} does not depend on ${formattedDependencyId}, no changes made.` + ); + return; + } + + // Remove the dependency + targetTask.dependencies.splice(dependencyIndex, 1); + + // Save the updated tasks + writeJSON(tasksPath, data); + + // Success message + log( + 'success', + `Removed dependency: Task ${formattedTaskId} no longer depends on ${formattedDependencyId}` + ); + + // Display a more visually appealing success message + console.log( + boxen( + chalk.green(`Successfully removed dependency:\n\n`) + + `Task ${chalk.bold(formattedTaskId)} no longer depends on ${chalk.bold(formattedDependencyId)}`, + { + padding: 1, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + + // Regenerate task files + await generateTaskFiles(tasksPath, 'tasks'); +} + +/** + * Check if adding a dependency would create a circular dependency + * @param {Array} tasks - Array of all tasks + * @param {number|string} taskId - ID of task to check + * @param {Array} chain - Chain of dependencies to check + * @returns {boolean} True if circular dependency would be created + */ +function isCircularDependency(tasks, taskId, chain = []) { + // Convert taskId to string for comparison + const taskIdStr = String(taskId); + + // If we've seen this task before in the chain, we have a circular dependency + if (chain.some((id) => String(id) === taskIdStr)) { + return true; + } + + // Find the task or subtask + let task = null; + + // Check if this is a subtask reference (e.g., "1.2") + if (taskIdStr.includes('.')) { + const [parentId, subtaskId] = taskIdStr.split('.').map(Number); + const parentTask = tasks.find((t) => t.id === parentId); + + if (parentTask && parentTask.subtasks) { + task = parentTask.subtasks.find((st) => st.id === subtaskId); + } + } else { + // Regular task + task = tasks.find((t) => String(t.id) === taskIdStr); + } + + if (!task) { + return false; // Task doesn't exist, can't create circular dependency + } + + // No dependencies, can't create circular dependency + if (!task.dependencies || task.dependencies.length === 0) { + return false; + } + + // Check each dependency recursively + const newChain = [...chain, taskId]; + return task.dependencies.some((depId) => + isCircularDependency(tasks, depId, newChain) + ); +} + +/** + * Validate task dependencies + * @param {Array} tasks - Array of all tasks + * @returns {Object} Validation result with valid flag and issues array + */ +function validateTaskDependencies(tasks) { + const issues = []; + + // Check each task's dependencies + tasks.forEach((task) => { + if (!task.dependencies) { + return; // No dependencies to validate + } + + task.dependencies.forEach((depId) => { + // Check for self-dependencies + if (String(depId) === String(task.id)) { + issues.push({ + type: 'self', + taskId: task.id, + message: `Task ${task.id} depends on itself` + }); + return; + } + + // Check if dependency exists + if (!taskExists(tasks, depId)) { + issues.push({ + type: 'missing', + taskId: task.id, + dependencyId: depId, + message: `Task ${task.id} depends on non-existent task ${depId}` + }); + } + }); + + // Check for circular dependencies + if (isCircularDependency(tasks, task.id)) { + issues.push({ + type: 'circular', + taskId: task.id, + message: `Task ${task.id} is part of a circular dependency chain` + }); + } + + // Check subtask dependencies if they exist + if (task.subtasks && task.subtasks.length > 0) { + task.subtasks.forEach((subtask) => { + if (!subtask.dependencies) { + return; // No dependencies to validate + } + + // Create a full subtask ID for reference + const fullSubtaskId = `${task.id}.${subtask.id}`; + + subtask.dependencies.forEach((depId) => { + // Check for self-dependencies in subtasks + if ( + String(depId) === String(fullSubtaskId) || + (typeof depId === 'number' && depId === subtask.id) + ) { + issues.push({ + type: 'self', + taskId: fullSubtaskId, + message: `Subtask ${fullSubtaskId} depends on itself` + }); + return; + } + + // Check if dependency exists + if (!taskExists(tasks, depId)) { + issues.push({ + type: 'missing', + taskId: fullSubtaskId, + dependencyId: depId, + message: `Subtask ${fullSubtaskId} depends on non-existent task/subtask ${depId}` + }); + } + }); + + // Check for circular dependencies in subtasks + if (isCircularDependency(tasks, fullSubtaskId)) { + issues.push({ + type: 'circular', + taskId: fullSubtaskId, + message: `Subtask ${fullSubtaskId} is part of a circular dependency chain` + }); + } + }); + } + }); + + return { + valid: issues.length === 0, + issues + }; +} + +/** + * Remove duplicate dependencies from tasks + * @param {Object} tasksData - Tasks data object with tasks array + * @returns {Object} Updated tasks data with duplicates removed + */ +function removeDuplicateDependencies(tasksData) { + const tasks = tasksData.tasks.map((task) => { + if (!task.dependencies) { + return task; + } + + // Convert to Set and back to array to remove duplicates + const uniqueDeps = [...new Set(task.dependencies)]; + return { + ...task, + dependencies: uniqueDeps + }; + }); + + return { + ...tasksData, + tasks + }; +} + +/** + * Clean up invalid subtask dependencies + * @param {Object} tasksData - Tasks data object with tasks array + * @returns {Object} Updated tasks data with invalid subtask dependencies removed + */ +function cleanupSubtaskDependencies(tasksData) { + const tasks = tasksData.tasks.map((task) => { + // Handle task's own dependencies + if (task.dependencies) { + task.dependencies = task.dependencies.filter((depId) => { + // Keep only dependencies that exist + return taskExists(tasksData.tasks, depId); + }); + } + + // Handle subtask dependencies + if (task.subtasks) { + task.subtasks = task.subtasks.map((subtask) => { + if (!subtask.dependencies) { + return subtask; + } + + // Filter out dependencies to non-existent subtasks + subtask.dependencies = subtask.dependencies.filter((depId) => { + return taskExists(tasksData.tasks, depId); + }); + + return subtask; + }); + } + + return task; + }); + + return { + ...tasksData, + tasks + }; +} + +/** + * Validate dependencies in task files + * @param {string} tasksPath - Path to tasks.json + */ +async function validateDependenciesCommand(tasksPath) { + displayBanner(); + + log('info', 'Checking for invalid dependencies in task files...'); + + // Read tasks data + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + log('error', 'No valid tasks found in tasks.json'); + process.exit(1); + } + + // Count of tasks and subtasks for reporting + const taskCount = data.tasks.length; + let subtaskCount = 0; + data.tasks.forEach((task) => { + if (task.subtasks && Array.isArray(task.subtasks)) { + subtaskCount += task.subtasks.length; + } + }); + + log( + 'info', + `Analyzing dependencies for ${taskCount} tasks and ${subtaskCount} subtasks...` + ); + + // Track validation statistics + const stats = { + nonExistentDependenciesRemoved: 0, + selfDependenciesRemoved: 0, + tasksFixed: 0, + subtasksFixed: 0 + }; + + // Create a custom logger instead of reassigning the imported log function + const warnings = []; + const customLogger = function (level, ...args) { + if (level === 'warn') { + warnings.push(args.join(' ')); + + // Count the type of fix based on the warning message + const msg = args.join(' '); + if (msg.includes('self-dependency')) { + stats.selfDependenciesRemoved++; + } else if (msg.includes('invalid')) { + stats.nonExistentDependenciesRemoved++; + } + + // Count if it's a task or subtask being fixed + if (msg.includes('from subtask')) { + stats.subtasksFixed++; + } else if (msg.includes('from task')) { + stats.tasksFixed++; + } + } + // Call the original log function + return log(level, ...args); + }; + + // Run validation with custom logger + try { + // Temporarily save validateTaskDependencies function with normal log + const originalValidateTaskDependencies = validateTaskDependencies; + + // Create patched version that uses customLogger + const patchedValidateTaskDependencies = (tasks, tasksPath) => { + // Temporarily redirect log calls in this scope + const originalLog = log; + const logProxy = function (...args) { + return customLogger(...args); + }; + + // Call the original function in a context where log calls are intercepted + const result = (() => { + // Use Function.prototype.bind to create a new function that has logProxy available + return Function( + 'tasks', + 'tasksPath', + 'log', + 'customLogger', + `return (${originalValidateTaskDependencies.toString()})(tasks, tasksPath);` + )(tasks, tasksPath, logProxy, customLogger); + })(); + + return result; + }; + + const changesDetected = patchedValidateTaskDependencies( + data.tasks, + tasksPath + ); + + // Create a detailed report + if (changesDetected) { + log('success', 'Invalid dependencies were removed from tasks.json'); + + // Show detailed stats in a nice box + console.log( + boxen( + chalk.green(`Dependency Validation Results:\n\n`) + + `${chalk.cyan('Tasks checked:')} ${taskCount}\n` + + `${chalk.cyan('Subtasks checked:')} ${subtaskCount}\n` + + `${chalk.cyan('Non-existent dependencies removed:')} ${stats.nonExistentDependenciesRemoved}\n` + + `${chalk.cyan('Self-dependencies removed:')} ${stats.selfDependenciesRemoved}\n` + + `${chalk.cyan('Tasks fixed:')} ${stats.tasksFixed}\n` + + `${chalk.cyan('Subtasks fixed:')} ${stats.subtasksFixed}`, + { + padding: 1, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + } + ) + ); + + // Show all warnings in a collapsible list if there are many + if (warnings.length > 0) { + console.log(chalk.yellow('\nDetailed fixes:')); + warnings.forEach((warning) => { + console.log(` ${warning}`); + }); + } + + // Regenerate task files to reflect the changes + await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + log('info', 'Task files regenerated to reflect dependency changes'); + } else { + log( + 'success', + 'No invalid dependencies found - all dependencies are valid' + ); + + // Show validation summary + console.log( + boxen( + chalk.green(`All Dependencies Are Valid\n\n`) + + `${chalk.cyan('Tasks checked:')} ${taskCount}\n` + + `${chalk.cyan('Subtasks checked:')} ${subtaskCount}\n` + + `${chalk.cyan('Total dependencies verified:')} ${countAllDependencies(data.tasks)}`, + { + padding: 1, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + } + ) + ); + } + } catch (error) { + log('error', 'Error validating dependencies:', error); + process.exit(1); + } +} + +/** + * Helper function to count all dependencies across tasks and subtasks + * @param {Array} tasks - All tasks + * @returns {number} - Total number of dependencies + */ +function countAllDependencies(tasks) { + let count = 0; + + tasks.forEach((task) => { + // Count main task dependencies + if (task.dependencies && Array.isArray(task.dependencies)) { + count += task.dependencies.length; + } + + // Count subtask dependencies + if (task.subtasks && Array.isArray(task.subtasks)) { + task.subtasks.forEach((subtask) => { + if (subtask.dependencies && Array.isArray(subtask.dependencies)) { + count += subtask.dependencies.length; + } + }); + } + }); + + return count; +} + +/** + * Fixes invalid dependencies in tasks.json + * @param {string} tasksPath - Path to tasks.json + */ +async function fixDependenciesCommand(tasksPath) { + displayBanner(); + + log('info', 'Checking for and fixing invalid dependencies in tasks.json...'); + + try { + // Read tasks data + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + log('error', 'No valid tasks found in tasks.json'); + process.exit(1); + } + + // Create a deep copy of the original data for comparison + const originalData = JSON.parse(JSON.stringify(data)); + + // Track fixes for reporting + const stats = { + nonExistentDependenciesRemoved: 0, + selfDependenciesRemoved: 0, + duplicateDependenciesRemoved: 0, + circularDependenciesFixed: 0, + tasksFixed: 0, + subtasksFixed: 0 + }; + + // First phase: Remove duplicate dependencies in tasks + data.tasks.forEach((task) => { + if (task.dependencies && Array.isArray(task.dependencies)) { + const uniqueDeps = new Set(); + const originalLength = task.dependencies.length; + task.dependencies = task.dependencies.filter((depId) => { + const depIdStr = String(depId); + if (uniqueDeps.has(depIdStr)) { + log( + 'info', + `Removing duplicate dependency from task ${task.id}: ${depId}` + ); + stats.duplicateDependenciesRemoved++; + return false; + } + uniqueDeps.add(depIdStr); + return true; + }); + if (task.dependencies.length < originalLength) { + stats.tasksFixed++; + } + } + + // Check for duplicates in subtasks + if (task.subtasks && Array.isArray(task.subtasks)) { + task.subtasks.forEach((subtask) => { + if (subtask.dependencies && Array.isArray(subtask.dependencies)) { + const uniqueDeps = new Set(); + const originalLength = subtask.dependencies.length; + subtask.dependencies = subtask.dependencies.filter((depId) => { + let depIdStr = String(depId); + if (typeof depId === 'number' && depId < 100) { + depIdStr = `${task.id}.${depId}`; + } + if (uniqueDeps.has(depIdStr)) { + log( + 'info', + `Removing duplicate dependency from subtask ${task.id}.${subtask.id}: ${depId}` + ); + stats.duplicateDependenciesRemoved++; + return false; + } + uniqueDeps.add(depIdStr); + return true; + }); + if (subtask.dependencies.length < originalLength) { + stats.subtasksFixed++; + } + } + }); + } + }); + + // Create validity maps for tasks and subtasks + const validTaskIds = new Set(data.tasks.map((t) => t.id)); + const validSubtaskIds = new Set(); + data.tasks.forEach((task) => { + if (task.subtasks && Array.isArray(task.subtasks)) { + task.subtasks.forEach((subtask) => { + validSubtaskIds.add(`${task.id}.${subtask.id}`); + }); + } + }); + + // Second phase: Remove invalid task dependencies (non-existent tasks) + data.tasks.forEach((task) => { + if (task.dependencies && Array.isArray(task.dependencies)) { + const originalLength = task.dependencies.length; + task.dependencies = task.dependencies.filter((depId) => { + const isSubtask = typeof depId === 'string' && depId.includes('.'); + + if (isSubtask) { + // Check if the subtask exists + if (!validSubtaskIds.has(depId)) { + log( + 'info', + `Removing invalid subtask dependency from task ${task.id}: ${depId} (subtask does not exist)` + ); + stats.nonExistentDependenciesRemoved++; + return false; + } + return true; + } else { + // Check if the task exists + const numericId = + typeof depId === 'string' ? parseInt(depId, 10) : depId; + if (!validTaskIds.has(numericId)) { + log( + 'info', + `Removing invalid task dependency from task ${task.id}: ${depId} (task does not exist)` + ); + stats.nonExistentDependenciesRemoved++; + return false; + } + return true; + } + }); + + if (task.dependencies.length < originalLength) { + stats.tasksFixed++; + } + } + + // Check subtask dependencies for invalid references + if (task.subtasks && Array.isArray(task.subtasks)) { + task.subtasks.forEach((subtask) => { + if (subtask.dependencies && Array.isArray(subtask.dependencies)) { + const originalLength = subtask.dependencies.length; + const subtaskId = `${task.id}.${subtask.id}`; + + // First check for self-dependencies + const hasSelfDependency = subtask.dependencies.some((depId) => { + if (typeof depId === 'string' && depId.includes('.')) { + return depId === subtaskId; + } else if (typeof depId === 'number' && depId < 100) { + return depId === subtask.id; + } + return false; + }); + + if (hasSelfDependency) { + subtask.dependencies = subtask.dependencies.filter((depId) => { + const normalizedDepId = + typeof depId === 'number' && depId < 100 + ? `${task.id}.${depId}` + : String(depId); + + if (normalizedDepId === subtaskId) { + log( + 'info', + `Removing self-dependency from subtask ${subtaskId}` + ); + stats.selfDependenciesRemoved++; + return false; + } + return true; + }); + } + + // Then check for non-existent dependencies + subtask.dependencies = subtask.dependencies.filter((depId) => { + if (typeof depId === 'string' && depId.includes('.')) { + if (!validSubtaskIds.has(depId)) { + log( + 'info', + `Removing invalid subtask dependency from subtask ${subtaskId}: ${depId} (subtask does not exist)` + ); + stats.nonExistentDependenciesRemoved++; + return false; + } + return true; + } + + // Handle numeric dependencies + const numericId = + typeof depId === 'number' ? depId : parseInt(depId, 10); + + // Small numbers likely refer to subtasks in the same task + if (numericId < 100) { + const fullSubtaskId = `${task.id}.${numericId}`; + + if (!validSubtaskIds.has(fullSubtaskId)) { + log( + 'info', + `Removing invalid subtask dependency from subtask ${subtaskId}: ${numericId}` + ); + stats.nonExistentDependenciesRemoved++; + return false; + } + + return true; + } + + // Otherwise it's a task reference + if (!validTaskIds.has(numericId)) { + log( + 'info', + `Removing invalid task dependency from subtask ${subtaskId}: ${numericId}` + ); + stats.nonExistentDependenciesRemoved++; + return false; + } + + return true; + }); + + if (subtask.dependencies.length < originalLength) { + stats.subtasksFixed++; + } + } + }); + } + }); + + // Third phase: Check for circular dependencies + log('info', 'Checking for circular dependencies...'); + + // Build the dependency map for subtasks + const subtaskDependencyMap = new Map(); + data.tasks.forEach((task) => { + if (task.subtasks && Array.isArray(task.subtasks)) { + task.subtasks.forEach((subtask) => { + const subtaskId = `${task.id}.${subtask.id}`; + + if (subtask.dependencies && Array.isArray(subtask.dependencies)) { + const normalizedDeps = subtask.dependencies.map((depId) => { + if (typeof depId === 'string' && depId.includes('.')) { + return depId; + } else if (typeof depId === 'number' && depId < 100) { + return `${task.id}.${depId}`; + } + return String(depId); + }); + subtaskDependencyMap.set(subtaskId, normalizedDeps); + } else { + subtaskDependencyMap.set(subtaskId, []); + } + }); + } + }); + + // Check for and fix circular dependencies + for (const [subtaskId, dependencies] of subtaskDependencyMap.entries()) { + const visited = new Set(); + const recursionStack = new Set(); + + // Detect cycles + const cycleEdges = findCycles( + subtaskId, + subtaskDependencyMap, + visited, + recursionStack + ); + + if (cycleEdges.length > 0) { + const [taskId, subtaskNum] = subtaskId + .split('.') + .map((part) => Number(part)); + const task = data.tasks.find((t) => t.id === taskId); + + if (task && task.subtasks) { + const subtask = task.subtasks.find((st) => st.id === subtaskNum); + + if (subtask && subtask.dependencies) { + const originalLength = subtask.dependencies.length; + + const edgesToRemove = cycleEdges.map((edge) => { + if (edge.includes('.')) { + const [depTaskId, depSubtaskId] = edge + .split('.') + .map((part) => Number(part)); + + if (depTaskId === taskId) { + return depSubtaskId; + } + + return edge; + } + + return Number(edge); + }); + + subtask.dependencies = subtask.dependencies.filter((depId) => { + const normalizedDepId = + typeof depId === 'number' && depId < 100 + ? `${taskId}.${depId}` + : String(depId); + + if ( + edgesToRemove.includes(depId) || + edgesToRemove.includes(normalizedDepId) + ) { + log( + 'info', + `Breaking circular dependency: Removing ${normalizedDepId} from subtask ${subtaskId}` + ); + stats.circularDependenciesFixed++; + return false; + } + return true; + }); + + if (subtask.dependencies.length < originalLength) { + stats.subtasksFixed++; + } + } + } + } + } + + // Check if any changes were made by comparing with original data + const dataChanged = JSON.stringify(data) !== JSON.stringify(originalData); + + if (dataChanged) { + // Save the changes + writeJSON(tasksPath, data); + log('success', 'Fixed dependency issues in tasks.json'); + + // Regenerate task files + log('info', 'Regenerating task files to reflect dependency changes...'); + await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + } else { + log('info', 'No changes needed to fix dependencies'); + } + + // Show detailed statistics report + const totalFixedAll = + stats.nonExistentDependenciesRemoved + + stats.selfDependenciesRemoved + + stats.duplicateDependenciesRemoved + + stats.circularDependenciesFixed; + + if (totalFixedAll > 0) { + log('success', `Fixed ${totalFixedAll} dependency issues in total!`); + + console.log( + boxen( + chalk.green(`Dependency Fixes Summary:\n\n`) + + `${chalk.cyan('Invalid dependencies removed:')} ${stats.nonExistentDependenciesRemoved}\n` + + `${chalk.cyan('Self-dependencies removed:')} ${stats.selfDependenciesRemoved}\n` + + `${chalk.cyan('Duplicate dependencies removed:')} ${stats.duplicateDependenciesRemoved}\n` + + `${chalk.cyan('Circular dependencies fixed:')} ${stats.circularDependenciesFixed}\n\n` + + `${chalk.cyan('Tasks fixed:')} ${stats.tasksFixed}\n` + + `${chalk.cyan('Subtasks fixed:')} ${stats.subtasksFixed}\n`, + { + padding: 1, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + } + ) + ); + } else { + log('success', 'No dependency issues found - all dependencies are valid'); + + console.log( + boxen( + chalk.green(`All Dependencies Are Valid\n\n`) + + `${chalk.cyan('Tasks checked:')} ${data.tasks.length}\n` + + `${chalk.cyan('Total dependencies verified:')} ${countAllDependencies(data.tasks)}`, + { + padding: 1, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + } + ) + ); + } + } catch (error) { + log('error', 'Error in fix-dependencies command:', error); + process.exit(1); + } +} + +/** + * Ensure at least one subtask in each task has no dependencies + * @param {Object} tasksData - The tasks data object with tasks array + * @returns {boolean} - True if any changes were made + */ +function ensureAtLeastOneIndependentSubtask(tasksData) { + if (!tasksData || !tasksData.tasks || !Array.isArray(tasksData.tasks)) { + return false; + } + + let changesDetected = false; + + tasksData.tasks.forEach((task) => { + if ( + !task.subtasks || + !Array.isArray(task.subtasks) || + task.subtasks.length === 0 + ) { + return; + } + + // Check if any subtask has no dependencies + const hasIndependentSubtask = task.subtasks.some( + (st) => + !st.dependencies || + !Array.isArray(st.dependencies) || + st.dependencies.length === 0 + ); + + if (!hasIndependentSubtask) { + // Find the first subtask and clear its dependencies + if (task.subtasks.length > 0) { + const firstSubtask = task.subtasks[0]; + log( + 'debug', + `Ensuring at least one independent subtask: Clearing dependencies for subtask ${task.id}.${firstSubtask.id}` + ); + firstSubtask.dependencies = []; + changesDetected = true; + } + } + }); + + return changesDetected; +} + +/** + * Validate and fix dependencies across all tasks and subtasks + * This function is designed to be called after any task modification + * @param {Object} tasksData - The tasks data object with tasks array + * @param {string} tasksPath - Optional path to save the changes + * @returns {boolean} - True if any changes were made + */ +function validateAndFixDependencies(tasksData, tasksPath = null) { + if (!tasksData || !tasksData.tasks || !Array.isArray(tasksData.tasks)) { + log('error', 'Invalid tasks data'); + return false; + } + + log('debug', 'Validating and fixing dependencies...'); + + // Create a deep copy for comparison + const originalData = JSON.parse(JSON.stringify(tasksData)); + + // 1. Remove duplicate dependencies from tasks and subtasks + tasksData.tasks = tasksData.tasks.map((task) => { + // Handle task dependencies + if (task.dependencies) { + const uniqueDeps = [...new Set(task.dependencies)]; + task.dependencies = uniqueDeps; + } + + // Handle subtask dependencies + if (task.subtasks) { + task.subtasks = task.subtasks.map((subtask) => { + if (subtask.dependencies) { + const uniqueDeps = [...new Set(subtask.dependencies)]; + subtask.dependencies = uniqueDeps; + } + return subtask; + }); + } + return task; + }); + + // 2. Remove invalid task dependencies (non-existent tasks) + tasksData.tasks.forEach((task) => { + // Clean up task dependencies + if (task.dependencies) { + task.dependencies = task.dependencies.filter((depId) => { + // Remove self-dependencies + if (String(depId) === String(task.id)) { + return false; + } + // Remove non-existent dependencies + return taskExists(tasksData.tasks, depId); + }); + } + + // Clean up subtask dependencies + if (task.subtasks) { + task.subtasks.forEach((subtask) => { + if (subtask.dependencies) { + subtask.dependencies = subtask.dependencies.filter((depId) => { + // Handle numeric subtask references + if (typeof depId === 'number' && depId < 100) { + const fullSubtaskId = `${task.id}.${depId}`; + return taskExists(tasksData.tasks, fullSubtaskId); + } + // Handle full task/subtask references + return taskExists(tasksData.tasks, depId); + }); + } + }); + } + }); + + // 3. Ensure at least one subtask has no dependencies in each task + tasksData.tasks.forEach((task) => { + if (task.subtasks && task.subtasks.length > 0) { + const hasIndependentSubtask = task.subtasks.some( + (st) => + !st.dependencies || + !Array.isArray(st.dependencies) || + st.dependencies.length === 0 + ); + + if (!hasIndependentSubtask) { + task.subtasks[0].dependencies = []; + } + } + }); + + // Check if any changes were made by comparing with original data + const changesDetected = + JSON.stringify(tasksData) !== JSON.stringify(originalData); + + // Save changes if needed + if (tasksPath && changesDetected) { + try { + writeJSON(tasksPath, tasksData); + log('debug', 'Saved dependency fixes to tasks.json'); + } catch (error) { + log('error', 'Failed to save dependency fixes to tasks.json', error); + } + } + + return changesDetected; +} + +export { + addDependency, + removeDependency, + isCircularDependency, + validateTaskDependencies, + validateDependenciesCommand, + fixDependenciesCommand, + removeDuplicateDependencies, + cleanupSubtaskDependencies, + ensureAtLeastOneIndependentSubtask, + validateAndFixDependencies +}; diff --git a/scripts/modules/index.js b/scripts/modules/index.js index a06fdbac..28361678 100644 --- a/scripts/modules/index.js +++ b/scripts/modules/index.js @@ -8,4 +8,4 @@ export * from './utils.js'; export * from './ui.js'; export * from './ai-services.js'; export * from './task-manager.js'; -export * from './commands.js'; \ No newline at end of file +export * from './commands.js'; diff --git a/scripts/modules/task-manager.js b/scripts/modules/task-manager.js index 3df5a44c..0387f6f3 100644 --- a/scripts/modules/task-manager.js +++ b/scripts/modules/task-manager.js @@ -11,65 +11,68 @@ import Table from 'cli-table3'; import readline from 'readline'; import { Anthropic } from '@anthropic-ai/sdk'; -import { - CONFIG, - log, - readJSON, - writeJSON, - sanitizePrompt, - findTaskById, - readComplexityReport, - findTaskInComplexityReport, - truncate +import { + CONFIG, + log, + readJSON, + writeJSON, + sanitizePrompt, + findTaskById, + readComplexityReport, + findTaskInComplexityReport, + truncate } from './utils.js'; import { - displayBanner, - getStatusWithColor, - formatDependenciesWithStatus, - getComplexityWithColor, - startLoadingIndicator, - stopLoadingIndicator, - createProgressBar + displayBanner, + getStatusWithColor, + formatDependenciesWithStatus, + getComplexityWithColor, + startLoadingIndicator, + stopLoadingIndicator, + createProgressBar } from './ui.js'; import { - callClaude, - generateSubtasks, - generateSubtasksWithPerplexity, - generateComplexityAnalysisPrompt, - getAvailableAIModel, - handleClaudeError + callClaude, + generateSubtasks, + generateSubtasksWithPerplexity, + generateComplexityAnalysisPrompt, + getAvailableAIModel, + handleClaudeError } from './ai-services.js'; import { - validateTaskDependencies, - validateAndFixDependencies + validateTaskDependencies, + validateAndFixDependencies } from './dependency-manager.js'; // Initialize Anthropic client const anthropic = new Anthropic({ - apiKey: process.env.ANTHROPIC_API_KEY, + apiKey: process.env.ANTHROPIC_API_KEY }); // Import perplexity if available let perplexity; try { - if (process.env.PERPLEXITY_API_KEY) { - // Using the existing approach from ai-services.js - const OpenAI = (await import('openai')).default; - - perplexity = new OpenAI({ - apiKey: process.env.PERPLEXITY_API_KEY, - baseURL: 'https://api.perplexity.ai', - }); - - log('info', `Initialized Perplexity client with OpenAI compatibility layer`); - } + if (process.env.PERPLEXITY_API_KEY) { + // Using the existing approach from ai-services.js + const OpenAI = (await import('openai')).default; + + perplexity = new OpenAI({ + apiKey: process.env.PERPLEXITY_API_KEY, + baseURL: 'https://api.perplexity.ai' + }); + + log( + 'info', + `Initialized Perplexity client with OpenAI compatibility layer` + ); + } } catch (error) { - log('warn', `Failed to initialize Perplexity client: ${error.message}`); - log('warn', 'Research-backed features will not be available'); + log('warn', `Failed to initialize Perplexity client: ${error.message}`); + log('warn', 'Research-backed features will not be available'); } /** @@ -79,51 +82,66 @@ try { * @param {number} numTasks - Number of tasks to generate */ async function parsePRD(prdPath, tasksPath, numTasks) { - try { - log('info', `Parsing PRD file: ${prdPath}`); - - // Read the PRD content - const prdContent = fs.readFileSync(prdPath, 'utf8'); - - // Call Claude to generate tasks - const tasksData = await callClaude(prdContent, prdPath, numTasks); - - // Create the directory if it doesn't exist - const tasksDir = path.dirname(tasksPath); - if (!fs.existsSync(tasksDir)) { - fs.mkdirSync(tasksDir, { recursive: true }); - } - - // Write the tasks to the file - writeJSON(tasksPath, tasksData); - - log('success', `Successfully generated ${tasksData.tasks.length} tasks from PRD`); - log('info', `Tasks saved to: ${tasksPath}`); - - // Generate individual task files - await generateTaskFiles(tasksPath, tasksDir); - - console.log(boxen( - chalk.green(`Successfully generated ${tasksData.tasks.length} tasks from PRD`), - { padding: 1, borderColor: 'green', borderStyle: 'round' } - )); - - console.log(boxen( - chalk.white.bold('Next Steps:') + '\n\n' + - `${chalk.cyan('1.')} Run ${chalk.yellow('task-master list')} to view all tasks\n` + - `${chalk.cyan('2.')} Run ${chalk.yellow('task-master expand --id=<id>')} to break down a task into subtasks`, - { padding: 1, borderColor: 'cyan', borderStyle: 'round', margin: { top: 1 } } - )); - } catch (error) { - log('error', `Error parsing PRD: ${error.message}`); - console.error(chalk.red(`Error: ${error.message}`)); - - if (CONFIG.debug) { - console.error(error); - } - - process.exit(1); - } + try { + log('info', `Parsing PRD file: ${prdPath}`); + + // Read the PRD content + const prdContent = fs.readFileSync(prdPath, 'utf8'); + + // Call Claude to generate tasks + const tasksData = await callClaude(prdContent, prdPath, numTasks); + + // Create the directory if it doesn't exist + const tasksDir = path.dirname(tasksPath); + if (!fs.existsSync(tasksDir)) { + fs.mkdirSync(tasksDir, { recursive: true }); + } + + // Write the tasks to the file + writeJSON(tasksPath, tasksData); + + log( + 'success', + `Successfully generated ${tasksData.tasks.length} tasks from PRD` + ); + log('info', `Tasks saved to: ${tasksPath}`); + + // Generate individual task files + await generateTaskFiles(tasksPath, tasksDir); + + console.log( + boxen( + chalk.green( + `Successfully generated ${tasksData.tasks.length} tasks from PRD` + ), + { padding: 1, borderColor: 'green', borderStyle: 'round' } + ) + ); + + console.log( + boxen( + chalk.white.bold('Next Steps:') + + '\n\n' + + `${chalk.cyan('1.')} Run ${chalk.yellow('task-master list')} to view all tasks\n` + + `${chalk.cyan('2.')} Run ${chalk.yellow('task-master expand --id=<id>')} to break down a task into subtasks`, + { + padding: 1, + borderColor: 'cyan', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + } catch (error) { + log('error', `Error parsing PRD: ${error.message}`); + console.error(chalk.red(`Error: ${error.message}`)); + + if (CONFIG.debug) { + console.error(error); + } + + process.exit(1); + } } /** @@ -134,67 +152,100 @@ async function parsePRD(prdPath, tasksPath, numTasks) { * @param {boolean} useResearch - Whether to use Perplexity AI for research */ async function updateTasks(tasksPath, fromId, prompt, useResearch = false) { - try { - log('info', `Updating tasks from ID ${fromId} with prompt: "${prompt}"`); - - // Validate research flag - if (useResearch && (!perplexity || !process.env.PERPLEXITY_API_KEY)) { - log('warn', 'Perplexity AI is not available. Falling back to Claude AI.'); - console.log(chalk.yellow('Perplexity AI is not available (API key may be missing). Falling back to Claude AI.')); - useResearch = false; - } - - // Read the tasks file - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - throw new Error(`No valid tasks found in ${tasksPath}`); - } - - // Find tasks to update (ID >= fromId and not 'done') - const tasksToUpdate = data.tasks.filter(task => task.id >= fromId && task.status !== 'done'); - if (tasksToUpdate.length === 0) { - log('info', `No tasks to update (all tasks with ID >= ${fromId} are already marked as done)`); - console.log(chalk.yellow(`No tasks to update (all tasks with ID >= ${fromId} are already marked as done)`)); - return; - } - - // Show the tasks that will be updated - const table = new Table({ - head: [ - chalk.cyan.bold('ID'), - chalk.cyan.bold('Title'), - chalk.cyan.bold('Status') - ], - colWidths: [5, 60, 10] - }); - - tasksToUpdate.forEach(task => { - table.push([ - task.id, - truncate(task.title, 57), - getStatusWithColor(task.status) - ]); - }); - - console.log(boxen( - chalk.white.bold(`Updating ${tasksToUpdate.length} tasks`), - { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 0 } } - )); - - console.log(table.toString()); - - // Display a message about how completed subtasks are handled - console.log(boxen( - chalk.cyan.bold('How Completed Subtasks Are Handled:') + '\n\n' + - chalk.white('• Subtasks marked as "done" or "completed" will be preserved\n') + - chalk.white('• New subtasks will build upon what has already been completed\n') + - chalk.white('• If completed work needs revision, a new subtask will be created instead of modifying done items\n') + - chalk.white('• This approach maintains a clear record of completed work and new requirements'), - { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 1 } } - )); - - // Build the system prompt - const systemPrompt = `You are an AI assistant helping to update software development tasks based on new context. + try { + log('info', `Updating tasks from ID ${fromId} with prompt: "${prompt}"`); + + // Validate research flag + if (useResearch && (!perplexity || !process.env.PERPLEXITY_API_KEY)) { + log('warn', 'Perplexity AI is not available. Falling back to Claude AI.'); + console.log( + chalk.yellow( + 'Perplexity AI is not available (API key may be missing). Falling back to Claude AI.' + ) + ); + useResearch = false; + } + + // Read the tasks file + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + throw new Error(`No valid tasks found in ${tasksPath}`); + } + + // Find tasks to update (ID >= fromId and not 'done') + const tasksToUpdate = data.tasks.filter( + (task) => task.id >= fromId && task.status !== 'done' + ); + if (tasksToUpdate.length === 0) { + log( + 'info', + `No tasks to update (all tasks with ID >= ${fromId} are already marked as done)` + ); + console.log( + chalk.yellow( + `No tasks to update (all tasks with ID >= ${fromId} are already marked as done)` + ) + ); + return; + } + + // Show the tasks that will be updated + const table = new Table({ + head: [ + chalk.cyan.bold('ID'), + chalk.cyan.bold('Title'), + chalk.cyan.bold('Status') + ], + colWidths: [5, 60, 10] + }); + + tasksToUpdate.forEach((task) => { + table.push([ + task.id, + truncate(task.title, 57), + getStatusWithColor(task.status) + ]); + }); + + console.log( + boxen(chalk.white.bold(`Updating ${tasksToUpdate.length} tasks`), { + padding: 1, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, bottom: 0 } + }) + ); + + console.log(table.toString()); + + // Display a message about how completed subtasks are handled + console.log( + boxen( + chalk.cyan.bold('How Completed Subtasks Are Handled:') + + '\n\n' + + chalk.white( + '• Subtasks marked as "done" or "completed" will be preserved\n' + ) + + chalk.white( + '• New subtasks will build upon what has already been completed\n' + ) + + chalk.white( + '• If completed work needs revision, a new subtask will be created instead of modifying done items\n' + ) + + chalk.white( + '• This approach maintains a clear record of completed work and new requirements' + ), + { + padding: 1, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + } + ) + ); + + // Build the system prompt + const systemPrompt = `You are an AI assistant helping to update software development tasks based on new context. You will be given a set of tasks and a prompt describing changes or new implementation details. Your job is to update the tasks to reflect these changes, while preserving their basic structure. @@ -212,29 +263,31 @@ Guidelines: The changes described in the prompt should be applied to ALL tasks in the list.`; - const taskData = JSON.stringify(tasksToUpdate, null, 2); - - let updatedTasks; - const loadingIndicator = startLoadingIndicator(useResearch - ? 'Updating tasks with Perplexity AI research...' - : 'Updating tasks with Claude AI...'); - - try { - if (useResearch) { - log('info', 'Using Perplexity AI for research-backed task updates'); - - // Call Perplexity AI using format consistent with ai-services.js - const perplexityModel = process.env.PERPLEXITY_MODEL || 'sonar-pro'; - const result = await perplexity.chat.completions.create({ - model: perplexityModel, - messages: [ - { - role: "system", - content: `${systemPrompt}\n\nAdditionally, please research the latest best practices, implementation details, and considerations when updating these tasks. Use your online search capabilities to gather relevant information. Remember to strictly follow the guidelines about preserving completed subtasks and building upon what has already been done rather than modifying or replacing it.` - }, - { - role: "user", - content: `Here are the tasks to update: + const taskData = JSON.stringify(tasksToUpdate, null, 2); + + let updatedTasks; + const loadingIndicator = startLoadingIndicator( + useResearch + ? 'Updating tasks with Perplexity AI research...' + : 'Updating tasks with Claude AI...' + ); + + try { + if (useResearch) { + log('info', 'Using Perplexity AI for research-backed task updates'); + + // Call Perplexity AI using format consistent with ai-services.js + const perplexityModel = process.env.PERPLEXITY_MODEL || 'sonar-pro'; + const result = await perplexity.chat.completions.create({ + model: perplexityModel, + messages: [ + { + role: 'system', + content: `${systemPrompt}\n\nAdditionally, please research the latest best practices, implementation details, and considerations when updating these tasks. Use your online search capabilities to gather relevant information. Remember to strictly follow the guidelines about preserving completed subtasks and building upon what has already been done rather than modifying or replacing it.` + }, + { + role: 'user', + content: `Here are the tasks to update: ${taskData} Please update these tasks based on the following new context: @@ -243,49 +296,55 @@ ${prompt} IMPORTANT: In the tasks JSON above, any subtasks with "status": "done" or "status": "completed" should be preserved exactly as is. Build your changes around these completed items. Return only the updated tasks as a valid JSON array.` - } - ], - temperature: parseFloat(process.env.TEMPERATURE || CONFIG.temperature), - max_tokens: parseInt(process.env.MAX_TOKENS || CONFIG.maxTokens), - }); - - const responseText = result.choices[0].message.content; - - // Extract JSON from response - const jsonStart = responseText.indexOf('['); - const jsonEnd = responseText.lastIndexOf(']'); - - if (jsonStart === -1 || jsonEnd === -1) { - throw new Error("Could not find valid JSON array in Perplexity's response"); - } - - const jsonText = responseText.substring(jsonStart, jsonEnd + 1); - updatedTasks = JSON.parse(jsonText); - } else { - // Call Claude to update the tasks with streaming enabled - let responseText = ''; - let streamingInterval = null; - - try { - // Update loading indicator to show streaming progress - let dotCount = 0; - const readline = await import('readline'); - streamingInterval = setInterval(() => { - readline.cursorTo(process.stdout, 0); - process.stdout.write(`Receiving streaming response from Claude${'.'.repeat(dotCount)}`); - dotCount = (dotCount + 1) % 4; - }, 500); - - // Use streaming API call - const stream = await anthropic.messages.create({ - model: CONFIG.model, - max_tokens: CONFIG.maxTokens, - temperature: CONFIG.temperature, - system: systemPrompt, - messages: [ - { - role: 'user', - content: `Here are the tasks to update: + } + ], + temperature: parseFloat( + process.env.TEMPERATURE || CONFIG.temperature + ), + max_tokens: parseInt(process.env.MAX_TOKENS || CONFIG.maxTokens) + }); + + const responseText = result.choices[0].message.content; + + // Extract JSON from response + const jsonStart = responseText.indexOf('['); + const jsonEnd = responseText.lastIndexOf(']'); + + if (jsonStart === -1 || jsonEnd === -1) { + throw new Error( + "Could not find valid JSON array in Perplexity's response" + ); + } + + const jsonText = responseText.substring(jsonStart, jsonEnd + 1); + updatedTasks = JSON.parse(jsonText); + } else { + // Call Claude to update the tasks with streaming enabled + let responseText = ''; + let streamingInterval = null; + + try { + // Update loading indicator to show streaming progress + let dotCount = 0; + const readline = await import('readline'); + streamingInterval = setInterval(() => { + readline.cursorTo(process.stdout, 0); + process.stdout.write( + `Receiving streaming response from Claude${'.'.repeat(dotCount)}` + ); + dotCount = (dotCount + 1) % 4; + }, 500); + + // Use streaming API call + const stream = await anthropic.messages.create({ + model: CONFIG.model, + max_tokens: CONFIG.maxTokens, + temperature: CONFIG.temperature, + system: systemPrompt, + messages: [ + { + role: 'user', + content: `Here are the tasks to update: ${taskData} Please update these tasks based on the following new context: @@ -294,70 +353,74 @@ ${prompt} IMPORTANT: In the tasks JSON above, any subtasks with "status": "done" or "status": "completed" should be preserved exactly as is. Build your changes around these completed items. Return only the updated tasks as a valid JSON array.` - } - ], - stream: true - }); - - // Process the stream - for await (const chunk of stream) { - if (chunk.type === 'content_block_delta' && chunk.delta.text) { - responseText += chunk.delta.text; - } - } - - if (streamingInterval) clearInterval(streamingInterval); - log('info', "Completed streaming response from Claude API!"); - - // Extract JSON from response - const jsonStart = responseText.indexOf('['); - const jsonEnd = responseText.lastIndexOf(']'); - - if (jsonStart === -1 || jsonEnd === -1) { - throw new Error("Could not find valid JSON array in Claude's response"); - } - - const jsonText = responseText.substring(jsonStart, jsonEnd + 1); - updatedTasks = JSON.parse(jsonText); - } catch (error) { - if (streamingInterval) clearInterval(streamingInterval); - throw error; - } - } - - // Replace the tasks in the original data - updatedTasks.forEach(updatedTask => { - const index = data.tasks.findIndex(t => t.id === updatedTask.id); - if (index !== -1) { - data.tasks[index] = updatedTask; - } - }); - - // Write the updated tasks to the file - writeJSON(tasksPath, data); - - log('success', `Successfully updated ${updatedTasks.length} tasks`); - - // Generate individual task files - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); - - console.log(boxen( - chalk.green(`Successfully updated ${updatedTasks.length} tasks`), - { padding: 1, borderColor: 'green', borderStyle: 'round' } - )); - } finally { - stopLoadingIndicator(loadingIndicator); - } - } catch (error) { - log('error', `Error updating tasks: ${error.message}`); - console.error(chalk.red(`Error: ${error.message}`)); - - if (CONFIG.debug) { - console.error(error); - } - - process.exit(1); - } + } + ], + stream: true + }); + + // Process the stream + for await (const chunk of stream) { + if (chunk.type === 'content_block_delta' && chunk.delta.text) { + responseText += chunk.delta.text; + } + } + + if (streamingInterval) clearInterval(streamingInterval); + log('info', 'Completed streaming response from Claude API!'); + + // Extract JSON from response + const jsonStart = responseText.indexOf('['); + const jsonEnd = responseText.lastIndexOf(']'); + + if (jsonStart === -1 || jsonEnd === -1) { + throw new Error( + "Could not find valid JSON array in Claude's response" + ); + } + + const jsonText = responseText.substring(jsonStart, jsonEnd + 1); + updatedTasks = JSON.parse(jsonText); + } catch (error) { + if (streamingInterval) clearInterval(streamingInterval); + throw error; + } + } + + // Replace the tasks in the original data + updatedTasks.forEach((updatedTask) => { + const index = data.tasks.findIndex((t) => t.id === updatedTask.id); + if (index !== -1) { + data.tasks[index] = updatedTask; + } + }); + + // Write the updated tasks to the file + writeJSON(tasksPath, data); + + log('success', `Successfully updated ${updatedTasks.length} tasks`); + + // Generate individual task files + await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + + console.log( + boxen( + chalk.green(`Successfully updated ${updatedTasks.length} tasks`), + { padding: 1, borderColor: 'green', borderStyle: 'round' } + ) + ); + } finally { + stopLoadingIndicator(loadingIndicator); + } + } catch (error) { + log('error', `Error updating tasks: ${error.message}`); + console.error(chalk.red(`Error: ${error.message}`)); + + if (CONFIG.debug) { + console.error(error); + } + + process.exit(1); + } } /** @@ -369,91 +432,135 @@ Return only the updated tasks as a valid JSON array.` * @returns {Object} - Updated task data or null if task wasn't updated */ async function updateTaskById(tasksPath, taskId, prompt, useResearch = false) { - try { - log('info', `Updating single task ${taskId} with prompt: "${prompt}"`); - - // Validate task ID is a positive integer - if (!Number.isInteger(taskId) || taskId <= 0) { - throw new Error(`Invalid task ID: ${taskId}. Task ID must be a positive integer.`); - } - - // Validate prompt - if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') { - throw new Error('Prompt cannot be empty. Please provide context for the task update.'); - } - - // Validate research flag - if (useResearch && (!perplexity || !process.env.PERPLEXITY_API_KEY)) { - log('warn', 'Perplexity AI is not available. Falling back to Claude AI.'); - console.log(chalk.yellow('Perplexity AI is not available (API key may be missing). Falling back to Claude AI.')); - useResearch = false; - } - - // Validate tasks file exists - if (!fs.existsSync(tasksPath)) { - throw new Error(`Tasks file not found at path: ${tasksPath}`); - } - - // Read the tasks file - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - throw new Error(`No valid tasks found in ${tasksPath}. The file may be corrupted or have an invalid format.`); - } - - // Find the specific task to update - const taskToUpdate = data.tasks.find(task => task.id === taskId); - if (!taskToUpdate) { - throw new Error(`Task with ID ${taskId} not found. Please verify the task ID and try again.`); - } - - // Check if task is already completed - if (taskToUpdate.status === 'done' || taskToUpdate.status === 'completed') { - log('warn', `Task ${taskId} is already marked as done and cannot be updated`); - console.log(boxen( - chalk.yellow(`Task ${taskId} is already marked as ${taskToUpdate.status} and cannot be updated.`) + '\n\n' + - chalk.white('Completed tasks are locked to maintain consistency. To modify a completed task, you must first:') + '\n' + - chalk.white('1. Change its status to "pending" or "in-progress"') + '\n' + - chalk.white('2. Then run the update-task command'), - { padding: 1, borderColor: 'yellow', borderStyle: 'round' } - )); - return null; - } - - // Show the task that will be updated - const table = new Table({ - head: [ - chalk.cyan.bold('ID'), - chalk.cyan.bold('Title'), - chalk.cyan.bold('Status') - ], - colWidths: [5, 60, 10] - }); - - table.push([ - taskToUpdate.id, - truncate(taskToUpdate.title, 57), - getStatusWithColor(taskToUpdate.status) - ]); - - console.log(boxen( - chalk.white.bold(`Updating Task #${taskId}`), - { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 0 } } - )); - - console.log(table.toString()); - - // Display a message about how completed subtasks are handled - console.log(boxen( - chalk.cyan.bold('How Completed Subtasks Are Handled:') + '\n\n' + - chalk.white('• Subtasks marked as "done" or "completed" will be preserved\n') + - chalk.white('• New subtasks will build upon what has already been completed\n') + - chalk.white('• If completed work needs revision, a new subtask will be created instead of modifying done items\n') + - chalk.white('• This approach maintains a clear record of completed work and new requirements'), - { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 1 } } - )); - - // Build the system prompt - const systemPrompt = `You are an AI assistant helping to update a software development task based on new context. + try { + log('info', `Updating single task ${taskId} with prompt: "${prompt}"`); + + // Validate task ID is a positive integer + if (!Number.isInteger(taskId) || taskId <= 0) { + throw new Error( + `Invalid task ID: ${taskId}. Task ID must be a positive integer.` + ); + } + + // Validate prompt + if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') { + throw new Error( + 'Prompt cannot be empty. Please provide context for the task update.' + ); + } + + // Validate research flag + if (useResearch && (!perplexity || !process.env.PERPLEXITY_API_KEY)) { + log('warn', 'Perplexity AI is not available. Falling back to Claude AI.'); + console.log( + chalk.yellow( + 'Perplexity AI is not available (API key may be missing). Falling back to Claude AI.' + ) + ); + useResearch = false; + } + + // Validate tasks file exists + if (!fs.existsSync(tasksPath)) { + throw new Error(`Tasks file not found at path: ${tasksPath}`); + } + + // Read the tasks file + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + throw new Error( + `No valid tasks found in ${tasksPath}. The file may be corrupted or have an invalid format.` + ); + } + + // Find the specific task to update + const taskToUpdate = data.tasks.find((task) => task.id === taskId); + if (!taskToUpdate) { + throw new Error( + `Task with ID ${taskId} not found. Please verify the task ID and try again.` + ); + } + + // Check if task is already completed + if (taskToUpdate.status === 'done' || taskToUpdate.status === 'completed') { + log( + 'warn', + `Task ${taskId} is already marked as done and cannot be updated` + ); + console.log( + boxen( + chalk.yellow( + `Task ${taskId} is already marked as ${taskToUpdate.status} and cannot be updated.` + ) + + '\n\n' + + chalk.white( + 'Completed tasks are locked to maintain consistency. To modify a completed task, you must first:' + ) + + '\n' + + chalk.white('1. Change its status to "pending" or "in-progress"') + + '\n' + + chalk.white('2. Then run the update-task command'), + { padding: 1, borderColor: 'yellow', borderStyle: 'round' } + ) + ); + return null; + } + + // Show the task that will be updated + const table = new Table({ + head: [ + chalk.cyan.bold('ID'), + chalk.cyan.bold('Title'), + chalk.cyan.bold('Status') + ], + colWidths: [5, 60, 10] + }); + + table.push([ + taskToUpdate.id, + truncate(taskToUpdate.title, 57), + getStatusWithColor(taskToUpdate.status) + ]); + + console.log( + boxen(chalk.white.bold(`Updating Task #${taskId}`), { + padding: 1, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, bottom: 0 } + }) + ); + + console.log(table.toString()); + + // Display a message about how completed subtasks are handled + console.log( + boxen( + chalk.cyan.bold('How Completed Subtasks Are Handled:') + + '\n\n' + + chalk.white( + '• Subtasks marked as "done" or "completed" will be preserved\n' + ) + + chalk.white( + '• New subtasks will build upon what has already been completed\n' + ) + + chalk.white( + '• If completed work needs revision, a new subtask will be created instead of modifying done items\n' + ) + + chalk.white( + '• This approach maintains a clear record of completed work and new requirements' + ), + { + padding: 1, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + } + ) + ); + + // Build the system prompt + const systemPrompt = `You are an AI assistant helping to update a software development task based on new context. You will be given a task and a prompt describing changes or new implementation details. Your job is to update the task to reflect these changes, while preserving its basic structure. @@ -472,35 +579,39 @@ Guidelines: The changes described in the prompt should be thoughtfully applied to make the task more accurate and actionable.`; - const taskData = JSON.stringify(taskToUpdate, null, 2); - - let updatedTask; - const loadingIndicator = startLoadingIndicator(useResearch - ? 'Updating task with Perplexity AI research...' - : 'Updating task with Claude AI...'); - - try { - if (useResearch) { - log('info', 'Using Perplexity AI for research-backed task update'); - - // Verify Perplexity API key exists - if (!process.env.PERPLEXITY_API_KEY) { - throw new Error('PERPLEXITY_API_KEY environment variable is missing but --research flag was used.'); - } - - try { - // Call Perplexity AI - const perplexityModel = process.env.PERPLEXITY_MODEL || 'sonar-pro'; - const result = await perplexity.chat.completions.create({ - model: perplexityModel, - messages: [ - { - role: "system", - content: `${systemPrompt}\n\nAdditionally, please research the latest best practices, implementation details, and considerations when updating this task. Use your online search capabilities to gather relevant information. Remember to strictly follow the guidelines about preserving completed subtasks and building upon what has already been done rather than modifying or replacing it.` - }, - { - role: "user", - content: `Here is the task to update: + const taskData = JSON.stringify(taskToUpdate, null, 2); + + let updatedTask; + const loadingIndicator = startLoadingIndicator( + useResearch + ? 'Updating task with Perplexity AI research...' + : 'Updating task with Claude AI...' + ); + + try { + if (useResearch) { + log('info', 'Using Perplexity AI for research-backed task update'); + + // Verify Perplexity API key exists + if (!process.env.PERPLEXITY_API_KEY) { + throw new Error( + 'PERPLEXITY_API_KEY environment variable is missing but --research flag was used.' + ); + } + + try { + // Call Perplexity AI + const perplexityModel = process.env.PERPLEXITY_MODEL || 'sonar-pro'; + const result = await perplexity.chat.completions.create({ + model: perplexityModel, + messages: [ + { + role: 'system', + content: `${systemPrompt}\n\nAdditionally, please research the latest best practices, implementation details, and considerations when updating this task. Use your online search capabilities to gather relevant information. Remember to strictly follow the guidelines about preserving completed subtasks and building upon what has already been done rather than modifying or replacing it.` + }, + { + role: 'user', + content: `Here is the task to update: ${taskData} Please update this task based on the following new context: @@ -509,62 +620,72 @@ ${prompt} IMPORTANT: In the task JSON above, any subtasks with "status": "done" or "status": "completed" should be preserved exactly as is. Build your changes around these completed items. Return only the updated task as a valid JSON object.` - } - ], - temperature: parseFloat(process.env.TEMPERATURE || CONFIG.temperature), - max_tokens: parseInt(process.env.MAX_TOKENS || CONFIG.maxTokens), - }); - - const responseText = result.choices[0].message.content; - - // Extract JSON from response - const jsonStart = responseText.indexOf('{'); - const jsonEnd = responseText.lastIndexOf('}'); - - if (jsonStart === -1 || jsonEnd === -1) { - throw new Error("Could not find valid JSON object in Perplexity's response. The response may be malformed."); - } - - const jsonText = responseText.substring(jsonStart, jsonEnd + 1); - - try { - updatedTask = JSON.parse(jsonText); - } catch (parseError) { - throw new Error(`Failed to parse Perplexity response as JSON: ${parseError.message}\nResponse fragment: ${jsonText.substring(0, 100)}...`); - } - } catch (perplexityError) { - throw new Error(`Perplexity API error: ${perplexityError.message}`); - } - } else { - // Call Claude to update the task with streaming enabled - let responseText = ''; - let streamingInterval = null; - - try { - // Verify Anthropic API key exists - if (!process.env.ANTHROPIC_API_KEY) { - throw new Error('ANTHROPIC_API_KEY environment variable is missing. Required for task updates.'); - } - - // Update loading indicator to show streaming progress - let dotCount = 0; - const readline = await import('readline'); - streamingInterval = setInterval(() => { - readline.cursorTo(process.stdout, 0); - process.stdout.write(`Receiving streaming response from Claude${'.'.repeat(dotCount)}`); - dotCount = (dotCount + 1) % 4; - }, 500); - - // Use streaming API call - const stream = await anthropic.messages.create({ - model: CONFIG.model, - max_tokens: CONFIG.maxTokens, - temperature: CONFIG.temperature, - system: systemPrompt, - messages: [ - { - role: 'user', - content: `Here is the task to update: + } + ], + temperature: parseFloat( + process.env.TEMPERATURE || CONFIG.temperature + ), + max_tokens: parseInt(process.env.MAX_TOKENS || CONFIG.maxTokens) + }); + + const responseText = result.choices[0].message.content; + + // Extract JSON from response + const jsonStart = responseText.indexOf('{'); + const jsonEnd = responseText.lastIndexOf('}'); + + if (jsonStart === -1 || jsonEnd === -1) { + throw new Error( + "Could not find valid JSON object in Perplexity's response. The response may be malformed." + ); + } + + const jsonText = responseText.substring(jsonStart, jsonEnd + 1); + + try { + updatedTask = JSON.parse(jsonText); + } catch (parseError) { + throw new Error( + `Failed to parse Perplexity response as JSON: ${parseError.message}\nResponse fragment: ${jsonText.substring(0, 100)}...` + ); + } + } catch (perplexityError) { + throw new Error(`Perplexity API error: ${perplexityError.message}`); + } + } else { + // Call Claude to update the task with streaming enabled + let responseText = ''; + let streamingInterval = null; + + try { + // Verify Anthropic API key exists + if (!process.env.ANTHROPIC_API_KEY) { + throw new Error( + 'ANTHROPIC_API_KEY environment variable is missing. Required for task updates.' + ); + } + + // Update loading indicator to show streaming progress + let dotCount = 0; + const readline = await import('readline'); + streamingInterval = setInterval(() => { + readline.cursorTo(process.stdout, 0); + process.stdout.write( + `Receiving streaming response from Claude${'.'.repeat(dotCount)}` + ); + dotCount = (dotCount + 1) % 4; + }, 500); + + // Use streaming API call + const stream = await anthropic.messages.create({ + model: CONFIG.model, + max_tokens: CONFIG.maxTokens, + temperature: CONFIG.temperature, + system: systemPrompt, + messages: [ + { + role: 'user', + content: `Here is the task to update: ${taskData} Please update this task based on the following new context: @@ -573,165 +694,212 @@ ${prompt} IMPORTANT: In the task JSON above, any subtasks with "status": "done" or "status": "completed" should be preserved exactly as is. Build your changes around these completed items. Return only the updated task as a valid JSON object.` - } - ], - stream: true - }); - - // Process the stream - for await (const chunk of stream) { - if (chunk.type === 'content_block_delta' && chunk.delta.text) { - responseText += chunk.delta.text; - } - } - - if (streamingInterval) clearInterval(streamingInterval); - log('info', "Completed streaming response from Claude API!"); - - // Extract JSON from response - const jsonStart = responseText.indexOf('{'); - const jsonEnd = responseText.lastIndexOf('}'); - - if (jsonStart === -1 || jsonEnd === -1) { - throw new Error("Could not find valid JSON object in Claude's response. The response may be malformed."); - } - - const jsonText = responseText.substring(jsonStart, jsonEnd + 1); - - try { - updatedTask = JSON.parse(jsonText); - } catch (parseError) { - throw new Error(`Failed to parse Claude response as JSON: ${parseError.message}\nResponse fragment: ${jsonText.substring(0, 100)}...`); - } - } catch (claudeError) { - if (streamingInterval) clearInterval(streamingInterval); - throw new Error(`Claude API error: ${claudeError.message}`); - } - } - - // Validation of the updated task - if (!updatedTask || typeof updatedTask !== 'object') { - throw new Error('Received invalid task object from AI. The response did not contain a valid task.'); - } - - // Ensure critical fields exist - if (!updatedTask.title || !updatedTask.description) { - throw new Error('Updated task is missing required fields (title or description).'); - } - - // Ensure ID is preserved - if (updatedTask.id !== taskId) { - log('warn', `Task ID was modified in the AI response. Restoring original ID ${taskId}.`); - updatedTask.id = taskId; - } - - // Ensure status is preserved unless explicitly changed in prompt - if (updatedTask.status !== taskToUpdate.status && !prompt.toLowerCase().includes('status')) { - log('warn', `Task status was modified without explicit instruction. Restoring original status '${taskToUpdate.status}'.`); - updatedTask.status = taskToUpdate.status; - } - - // Ensure completed subtasks are preserved - if (taskToUpdate.subtasks && taskToUpdate.subtasks.length > 0) { - if (!updatedTask.subtasks) { - log('warn', 'Subtasks were removed in the AI response. Restoring original subtasks.'); - updatedTask.subtasks = taskToUpdate.subtasks; - } else { - // Check for each completed subtask - const completedSubtasks = taskToUpdate.subtasks.filter( - st => st.status === 'done' || st.status === 'completed' - ); - - for (const completedSubtask of completedSubtasks) { - const updatedSubtask = updatedTask.subtasks.find(st => st.id === completedSubtask.id); - - // If completed subtask is missing or modified, restore it - if (!updatedSubtask) { - log('warn', `Completed subtask ${completedSubtask.id} was removed. Restoring it.`); - updatedTask.subtasks.push(completedSubtask); - } else if ( - updatedSubtask.title !== completedSubtask.title || - updatedSubtask.description !== completedSubtask.description || - updatedSubtask.details !== completedSubtask.details || - updatedSubtask.status !== completedSubtask.status - ) { - log('warn', `Completed subtask ${completedSubtask.id} was modified. Restoring original.`); - // Find and replace the modified subtask - const index = updatedTask.subtasks.findIndex(st => st.id === completedSubtask.id); - if (index !== -1) { - updatedTask.subtasks[index] = completedSubtask; - } - } - } - - // Ensure no duplicate subtask IDs - const subtaskIds = new Set(); - const uniqueSubtasks = []; - - for (const subtask of updatedTask.subtasks) { - if (!subtaskIds.has(subtask.id)) { - subtaskIds.add(subtask.id); - uniqueSubtasks.push(subtask); - } else { - log('warn', `Duplicate subtask ID ${subtask.id} found. Removing duplicate.`); - } - } - - updatedTask.subtasks = uniqueSubtasks; - } - } - - // Update the task in the original data - const index = data.tasks.findIndex(t => t.id === taskId); - if (index !== -1) { - data.tasks[index] = updatedTask; - } else { - throw new Error(`Task with ID ${taskId} not found in tasks array.`); - } - - // Write the updated tasks to the file - writeJSON(tasksPath, data); - - log('success', `Successfully updated task ${taskId}`); - - // Generate individual task files - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); - - console.log(boxen( - chalk.green(`Successfully updated task #${taskId}`) + '\n\n' + - chalk.white.bold('Updated Title:') + ' ' + updatedTask.title, - { padding: 1, borderColor: 'green', borderStyle: 'round' } - )); - - // Return the updated task for testing purposes - return updatedTask; - } finally { - stopLoadingIndicator(loadingIndicator); - } - } catch (error) { - log('error', `Error updating task: ${error.message}`); - console.error(chalk.red(`Error: ${error.message}`)); - - // Provide more helpful error messages for common issues - if (error.message.includes('ANTHROPIC_API_KEY')) { - console.log(chalk.yellow('\nTo fix this issue, set your Anthropic API key:')); - console.log(' export ANTHROPIC_API_KEY=your_api_key_here'); - } else if (error.message.includes('PERPLEXITY_API_KEY')) { - console.log(chalk.yellow('\nTo fix this issue:')); - console.log(' 1. Set your Perplexity API key: export PERPLEXITY_API_KEY=your_api_key_here'); - console.log(' 2. Or run without the research flag: task-master update-task --id=<id> --prompt="..."'); - } else if (error.message.includes('Task with ID') && 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'); - } - - if (CONFIG.debug) { - console.error(error); - } - - return null; - } + } + ], + stream: true + }); + + // Process the stream + for await (const chunk of stream) { + if (chunk.type === 'content_block_delta' && chunk.delta.text) { + responseText += chunk.delta.text; + } + } + + if (streamingInterval) clearInterval(streamingInterval); + log('info', 'Completed streaming response from Claude API!'); + + // Extract JSON from response + const jsonStart = responseText.indexOf('{'); + const jsonEnd = responseText.lastIndexOf('}'); + + if (jsonStart === -1 || jsonEnd === -1) { + throw new Error( + "Could not find valid JSON object in Claude's response. The response may be malformed." + ); + } + + const jsonText = responseText.substring(jsonStart, jsonEnd + 1); + + try { + updatedTask = JSON.parse(jsonText); + } catch (parseError) { + throw new Error( + `Failed to parse Claude response as JSON: ${parseError.message}\nResponse fragment: ${jsonText.substring(0, 100)}...` + ); + } + } catch (claudeError) { + if (streamingInterval) clearInterval(streamingInterval); + throw new Error(`Claude API error: ${claudeError.message}`); + } + } + + // Validation of the updated task + if (!updatedTask || typeof updatedTask !== 'object') { + throw new Error( + 'Received invalid task object from AI. The response did not contain a valid task.' + ); + } + + // Ensure critical fields exist + if (!updatedTask.title || !updatedTask.description) { + throw new Error( + 'Updated task is missing required fields (title or description).' + ); + } + + // Ensure ID is preserved + if (updatedTask.id !== taskId) { + log( + 'warn', + `Task ID was modified in the AI response. Restoring original ID ${taskId}.` + ); + updatedTask.id = taskId; + } + + // Ensure status is preserved unless explicitly changed in prompt + if ( + updatedTask.status !== taskToUpdate.status && + !prompt.toLowerCase().includes('status') + ) { + log( + 'warn', + `Task status was modified without explicit instruction. Restoring original status '${taskToUpdate.status}'.` + ); + updatedTask.status = taskToUpdate.status; + } + + // Ensure completed subtasks are preserved + if (taskToUpdate.subtasks && taskToUpdate.subtasks.length > 0) { + if (!updatedTask.subtasks) { + log( + 'warn', + 'Subtasks were removed in the AI response. Restoring original subtasks.' + ); + updatedTask.subtasks = taskToUpdate.subtasks; + } else { + // Check for each completed subtask + const completedSubtasks = taskToUpdate.subtasks.filter( + (st) => st.status === 'done' || st.status === 'completed' + ); + + for (const completedSubtask of completedSubtasks) { + const updatedSubtask = updatedTask.subtasks.find( + (st) => st.id === completedSubtask.id + ); + + // If completed subtask is missing or modified, restore it + if (!updatedSubtask) { + log( + 'warn', + `Completed subtask ${completedSubtask.id} was removed. Restoring it.` + ); + updatedTask.subtasks.push(completedSubtask); + } else if ( + updatedSubtask.title !== completedSubtask.title || + updatedSubtask.description !== completedSubtask.description || + updatedSubtask.details !== completedSubtask.details || + updatedSubtask.status !== completedSubtask.status + ) { + log( + 'warn', + `Completed subtask ${completedSubtask.id} was modified. Restoring original.` + ); + // Find and replace the modified subtask + const index = updatedTask.subtasks.findIndex( + (st) => st.id === completedSubtask.id + ); + if (index !== -1) { + updatedTask.subtasks[index] = completedSubtask; + } + } + } + + // Ensure no duplicate subtask IDs + const subtaskIds = new Set(); + const uniqueSubtasks = []; + + for (const subtask of updatedTask.subtasks) { + if (!subtaskIds.has(subtask.id)) { + subtaskIds.add(subtask.id); + uniqueSubtasks.push(subtask); + } else { + log( + 'warn', + `Duplicate subtask ID ${subtask.id} found. Removing duplicate.` + ); + } + } + + updatedTask.subtasks = uniqueSubtasks; + } + } + + // Update the task in the original data + const index = data.tasks.findIndex((t) => t.id === taskId); + if (index !== -1) { + data.tasks[index] = updatedTask; + } else { + throw new Error(`Task with ID ${taskId} not found in tasks array.`); + } + + // Write the updated tasks to the file + writeJSON(tasksPath, data); + + log('success', `Successfully updated task ${taskId}`); + + // Generate individual task files + await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + + console.log( + boxen( + chalk.green(`Successfully updated task #${taskId}`) + + '\n\n' + + chalk.white.bold('Updated Title:') + + ' ' + + updatedTask.title, + { padding: 1, borderColor: 'green', borderStyle: 'round' } + ) + ); + + // Return the updated task for testing purposes + return updatedTask; + } finally { + stopLoadingIndicator(loadingIndicator); + } + } catch (error) { + log('error', `Error updating task: ${error.message}`); + console.error(chalk.red(`Error: ${error.message}`)); + + // Provide more helpful error messages for common issues + if (error.message.includes('ANTHROPIC_API_KEY')) { + console.log( + chalk.yellow('\nTo fix this issue, set your Anthropic API key:') + ); + console.log(' export ANTHROPIC_API_KEY=your_api_key_here'); + } else if (error.message.includes('PERPLEXITY_API_KEY')) { + console.log(chalk.yellow('\nTo fix this issue:')); + console.log( + ' 1. Set your Perplexity API key: export PERPLEXITY_API_KEY=your_api_key_here' + ); + console.log( + ' 2. Or run without the research flag: task-master update-task --id=<id> --prompt="..."' + ); + } else if ( + error.message.includes('Task with ID') && + 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'); + } + + if (CONFIG.debug) { + console.error(error); + } + + return null; + } } /** @@ -740,102 +908,124 @@ Return only the updated task as a valid JSON object.` * @param {string} outputDir - Output directory for task files */ function generateTaskFiles(tasksPath, outputDir) { - try { - log('info', `Reading tasks from ${tasksPath}...`); - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - throw new Error(`No valid tasks found in ${tasksPath}`); - } - - // Create the output directory if it doesn't exist - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - log('info', `Found ${data.tasks.length} tasks to generate files for.`); - - // Validate and fix dependencies before generating files - log('info', `Validating and fixing dependencies before generating files...`); - validateAndFixDependencies(data, tasksPath); - - // Generate task files - log('info', 'Generating individual task files...'); - data.tasks.forEach(task => { - const taskPath = path.join(outputDir, `task_${task.id.toString().padStart(3, '0')}.txt`); - - // Format the content - let content = `# Task ID: ${task.id}\n`; - content += `# Title: ${task.title}\n`; - content += `# Status: ${task.status || 'pending'}\n`; - - // Format dependencies with their status - if (task.dependencies && task.dependencies.length > 0) { - content += `# Dependencies: ${formatDependenciesWithStatus(task.dependencies, data.tasks, false)}\n`; - } else { - content += '# Dependencies: None\n'; - } - - content += `# Priority: ${task.priority || 'medium'}\n`; - content += `# Description: ${task.description || ''}\n`; - - // Add more detailed sections - content += '# Details:\n'; - content += (task.details || '').split('\n').map(line => line).join('\n'); - content += '\n\n'; - - content += '# Test Strategy:\n'; - content += (task.testStrategy || '').split('\n').map(line => line).join('\n'); - content += '\n'; - - // Add subtasks if they exist - if (task.subtasks && task.subtasks.length > 0) { - content += '\n# Subtasks:\n'; - - task.subtasks.forEach(subtask => { - content += `## ${subtask.id}. ${subtask.title} [${subtask.status || 'pending'}]\n`; - - if (subtask.dependencies && subtask.dependencies.length > 0) { - // Format subtask dependencies - let subtaskDeps = subtask.dependencies.map(depId => { - if (typeof depId === 'number') { - // Handle numeric dependencies to other subtasks - const foundSubtask = task.subtasks.find(st => st.id === depId); - if (foundSubtask) { - // Just return the plain ID format without any color formatting - return `${task.id}.${depId}`; - } - } - return depId.toString(); - }).join(', '); - - content += `### Dependencies: ${subtaskDeps}\n`; - } else { - content += '### Dependencies: None\n'; - } - - content += `### Description: ${subtask.description || ''}\n`; - content += '### Details:\n'; - content += (subtask.details || '').split('\n').map(line => line).join('\n'); - content += '\n\n'; - }); - } - - // Write the file - fs.writeFileSync(taskPath, content); - log('info', `Generated: task_${task.id.toString().padStart(3, '0')}.txt`); - }); - - log('success', `All ${data.tasks.length} tasks have been generated into '${outputDir}'.`); - } catch (error) { - log('error', `Error generating task files: ${error.message}`); - console.error(chalk.red(`Error generating task files: ${error.message}`)); - - if (CONFIG.debug) { - console.error(error); - } - - process.exit(1); - } + try { + log('info', `Reading tasks from ${tasksPath}...`); + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + throw new Error(`No valid tasks found in ${tasksPath}`); + } + + // Create the output directory if it doesn't exist + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + log('info', `Found ${data.tasks.length} tasks to generate files for.`); + + // Validate and fix dependencies before generating files + log( + 'info', + `Validating and fixing dependencies before generating files...` + ); + validateAndFixDependencies(data, tasksPath); + + // Generate task files + log('info', 'Generating individual task files...'); + data.tasks.forEach((task) => { + const taskPath = path.join( + outputDir, + `task_${task.id.toString().padStart(3, '0')}.txt` + ); + + // Format the content + let content = `# Task ID: ${task.id}\n`; + content += `# Title: ${task.title}\n`; + content += `# Status: ${task.status || 'pending'}\n`; + + // Format dependencies with their status + if (task.dependencies && task.dependencies.length > 0) { + content += `# Dependencies: ${formatDependenciesWithStatus(task.dependencies, data.tasks, false)}\n`; + } else { + content += '# Dependencies: None\n'; + } + + content += `# Priority: ${task.priority || 'medium'}\n`; + content += `# Description: ${task.description || ''}\n`; + + // Add more detailed sections + content += '# Details:\n'; + content += (task.details || '') + .split('\n') + .map((line) => line) + .join('\n'); + content += '\n\n'; + + content += '# Test Strategy:\n'; + content += (task.testStrategy || '') + .split('\n') + .map((line) => line) + .join('\n'); + content += '\n'; + + // Add subtasks if they exist + if (task.subtasks && task.subtasks.length > 0) { + content += '\n# Subtasks:\n'; + + task.subtasks.forEach((subtask) => { + content += `## ${subtask.id}. ${subtask.title} [${subtask.status || 'pending'}]\n`; + + if (subtask.dependencies && subtask.dependencies.length > 0) { + // Format subtask dependencies + let subtaskDeps = subtask.dependencies + .map((depId) => { + if (typeof depId === 'number') { + // Handle numeric dependencies to other subtasks + const foundSubtask = task.subtasks.find( + (st) => st.id === depId + ); + if (foundSubtask) { + // Just return the plain ID format without any color formatting + return `${task.id}.${depId}`; + } + } + return depId.toString(); + }) + .join(', '); + + content += `### Dependencies: ${subtaskDeps}\n`; + } else { + content += '### Dependencies: None\n'; + } + + content += `### Description: ${subtask.description || ''}\n`; + content += '### Details:\n'; + content += (subtask.details || '') + .split('\n') + .map((line) => line) + .join('\n'); + content += '\n\n'; + }); + } + + // Write the file + fs.writeFileSync(taskPath, content); + log('info', `Generated: task_${task.id.toString().padStart(3, '0')}.txt`); + }); + + log( + 'success', + `All ${data.tasks.length} tasks have been generated into '${outputDir}'.` + ); + } catch (error) { + log('error', `Error generating task files: ${error.message}`); + console.error(chalk.red(`Error generating task files: ${error.message}`)); + + if (CONFIG.debug) { + console.error(error); + } + + process.exit(1); + } } /** @@ -845,63 +1035,69 @@ function generateTaskFiles(tasksPath, outputDir) { * @param {string} newStatus - New status */ async function setTaskStatus(tasksPath, taskIdInput, newStatus) { - try { - displayBanner(); - - console.log(boxen( - chalk.white.bold(`Updating Task Status to: ${newStatus}`), - { padding: 1, borderColor: 'blue', borderStyle: 'round' } - )); - - log('info', `Reading tasks from ${tasksPath}...`); - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - throw new Error(`No valid tasks found in ${tasksPath}`); - } - - // Handle multiple task IDs (comma-separated) - const taskIds = taskIdInput.split(',').map(id => id.trim()); - const updatedTasks = []; - - // Update each task - for (const id of taskIds) { - await updateSingleTaskStatus(tasksPath, id, newStatus, data); - updatedTasks.push(id); - } - - // Write the updated tasks to the file - writeJSON(tasksPath, data); - - // Validate dependencies after status update - log('info', 'Validating dependencies after status update...'); - validateTaskDependencies(data.tasks); - - // Generate individual task files - log('info', 'Regenerating task files...'); - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); - - // Display success message - for (const id of updatedTasks) { - const task = findTaskById(data.tasks, id); - const taskName = task ? task.title : id; - - console.log(boxen( - chalk.white.bold(`Successfully updated task ${id} status:`) + '\n' + - `From: ${chalk.yellow(task ? task.status : 'unknown')}\n` + - `To: ${chalk.green(newStatus)}`, - { padding: 1, borderColor: 'green', borderStyle: 'round' } - )); - } - } catch (error) { - log('error', `Error setting task status: ${error.message}`); - console.error(chalk.red(`Error: ${error.message}`)); - - if (CONFIG.debug) { - console.error(error); - } - - process.exit(1); - } + try { + displayBanner(); + + console.log( + boxen(chalk.white.bold(`Updating Task Status to: ${newStatus}`), { + padding: 1, + borderColor: 'blue', + borderStyle: 'round' + }) + ); + + log('info', `Reading tasks from ${tasksPath}...`); + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + throw new Error(`No valid tasks found in ${tasksPath}`); + } + + // Handle multiple task IDs (comma-separated) + const taskIds = taskIdInput.split(',').map((id) => id.trim()); + const updatedTasks = []; + + // Update each task + for (const id of taskIds) { + await updateSingleTaskStatus(tasksPath, id, newStatus, data); + updatedTasks.push(id); + } + + // Write the updated tasks to the file + writeJSON(tasksPath, data); + + // Validate dependencies after status update + log('info', 'Validating dependencies after status update...'); + validateTaskDependencies(data.tasks); + + // Generate individual task files + log('info', 'Regenerating task files...'); + await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + + // Display success message + for (const id of updatedTasks) { + const task = findTaskById(data.tasks, id); + const taskName = task ? task.title : id; + + console.log( + boxen( + chalk.white.bold(`Successfully updated task ${id} status:`) + + '\n' + + `From: ${chalk.yellow(task ? task.status : 'unknown')}\n` + + `To: ${chalk.green(newStatus)}`, + { padding: 1, borderColor: 'green', borderStyle: 'round' } + ) + ); + } + } catch (error) { + log('error', `Error setting task status: ${error.message}`); + console.error(chalk.red(`Error: ${error.message}`)); + + if (CONFIG.debug) { + console.error(error); + } + + process.exit(1); + } } /** @@ -912,74 +1108,107 @@ async function setTaskStatus(tasksPath, taskIdInput, newStatus) { * @param {Object} data - Tasks data */ async function updateSingleTaskStatus(tasksPath, taskIdInput, newStatus, data) { - // Check if it's a subtask (e.g., "1.2") - if (taskIdInput.includes('.')) { - const [parentId, subtaskId] = taskIdInput.split('.').map(id => parseInt(id, 10)); - - // Find the parent task - const parentTask = data.tasks.find(t => t.id === parentId); - if (!parentTask) { - throw new Error(`Parent task ${parentId} not found`); - } - - // Find the subtask - if (!parentTask.subtasks) { - throw new Error(`Parent task ${parentId} has no subtasks`); - } - - const subtask = parentTask.subtasks.find(st => st.id === subtaskId); - if (!subtask) { - throw new Error(`Subtask ${subtaskId} not found in parent task ${parentId}`); - } - - // Update the subtask status - const oldStatus = subtask.status || 'pending'; - subtask.status = newStatus; - - log('info', `Updated subtask ${parentId}.${subtaskId} status from '${oldStatus}' to '${newStatus}'`); - - // Check if all subtasks are done (if setting to 'done') - if (newStatus.toLowerCase() === 'done' || newStatus.toLowerCase() === 'completed') { - const allSubtasksDone = parentTask.subtasks.every(st => - st.status === 'done' || st.status === 'completed'); - - // Suggest updating parent task if all subtasks are done - if (allSubtasksDone && parentTask.status !== 'done' && parentTask.status !== 'completed') { - console.log(chalk.yellow(`All subtasks of parent task ${parentId} are now marked as done.`)); - console.log(chalk.yellow(`Consider updating the parent task status with: task-master set-status --id=${parentId} --status=done`)); - } - } - } else { - // Handle regular task - const taskId = parseInt(taskIdInput, 10); - const task = data.tasks.find(t => t.id === taskId); - - if (!task) { - throw new Error(`Task ${taskId} not found`); - } - - // Update the task status - const oldStatus = task.status || 'pending'; - task.status = newStatus; - - log('info', `Updated task ${taskId} status from '${oldStatus}' to '${newStatus}'`); - - // If marking as done, also mark all subtasks as done - if ((newStatus.toLowerCase() === 'done' || newStatus.toLowerCase() === 'completed') && - task.subtasks && task.subtasks.length > 0) { - - const pendingSubtasks = task.subtasks.filter(st => - st.status !== 'done' && st.status !== 'completed'); - - if (pendingSubtasks.length > 0) { - log('info', `Also marking ${pendingSubtasks.length} subtasks as '${newStatus}'`); - - pendingSubtasks.forEach(subtask => { - subtask.status = newStatus; - }); - } - } - } + // Check if it's a subtask (e.g., "1.2") + if (taskIdInput.includes('.')) { + const [parentId, subtaskId] = taskIdInput + .split('.') + .map((id) => parseInt(id, 10)); + + // Find the parent task + const parentTask = data.tasks.find((t) => t.id === parentId); + if (!parentTask) { + throw new Error(`Parent task ${parentId} not found`); + } + + // Find the subtask + if (!parentTask.subtasks) { + throw new Error(`Parent task ${parentId} has no subtasks`); + } + + const subtask = parentTask.subtasks.find((st) => st.id === subtaskId); + if (!subtask) { + throw new Error( + `Subtask ${subtaskId} not found in parent task ${parentId}` + ); + } + + // Update the subtask status + const oldStatus = subtask.status || 'pending'; + subtask.status = newStatus; + + log( + 'info', + `Updated subtask ${parentId}.${subtaskId} status from '${oldStatus}' to '${newStatus}'` + ); + + // Check if all subtasks are done (if setting to 'done') + if ( + newStatus.toLowerCase() === 'done' || + newStatus.toLowerCase() === 'completed' + ) { + const allSubtasksDone = parentTask.subtasks.every( + (st) => st.status === 'done' || st.status === 'completed' + ); + + // Suggest updating parent task if all subtasks are done + if ( + allSubtasksDone && + parentTask.status !== 'done' && + parentTask.status !== 'completed' + ) { + console.log( + chalk.yellow( + `All subtasks of parent task ${parentId} are now marked as done.` + ) + ); + console.log( + chalk.yellow( + `Consider updating the parent task status with: task-master set-status --id=${parentId} --status=done` + ) + ); + } + } + } else { + // Handle regular task + const taskId = parseInt(taskIdInput, 10); + const task = data.tasks.find((t) => t.id === taskId); + + if (!task) { + throw new Error(`Task ${taskId} not found`); + } + + // Update the task status + const oldStatus = task.status || 'pending'; + task.status = newStatus; + + log( + 'info', + `Updated task ${taskId} status from '${oldStatus}' to '${newStatus}'` + ); + + // If marking as done, also mark all subtasks as done + if ( + (newStatus.toLowerCase() === 'done' || + newStatus.toLowerCase() === 'completed') && + task.subtasks && + task.subtasks.length > 0 + ) { + const pendingSubtasks = task.subtasks.filter( + (st) => st.status !== 'done' && st.status !== 'completed' + ); + + if (pendingSubtasks.length > 0) { + log( + 'info', + `Also marking ${pendingSubtasks.length} subtasks as '${newStatus}'` + ); + + pendingSubtasks.forEach((subtask) => { + subtask.status = newStatus; + }); + } + } + } } /** @@ -990,535 +1219,621 @@ async function updateSingleTaskStatus(tasksPath, taskIdInput, newStatus, data) { * @param {string} outputFormat - Output format (text or json) * @returns {Object} - Task list result for json format */ -function listTasks(tasksPath, statusFilter, withSubtasks = false, outputFormat = 'text') { - try { - // Only display banner for text output - if (outputFormat === 'text') { - displayBanner(); - } - - const data = readJSON(tasksPath); // Reads the whole tasks.json - if (!data || !data.tasks) { - throw new Error(`No valid tasks found in ${tasksPath}`); - } - - // Filter tasks by status if specified - const filteredTasks = statusFilter && statusFilter.toLowerCase() !== 'all' // <-- Added check for 'all' - ? data.tasks.filter(task => - task.status && task.status.toLowerCase() === statusFilter.toLowerCase()) - : data.tasks; // Default to all tasks if no filter or filter is 'all' - - // Calculate completion statistics - const totalTasks = data.tasks.length; - const completedTasks = data.tasks.filter(task => - task.status === 'done' || task.status === 'completed').length; - const completionPercentage = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0; - - // Count statuses - const doneCount = completedTasks; - const inProgressCount = data.tasks.filter(task => task.status === 'in-progress').length; - const pendingCount = data.tasks.filter(task => task.status === 'pending').length; - const blockedCount = data.tasks.filter(task => task.status === 'blocked').length; - const deferredCount = data.tasks.filter(task => task.status === 'deferred').length; - - // Count subtasks - let totalSubtasks = 0; - let completedSubtasks = 0; - - data.tasks.forEach(task => { - if (task.subtasks && task.subtasks.length > 0) { - totalSubtasks += task.subtasks.length; - completedSubtasks += task.subtasks.filter(st => - st.status === 'done' || st.status === 'completed').length; - } - }); - - const subtaskCompletionPercentage = totalSubtasks > 0 ? - (completedSubtasks / totalSubtasks) * 100 : 0; +function listTasks( + tasksPath, + statusFilter, + withSubtasks = false, + outputFormat = 'text' +) { + try { + // Only display banner for text output + if (outputFormat === 'text') { + displayBanner(); + } - // For JSON output, return structured data - if (outputFormat === 'json') { - // *** Modification: Remove 'details' field for JSON output *** - const tasksWithoutDetails = filteredTasks.map(task => { // <-- USES filteredTasks! - // Omit 'details' from the parent task - const { details, ...taskRest } = task; + const data = readJSON(tasksPath); // Reads the whole tasks.json + if (!data || !data.tasks) { + throw new Error(`No valid tasks found in ${tasksPath}`); + } - // If subtasks exist, omit 'details' from them too - if (taskRest.subtasks && Array.isArray(taskRest.subtasks)) { - taskRest.subtasks = taskRest.subtasks.map(subtask => { - const { details: subtaskDetails, ...subtaskRest } = subtask; - return subtaskRest; - }); - } - return taskRest; - }); - // *** End of Modification *** + // Filter tasks by status if specified + const filteredTasks = + statusFilter && statusFilter.toLowerCase() !== 'all' // <-- Added check for 'all' + ? data.tasks.filter( + (task) => + task.status && + task.status.toLowerCase() === statusFilter.toLowerCase() + ) + : data.tasks; // Default to all tasks if no filter or filter is 'all' - return { - tasks: tasksWithoutDetails, // <--- THIS IS THE ARRAY BEING RETURNED - filter: statusFilter || 'all', // Return the actual filter used - stats: { - total: totalTasks, - completed: doneCount, - inProgress: inProgressCount, - pending: pendingCount, - blocked: blockedCount, - deferred: deferredCount, - completionPercentage, - subtasks: { - total: totalSubtasks, - completed: completedSubtasks, - completionPercentage: subtaskCompletionPercentage - } - } - }; - } - - // ... existing code for text output ... - - // Create progress bars - const taskProgressBar = createProgressBar(completionPercentage, 30); - const subtaskProgressBar = createProgressBar(subtaskCompletionPercentage, 30); - - // Calculate dependency statistics - const completedTaskIds = new Set(data.tasks.filter(t => - t.status === 'done' || t.status === 'completed').map(t => t.id)); - - const tasksWithNoDeps = data.tasks.filter(t => - t.status !== 'done' && - t.status !== 'completed' && - (!t.dependencies || t.dependencies.length === 0)).length; - - const tasksWithAllDepsSatisfied = data.tasks.filter(t => - t.status !== 'done' && - t.status !== 'completed' && - t.dependencies && - t.dependencies.length > 0 && - t.dependencies.every(depId => completedTaskIds.has(depId))).length; - - const tasksWithUnsatisfiedDeps = data.tasks.filter(t => - t.status !== 'done' && - t.status !== 'completed' && - t.dependencies && - t.dependencies.length > 0 && - !t.dependencies.every(depId => completedTaskIds.has(depId))).length; - - // Calculate total tasks ready to work on (no deps + satisfied deps) - const tasksReadyToWork = tasksWithNoDeps + tasksWithAllDepsSatisfied; - - // Calculate most depended-on tasks - const dependencyCount = {}; - data.tasks.forEach(task => { - if (task.dependencies && task.dependencies.length > 0) { - task.dependencies.forEach(depId => { - dependencyCount[depId] = (dependencyCount[depId] || 0) + 1; - }); - } - }); - - // Find the most depended-on task - let mostDependedOnTaskId = null; - let maxDependents = 0; - - for (const [taskId, count] of Object.entries(dependencyCount)) { - if (count > maxDependents) { - maxDependents = count; - mostDependedOnTaskId = parseInt(taskId); - } - } - - // Get the most depended-on task - const mostDependedOnTask = mostDependedOnTaskId !== null - ? data.tasks.find(t => t.id === mostDependedOnTaskId) - : null; - - // Calculate average dependencies per task - const totalDependencies = data.tasks.reduce((sum, task) => - sum + (task.dependencies ? task.dependencies.length : 0), 0); - const avgDependenciesPerTask = totalDependencies / data.tasks.length; - - // Find next task to work on - const nextTask = findNextTask(data.tasks); - const nextTaskInfo = nextTask ? - `ID: ${chalk.cyan(nextTask.id)} - ${chalk.white.bold(truncate(nextTask.title, 40))}\n` + - `Priority: ${chalk.white(nextTask.priority || 'medium')} Dependencies: ${formatDependenciesWithStatus(nextTask.dependencies, data.tasks, true)}` : - chalk.yellow('No eligible tasks found. All tasks are either completed or have unsatisfied dependencies.'); - - // Get terminal width - more reliable method - let terminalWidth; - try { - // Try to get the actual terminal columns - terminalWidth = process.stdout.columns; - } catch (e) { - // Fallback if columns cannot be determined - log('debug', 'Could not determine terminal width, using default'); - } - // Ensure we have a reasonable default if detection fails - terminalWidth = terminalWidth || 80; - - // Ensure terminal width is at least a minimum value to prevent layout issues - terminalWidth = Math.max(terminalWidth, 80); - - // Create dashboard content - const projectDashboardContent = - chalk.white.bold('Project Dashboard') + '\n' + - `Tasks Progress: ${chalk.greenBright(taskProgressBar)} ${completionPercentage.toFixed(0)}%\n` + - `Done: ${chalk.green(doneCount)} In Progress: ${chalk.blue(inProgressCount)} Pending: ${chalk.yellow(pendingCount)} Blocked: ${chalk.red(blockedCount)} Deferred: ${chalk.gray(deferredCount)}\n\n` + - `Subtasks Progress: ${chalk.cyan(subtaskProgressBar)} ${subtaskCompletionPercentage.toFixed(0)}%\n` + - `Completed: ${chalk.green(completedSubtasks)}/${totalSubtasks} Remaining: ${chalk.yellow(totalSubtasks - completedSubtasks)}\n\n` + - chalk.cyan.bold('Priority Breakdown:') + '\n' + - `${chalk.red('•')} ${chalk.white('High priority:')} ${data.tasks.filter(t => t.priority === 'high').length}\n` + - `${chalk.yellow('•')} ${chalk.white('Medium priority:')} ${data.tasks.filter(t => t.priority === 'medium').length}\n` + - `${chalk.green('•')} ${chalk.white('Low priority:')} ${data.tasks.filter(t => t.priority === 'low').length}`; - - const dependencyDashboardContent = - chalk.white.bold('Dependency Status & Next Task') + '\n' + - chalk.cyan.bold('Dependency Metrics:') + '\n' + - `${chalk.green('•')} ${chalk.white('Tasks with no dependencies:')} ${tasksWithNoDeps}\n` + - `${chalk.green('•')} ${chalk.white('Tasks ready to work on:')} ${tasksReadyToWork}\n` + - `${chalk.yellow('•')} ${chalk.white('Tasks blocked by dependencies:')} ${tasksWithUnsatisfiedDeps}\n` + - `${chalk.magenta('•')} ${chalk.white('Most depended-on task:')} ${mostDependedOnTask ? chalk.cyan(`#${mostDependedOnTaskId} (${maxDependents} dependents)`) : chalk.gray('None')}\n` + - `${chalk.blue('•')} ${chalk.white('Avg dependencies per task:')} ${avgDependenciesPerTask.toFixed(1)}\n\n` + - chalk.cyan.bold('Next Task to Work On:') + '\n' + - `ID: ${chalk.cyan(nextTask ? nextTask.id : 'N/A')} - ${nextTask ? chalk.white.bold(truncate(nextTask.title, 40)) : chalk.yellow('No task available')}\n` + - `Priority: ${nextTask ? chalk.white(nextTask.priority || 'medium') : ''} Dependencies: ${nextTask ? formatDependenciesWithStatus(nextTask.dependencies, data.tasks, true) : ''}`; - - // Calculate width for side-by-side display - // Box borders, padding take approximately 4 chars on each side - const minDashboardWidth = 50; // Minimum width for dashboard - const minDependencyWidth = 50; // Minimum width for dependency dashboard - const totalMinWidth = minDashboardWidth + minDependencyWidth + 4; // Extra 4 chars for spacing - - // If terminal is wide enough, show boxes side by side with responsive widths - if (terminalWidth >= totalMinWidth) { - // Calculate widths proportionally for each box - use exact 50% width each - const availableWidth = terminalWidth; - const halfWidth = Math.floor(availableWidth / 2); - - // Account for border characters (2 chars on each side) - const boxContentWidth = halfWidth - 4; - - // Create boxen options with precise widths - const dashboardBox = boxen( - projectDashboardContent, - { - padding: 1, - borderColor: 'blue', - borderStyle: 'round', - width: boxContentWidth, - dimBorder: false - } - ); - - const dependencyBox = boxen( - dependencyDashboardContent, - { - padding: 1, - borderColor: 'magenta', - borderStyle: 'round', - width: boxContentWidth, - dimBorder: false - } - ); - - // Create a better side-by-side layout with exact spacing - const dashboardLines = dashboardBox.split('\n'); - const dependencyLines = dependencyBox.split('\n'); - - // Make sure both boxes have the same height - const maxHeight = Math.max(dashboardLines.length, dependencyLines.length); - - // For each line of output, pad the dashboard line to exactly halfWidth chars - // This ensures the dependency box starts at exactly the right position - const combinedLines = []; - for (let i = 0; i < maxHeight; i++) { - // Get the dashboard line (or empty string if we've run out of lines) - const dashLine = i < dashboardLines.length ? dashboardLines[i] : ''; - // Get the dependency line (or empty string if we've run out of lines) - const depLine = i < dependencyLines.length ? dependencyLines[i] : ''; - - // Remove any trailing spaces from dashLine before padding to exact width - const trimmedDashLine = dashLine.trimEnd(); - // Pad the dashboard line to exactly halfWidth chars with no extra spaces - const paddedDashLine = trimmedDashLine.padEnd(halfWidth, ' '); - - // Join the lines with no space in between - combinedLines.push(paddedDashLine + depLine); - } - - // Join all lines and output - console.log(combinedLines.join('\n')); - } else { - // Terminal too narrow, show boxes stacked vertically - const dashboardBox = boxen( - projectDashboardContent, - { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 0, bottom: 1 } } - ); - - const dependencyBox = boxen( - dependencyDashboardContent, - { padding: 1, borderColor: 'magenta', borderStyle: 'round', margin: { top: 0, bottom: 1 } } - ); - - // Display stacked vertically - console.log(dashboardBox); - console.log(dependencyBox); - } - - if (filteredTasks.length === 0) { - console.log(boxen( - statusFilter - ? chalk.yellow(`No tasks with status '${statusFilter}' found`) - : chalk.yellow('No tasks found'), - { padding: 1, borderColor: 'yellow', borderStyle: 'round' } - )); - return; - } - - // COMPLETELY REVISED TABLE APPROACH - // Define percentage-based column widths and calculate actual widths - // Adjust percentages based on content type and user requirements + // Calculate completion statistics + const totalTasks = data.tasks.length; + const completedTasks = data.tasks.filter( + (task) => task.status === 'done' || task.status === 'completed' + ).length; + const completionPercentage = + totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0; - // Adjust ID width if showing subtasks (subtask IDs are longer: e.g., "1.2") - const idWidthPct = withSubtasks ? 10 : 7; - - // Calculate max status length to accommodate "in-progress" - const statusWidthPct = 15; - - // Increase priority column width as requested - const priorityWidthPct = 12; - - // Make dependencies column smaller as requested (-20%) - const depsWidthPct = 20; - - // Calculate title/description width as remaining space (+20% from dependencies reduction) - const titleWidthPct = 100 - idWidthPct - statusWidthPct - priorityWidthPct - depsWidthPct; - - // Allow 10 characters for borders and padding - const availableWidth = terminalWidth - 10; - - // Calculate actual column widths based on percentages - const idWidth = Math.floor(availableWidth * (idWidthPct / 100)); - const statusWidth = Math.floor(availableWidth * (statusWidthPct / 100)); - const priorityWidth = Math.floor(availableWidth * (priorityWidthPct / 100)); - const depsWidth = Math.floor(availableWidth * (depsWidthPct / 100)); - const titleWidth = Math.floor(availableWidth * (titleWidthPct / 100)); - - // Create a table with correct borders and spacing - const table = new Table({ - head: [ - chalk.cyan.bold('ID'), - chalk.cyan.bold('Title'), - chalk.cyan.bold('Status'), - chalk.cyan.bold('Priority'), - chalk.cyan.bold('Dependencies') - ], - colWidths: [idWidth, titleWidth, statusWidth, priorityWidth, depsWidth], - style: { - head: [], // No special styling for header - border: [], // No special styling for border - compact: false // Use default spacing - }, - wordWrap: true, - wrapOnWordBoundary: true, - }); - - // Process tasks for the table - filteredTasks.forEach(task => { - // Format dependencies with status indicators (colored) - let depText = 'None'; - if (task.dependencies && task.dependencies.length > 0) { - // Use the proper formatDependenciesWithStatus function for colored status - depText = formatDependenciesWithStatus(task.dependencies, data.tasks, true); - } else { - depText = chalk.gray('None'); - } - - // Clean up any ANSI codes or confusing characters - const cleanTitle = task.title.replace(/\n/g, ' '); - - // Get priority color - const priorityColor = { - 'high': chalk.red, - 'medium': chalk.yellow, - 'low': chalk.gray - }[task.priority || 'medium'] || chalk.white; - - // Format status - const status = getStatusWithColor(task.status, true); - - // Add the row without truncating dependencies - table.push([ - task.id.toString(), - truncate(cleanTitle, titleWidth - 3), - status, - priorityColor(truncate(task.priority || 'medium', priorityWidth - 2)), - depText // No truncation for dependencies - ]); - - // Add subtasks if requested - if (withSubtasks && task.subtasks && task.subtasks.length > 0) { - task.subtasks.forEach(subtask => { - // Format subtask dependencies with status indicators - let subtaskDepText = 'None'; - if (subtask.dependencies && subtask.dependencies.length > 0) { - // Handle both subtask-to-subtask and subtask-to-task dependencies - const formattedDeps = subtask.dependencies.map(depId => { - // Check if it's a dependency on another subtask - if (typeof depId === 'number' && depId < 100) { - const foundSubtask = task.subtasks.find(st => st.id === depId); - if (foundSubtask) { - const isDone = foundSubtask.status === 'done' || foundSubtask.status === 'completed'; - const isInProgress = foundSubtask.status === 'in-progress'; - - // Use consistent color formatting instead of emojis - if (isDone) { - return chalk.green.bold(`${task.id}.${depId}`); - } else if (isInProgress) { - return chalk.hex('#FFA500').bold(`${task.id}.${depId}`); - } else { - return chalk.red.bold(`${task.id}.${depId}`); - } - } - } - // Default to regular task dependency - const depTask = data.tasks.find(t => t.id === depId); - if (depTask) { - const isDone = depTask.status === 'done' || depTask.status === 'completed'; - const isInProgress = depTask.status === 'in-progress'; - // Use the same color scheme as in formatDependenciesWithStatus - if (isDone) { - return chalk.green.bold(`${depId}`); - } else if (isInProgress) { - return chalk.hex('#FFA500').bold(`${depId}`); - } else { - return chalk.red.bold(`${depId}`); - } - } - return chalk.cyan(depId.toString()); - }).join(', '); - - subtaskDepText = formattedDeps || chalk.gray('None'); - } - - // Add the subtask row without truncating dependencies - table.push([ - `${task.id}.${subtask.id}`, - chalk.dim(`└─ ${truncate(subtask.title, titleWidth - 5)}`), - getStatusWithColor(subtask.status, true), - chalk.dim('-'), - subtaskDepText // No truncation for dependencies - ]); - }); - } - }); - - // Ensure we output the table even if it had to wrap - try { - console.log(table.toString()); - } catch (err) { - log('error', `Error rendering table: ${err.message}`); - - // Fall back to simpler output - console.log(chalk.yellow('\nFalling back to simple task list due to terminal width constraints:')); - filteredTasks.forEach(task => { - console.log(`${chalk.cyan(task.id)}: ${chalk.white(task.title)} - ${getStatusWithColor(task.status)}`); - }); - } - - // Show filter info if applied - if (statusFilter) { - console.log(chalk.yellow(`\nFiltered by status: ${statusFilter}`)); - console.log(chalk.yellow(`Showing ${filteredTasks.length} of ${totalTasks} tasks`)); - } - - // Define priority colors - const priorityColors = { - 'high': chalk.red.bold, - 'medium': chalk.yellow, - 'low': chalk.gray - }; - - // Show next task box in a prominent color - if (nextTask) { - // Prepare subtasks section if they exist - let subtasksSection = ''; - if (nextTask.subtasks && nextTask.subtasks.length > 0) { - subtasksSection = `\n\n${chalk.white.bold('Subtasks:')}\n`; - subtasksSection += nextTask.subtasks.map(subtask => { - // Using a more simplified format for subtask status display - const status = subtask.status || 'pending'; - const statusColors = { - 'done': chalk.green, - 'completed': chalk.green, - 'pending': chalk.yellow, - 'in-progress': chalk.blue, - 'deferred': chalk.gray, - 'blocked': chalk.red - }; - const statusColor = statusColors[status.toLowerCase()] || chalk.white; - return `${chalk.cyan(`${nextTask.id}.${subtask.id}`)} [${statusColor(status)}] ${subtask.title}`; - }).join('\n'); - } - - console.log(boxen( - chalk.hex('#FF8800').bold(`🔥 Next Task to Work On: #${nextTask.id} - ${nextTask.title}`) + '\n\n' + - `${chalk.white('Priority:')} ${priorityColors[nextTask.priority || 'medium'](nextTask.priority || 'medium')} ${chalk.white('Status:')} ${getStatusWithColor(nextTask.status, true)}\n` + - `${chalk.white('Dependencies:')} ${nextTask.dependencies && nextTask.dependencies.length > 0 ? formatDependenciesWithStatus(nextTask.dependencies, data.tasks, true) : chalk.gray('None')}\n\n` + - `${chalk.white('Description:')} ${nextTask.description}` + - subtasksSection + '\n\n' + - `${chalk.cyan('Start working:')} ${chalk.yellow(`task-master set-status --id=${nextTask.id} --status=in-progress`)}\n` + - `${chalk.cyan('View details:')} ${chalk.yellow(`task-master show ${nextTask.id}`)}`, - { - padding: { left: 2, right: 2, top: 1, bottom: 1 }, - borderColor: '#FF8800', - borderStyle: 'round', - margin: { top: 1, bottom: 1 }, - title: '⚡ RECOMMENDED NEXT TASK ⚡', - titleAlignment: 'center', - width: terminalWidth - 4, // Use full terminal width minus a small margin - fullscreen: false // Keep it expandable but not literally fullscreen - } - )); - } else { - console.log(boxen( - chalk.hex('#FF8800').bold('No eligible next task found') + '\n\n' + - 'All pending tasks have dependencies that are not yet completed, or all tasks are done.', - { - padding: 1, - borderColor: '#FF8800', - borderStyle: 'round', - margin: { top: 1, bottom: 1 }, - title: '⚡ NEXT TASK ⚡', - titleAlignment: 'center', - width: terminalWidth - 4, // Use full terminal width minus a small margin - } - )); - } - - // Show next steps - console.log(boxen( - chalk.white.bold('Suggested Next Steps:') + '\n\n' + - `${chalk.cyan('1.')} Run ${chalk.yellow('task-master next')} to see what to work on next\n` + - `${chalk.cyan('2.')} Run ${chalk.yellow('task-master expand --id=<id>')} to break down a task into subtasks\n` + - `${chalk.cyan('3.')} Run ${chalk.yellow('task-master set-status --id=<id> --status=done')} to mark a task as complete`, - { padding: 1, borderColor: 'gray', borderStyle: 'round', margin: { top: 1 } } - )); - } catch (error) { - log('error', `Error listing tasks: ${error.message}`); - - if (outputFormat === 'json') { - // Return structured error for JSON output - throw { - code: 'TASK_LIST_ERROR', - message: error.message, - details: error.stack - }; - } - - console.error(chalk.red(`Error: ${error.message}`)); - process.exit(1); - } + // Count statuses + const doneCount = completedTasks; + const inProgressCount = data.tasks.filter( + (task) => task.status === 'in-progress' + ).length; + const pendingCount = data.tasks.filter( + (task) => task.status === 'pending' + ).length; + const blockedCount = data.tasks.filter( + (task) => task.status === 'blocked' + ).length; + const deferredCount = data.tasks.filter( + (task) => task.status === 'deferred' + ).length; + + // Count subtasks + let totalSubtasks = 0; + let completedSubtasks = 0; + + data.tasks.forEach((task) => { + if (task.subtasks && task.subtasks.length > 0) { + totalSubtasks += task.subtasks.length; + completedSubtasks += task.subtasks.filter( + (st) => st.status === 'done' || st.status === 'completed' + ).length; + } + }); + + const subtaskCompletionPercentage = + totalSubtasks > 0 ? (completedSubtasks / totalSubtasks) * 100 : 0; + + // For JSON output, return structured data + if (outputFormat === 'json') { + // *** Modification: Remove 'details' field for JSON output *** + const tasksWithoutDetails = filteredTasks.map((task) => { + // <-- USES filteredTasks! + // Omit 'details' from the parent task + const { details, ...taskRest } = task; + + // If subtasks exist, omit 'details' from them too + if (taskRest.subtasks && Array.isArray(taskRest.subtasks)) { + taskRest.subtasks = taskRest.subtasks.map((subtask) => { + const { details: subtaskDetails, ...subtaskRest } = subtask; + return subtaskRest; + }); + } + return taskRest; + }); + // *** End of Modification *** + + return { + tasks: tasksWithoutDetails, // <--- THIS IS THE ARRAY BEING RETURNED + filter: statusFilter || 'all', // Return the actual filter used + stats: { + total: totalTasks, + completed: doneCount, + inProgress: inProgressCount, + pending: pendingCount, + blocked: blockedCount, + deferred: deferredCount, + completionPercentage, + subtasks: { + total: totalSubtasks, + completed: completedSubtasks, + completionPercentage: subtaskCompletionPercentage + } + } + }; + } + + // ... existing code for text output ... + + // Create progress bars + const taskProgressBar = createProgressBar(completionPercentage, 30); + const subtaskProgressBar = createProgressBar( + subtaskCompletionPercentage, + 30 + ); + + // Calculate dependency statistics + const completedTaskIds = new Set( + data.tasks + .filter((t) => t.status === 'done' || t.status === 'completed') + .map((t) => t.id) + ); + + const tasksWithNoDeps = data.tasks.filter( + (t) => + t.status !== 'done' && + t.status !== 'completed' && + (!t.dependencies || t.dependencies.length === 0) + ).length; + + const tasksWithAllDepsSatisfied = data.tasks.filter( + (t) => + t.status !== 'done' && + t.status !== 'completed' && + t.dependencies && + t.dependencies.length > 0 && + t.dependencies.every((depId) => completedTaskIds.has(depId)) + ).length; + + const tasksWithUnsatisfiedDeps = data.tasks.filter( + (t) => + t.status !== 'done' && + t.status !== 'completed' && + t.dependencies && + t.dependencies.length > 0 && + !t.dependencies.every((depId) => completedTaskIds.has(depId)) + ).length; + + // Calculate total tasks ready to work on (no deps + satisfied deps) + const tasksReadyToWork = tasksWithNoDeps + tasksWithAllDepsSatisfied; + + // Calculate most depended-on tasks + const dependencyCount = {}; + data.tasks.forEach((task) => { + if (task.dependencies && task.dependencies.length > 0) { + task.dependencies.forEach((depId) => { + dependencyCount[depId] = (dependencyCount[depId] || 0) + 1; + }); + } + }); + + // Find the most depended-on task + let mostDependedOnTaskId = null; + let maxDependents = 0; + + for (const [taskId, count] of Object.entries(dependencyCount)) { + if (count > maxDependents) { + maxDependents = count; + mostDependedOnTaskId = parseInt(taskId); + } + } + + // Get the most depended-on task + const mostDependedOnTask = + mostDependedOnTaskId !== null + ? data.tasks.find((t) => t.id === mostDependedOnTaskId) + : null; + + // Calculate average dependencies per task + const totalDependencies = data.tasks.reduce( + (sum, task) => sum + (task.dependencies ? task.dependencies.length : 0), + 0 + ); + const avgDependenciesPerTask = totalDependencies / data.tasks.length; + + // Find next task to work on + const nextTask = findNextTask(data.tasks); + const nextTaskInfo = nextTask + ? `ID: ${chalk.cyan(nextTask.id)} - ${chalk.white.bold(truncate(nextTask.title, 40))}\n` + + `Priority: ${chalk.white(nextTask.priority || 'medium')} Dependencies: ${formatDependenciesWithStatus(nextTask.dependencies, data.tasks, true)}` + : chalk.yellow( + 'No eligible tasks found. All tasks are either completed or have unsatisfied dependencies.' + ); + + // Get terminal width - more reliable method + let terminalWidth; + try { + // Try to get the actual terminal columns + terminalWidth = process.stdout.columns; + } catch (e) { + // Fallback if columns cannot be determined + log('debug', 'Could not determine terminal width, using default'); + } + // Ensure we have a reasonable default if detection fails + terminalWidth = terminalWidth || 80; + + // Ensure terminal width is at least a minimum value to prevent layout issues + terminalWidth = Math.max(terminalWidth, 80); + + // Create dashboard content + const projectDashboardContent = + chalk.white.bold('Project Dashboard') + + '\n' + + `Tasks Progress: ${chalk.greenBright(taskProgressBar)} ${completionPercentage.toFixed(0)}%\n` + + `Done: ${chalk.green(doneCount)} In Progress: ${chalk.blue(inProgressCount)} Pending: ${chalk.yellow(pendingCount)} Blocked: ${chalk.red(blockedCount)} Deferred: ${chalk.gray(deferredCount)}\n\n` + + `Subtasks Progress: ${chalk.cyan(subtaskProgressBar)} ${subtaskCompletionPercentage.toFixed(0)}%\n` + + `Completed: ${chalk.green(completedSubtasks)}/${totalSubtasks} Remaining: ${chalk.yellow(totalSubtasks - completedSubtasks)}\n\n` + + chalk.cyan.bold('Priority Breakdown:') + + '\n' + + `${chalk.red('•')} ${chalk.white('High priority:')} ${data.tasks.filter((t) => t.priority === 'high').length}\n` + + `${chalk.yellow('•')} ${chalk.white('Medium priority:')} ${data.tasks.filter((t) => t.priority === 'medium').length}\n` + + `${chalk.green('•')} ${chalk.white('Low priority:')} ${data.tasks.filter((t) => t.priority === 'low').length}`; + + const dependencyDashboardContent = + chalk.white.bold('Dependency Status & Next Task') + + '\n' + + chalk.cyan.bold('Dependency Metrics:') + + '\n' + + `${chalk.green('•')} ${chalk.white('Tasks with no dependencies:')} ${tasksWithNoDeps}\n` + + `${chalk.green('•')} ${chalk.white('Tasks ready to work on:')} ${tasksReadyToWork}\n` + + `${chalk.yellow('•')} ${chalk.white('Tasks blocked by dependencies:')} ${tasksWithUnsatisfiedDeps}\n` + + `${chalk.magenta('•')} ${chalk.white('Most depended-on task:')} ${mostDependedOnTask ? chalk.cyan(`#${mostDependedOnTaskId} (${maxDependents} dependents)`) : chalk.gray('None')}\n` + + `${chalk.blue('•')} ${chalk.white('Avg dependencies per task:')} ${avgDependenciesPerTask.toFixed(1)}\n\n` + + chalk.cyan.bold('Next Task to Work On:') + + '\n' + + `ID: ${chalk.cyan(nextTask ? nextTask.id : 'N/A')} - ${nextTask ? chalk.white.bold(truncate(nextTask.title, 40)) : chalk.yellow('No task available')}\n` + + `Priority: ${nextTask ? chalk.white(nextTask.priority || 'medium') : ''} Dependencies: ${nextTask ? formatDependenciesWithStatus(nextTask.dependencies, data.tasks, true) : ''}`; + + // Calculate width for side-by-side display + // Box borders, padding take approximately 4 chars on each side + const minDashboardWidth = 50; // Minimum width for dashboard + const minDependencyWidth = 50; // Minimum width for dependency dashboard + const totalMinWidth = minDashboardWidth + minDependencyWidth + 4; // Extra 4 chars for spacing + + // If terminal is wide enough, show boxes side by side with responsive widths + if (terminalWidth >= totalMinWidth) { + // Calculate widths proportionally for each box - use exact 50% width each + const availableWidth = terminalWidth; + const halfWidth = Math.floor(availableWidth / 2); + + // Account for border characters (2 chars on each side) + const boxContentWidth = halfWidth - 4; + + // Create boxen options with precise widths + const dashboardBox = boxen(projectDashboardContent, { + padding: 1, + borderColor: 'blue', + borderStyle: 'round', + width: boxContentWidth, + dimBorder: false + }); + + const dependencyBox = boxen(dependencyDashboardContent, { + padding: 1, + borderColor: 'magenta', + borderStyle: 'round', + width: boxContentWidth, + dimBorder: false + }); + + // Create a better side-by-side layout with exact spacing + const dashboardLines = dashboardBox.split('\n'); + const dependencyLines = dependencyBox.split('\n'); + + // Make sure both boxes have the same height + const maxHeight = Math.max(dashboardLines.length, dependencyLines.length); + + // For each line of output, pad the dashboard line to exactly halfWidth chars + // This ensures the dependency box starts at exactly the right position + const combinedLines = []; + for (let i = 0; i < maxHeight; i++) { + // Get the dashboard line (or empty string if we've run out of lines) + const dashLine = i < dashboardLines.length ? dashboardLines[i] : ''; + // Get the dependency line (or empty string if we've run out of lines) + const depLine = i < dependencyLines.length ? dependencyLines[i] : ''; + + // Remove any trailing spaces from dashLine before padding to exact width + const trimmedDashLine = dashLine.trimEnd(); + // Pad the dashboard line to exactly halfWidth chars with no extra spaces + const paddedDashLine = trimmedDashLine.padEnd(halfWidth, ' '); + + // Join the lines with no space in between + combinedLines.push(paddedDashLine + depLine); + } + + // Join all lines and output + console.log(combinedLines.join('\n')); + } else { + // Terminal too narrow, show boxes stacked vertically + const dashboardBox = boxen(projectDashboardContent, { + padding: 1, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 0, bottom: 1 } + }); + + const dependencyBox = boxen(dependencyDashboardContent, { + padding: 1, + borderColor: 'magenta', + borderStyle: 'round', + margin: { top: 0, bottom: 1 } + }); + + // Display stacked vertically + console.log(dashboardBox); + console.log(dependencyBox); + } + + if (filteredTasks.length === 0) { + console.log( + boxen( + statusFilter + ? chalk.yellow(`No tasks with status '${statusFilter}' found`) + : chalk.yellow('No tasks found'), + { padding: 1, borderColor: 'yellow', borderStyle: 'round' } + ) + ); + return; + } + + // COMPLETELY REVISED TABLE APPROACH + // Define percentage-based column widths and calculate actual widths + // Adjust percentages based on content type and user requirements + + // Adjust ID width if showing subtasks (subtask IDs are longer: e.g., "1.2") + const idWidthPct = withSubtasks ? 10 : 7; + + // Calculate max status length to accommodate "in-progress" + const statusWidthPct = 15; + + // Increase priority column width as requested + const priorityWidthPct = 12; + + // Make dependencies column smaller as requested (-20%) + const depsWidthPct = 20; + + // Calculate title/description width as remaining space (+20% from dependencies reduction) + const titleWidthPct = + 100 - idWidthPct - statusWidthPct - priorityWidthPct - depsWidthPct; + + // Allow 10 characters for borders and padding + const availableWidth = terminalWidth - 10; + + // Calculate actual column widths based on percentages + const idWidth = Math.floor(availableWidth * (idWidthPct / 100)); + const statusWidth = Math.floor(availableWidth * (statusWidthPct / 100)); + const priorityWidth = Math.floor(availableWidth * (priorityWidthPct / 100)); + const depsWidth = Math.floor(availableWidth * (depsWidthPct / 100)); + const titleWidth = Math.floor(availableWidth * (titleWidthPct / 100)); + + // Create a table with correct borders and spacing + const table = new Table({ + head: [ + chalk.cyan.bold('ID'), + chalk.cyan.bold('Title'), + chalk.cyan.bold('Status'), + chalk.cyan.bold('Priority'), + chalk.cyan.bold('Dependencies') + ], + colWidths: [idWidth, titleWidth, statusWidth, priorityWidth, depsWidth], + style: { + head: [], // No special styling for header + border: [], // No special styling for border + compact: false // Use default spacing + }, + wordWrap: true, + wrapOnWordBoundary: true + }); + + // Process tasks for the table + filteredTasks.forEach((task) => { + // Format dependencies with status indicators (colored) + let depText = 'None'; + if (task.dependencies && task.dependencies.length > 0) { + // Use the proper formatDependenciesWithStatus function for colored status + depText = formatDependenciesWithStatus( + task.dependencies, + data.tasks, + true + ); + } else { + depText = chalk.gray('None'); + } + + // Clean up any ANSI codes or confusing characters + const cleanTitle = task.title.replace(/\n/g, ' '); + + // Get priority color + const priorityColor = + { + high: chalk.red, + medium: chalk.yellow, + low: chalk.gray + }[task.priority || 'medium'] || chalk.white; + + // Format status + const status = getStatusWithColor(task.status, true); + + // Add the row without truncating dependencies + table.push([ + task.id.toString(), + truncate(cleanTitle, titleWidth - 3), + status, + priorityColor(truncate(task.priority || 'medium', priorityWidth - 2)), + depText // No truncation for dependencies + ]); + + // Add subtasks if requested + if (withSubtasks && task.subtasks && task.subtasks.length > 0) { + task.subtasks.forEach((subtask) => { + // Format subtask dependencies with status indicators + let subtaskDepText = 'None'; + if (subtask.dependencies && subtask.dependencies.length > 0) { + // Handle both subtask-to-subtask and subtask-to-task dependencies + const formattedDeps = subtask.dependencies + .map((depId) => { + // Check if it's a dependency on another subtask + if (typeof depId === 'number' && depId < 100) { + const foundSubtask = task.subtasks.find( + (st) => st.id === depId + ); + if (foundSubtask) { + const isDone = + foundSubtask.status === 'done' || + foundSubtask.status === 'completed'; + const isInProgress = foundSubtask.status === 'in-progress'; + + // Use consistent color formatting instead of emojis + if (isDone) { + return chalk.green.bold(`${task.id}.${depId}`); + } else if (isInProgress) { + return chalk.hex('#FFA500').bold(`${task.id}.${depId}`); + } else { + return chalk.red.bold(`${task.id}.${depId}`); + } + } + } + // Default to regular task dependency + const depTask = data.tasks.find((t) => t.id === depId); + if (depTask) { + const isDone = + depTask.status === 'done' || depTask.status === 'completed'; + const isInProgress = depTask.status === 'in-progress'; + // Use the same color scheme as in formatDependenciesWithStatus + if (isDone) { + return chalk.green.bold(`${depId}`); + } else if (isInProgress) { + return chalk.hex('#FFA500').bold(`${depId}`); + } else { + return chalk.red.bold(`${depId}`); + } + } + return chalk.cyan(depId.toString()); + }) + .join(', '); + + subtaskDepText = formattedDeps || chalk.gray('None'); + } + + // Add the subtask row without truncating dependencies + table.push([ + `${task.id}.${subtask.id}`, + chalk.dim(`└─ ${truncate(subtask.title, titleWidth - 5)}`), + getStatusWithColor(subtask.status, true), + chalk.dim('-'), + subtaskDepText // No truncation for dependencies + ]); + }); + } + }); + + // Ensure we output the table even if it had to wrap + try { + console.log(table.toString()); + } catch (err) { + log('error', `Error rendering table: ${err.message}`); + + // Fall back to simpler output + console.log( + chalk.yellow( + '\nFalling back to simple task list due to terminal width constraints:' + ) + ); + filteredTasks.forEach((task) => { + console.log( + `${chalk.cyan(task.id)}: ${chalk.white(task.title)} - ${getStatusWithColor(task.status)}` + ); + }); + } + + // Show filter info if applied + if (statusFilter) { + console.log(chalk.yellow(`\nFiltered by status: ${statusFilter}`)); + console.log( + chalk.yellow(`Showing ${filteredTasks.length} of ${totalTasks} tasks`) + ); + } + + // Define priority colors + const priorityColors = { + high: chalk.red.bold, + medium: chalk.yellow, + low: chalk.gray + }; + + // Show next task box in a prominent color + if (nextTask) { + // Prepare subtasks section if they exist + let subtasksSection = ''; + if (nextTask.subtasks && nextTask.subtasks.length > 0) { + subtasksSection = `\n\n${chalk.white.bold('Subtasks:')}\n`; + subtasksSection += nextTask.subtasks + .map((subtask) => { + // Using a more simplified format for subtask status display + const status = subtask.status || 'pending'; + const statusColors = { + done: chalk.green, + completed: chalk.green, + pending: chalk.yellow, + 'in-progress': chalk.blue, + deferred: chalk.gray, + blocked: chalk.red + }; + const statusColor = + statusColors[status.toLowerCase()] || chalk.white; + return `${chalk.cyan(`${nextTask.id}.${subtask.id}`)} [${statusColor(status)}] ${subtask.title}`; + }) + .join('\n'); + } + + console.log( + boxen( + chalk + .hex('#FF8800') + .bold( + `🔥 Next Task to Work On: #${nextTask.id} - ${nextTask.title}` + ) + + '\n\n' + + `${chalk.white('Priority:')} ${priorityColors[nextTask.priority || 'medium'](nextTask.priority || 'medium')} ${chalk.white('Status:')} ${getStatusWithColor(nextTask.status, true)}\n` + + `${chalk.white('Dependencies:')} ${nextTask.dependencies && nextTask.dependencies.length > 0 ? formatDependenciesWithStatus(nextTask.dependencies, data.tasks, true) : chalk.gray('None')}\n\n` + + `${chalk.white('Description:')} ${nextTask.description}` + + subtasksSection + + '\n\n' + + `${chalk.cyan('Start working:')} ${chalk.yellow(`task-master set-status --id=${nextTask.id} --status=in-progress`)}\n` + + `${chalk.cyan('View details:')} ${chalk.yellow(`task-master show ${nextTask.id}`)}`, + { + padding: { left: 2, right: 2, top: 1, bottom: 1 }, + borderColor: '#FF8800', + borderStyle: 'round', + margin: { top: 1, bottom: 1 }, + title: '⚡ RECOMMENDED NEXT TASK ⚡', + titleAlignment: 'center', + width: terminalWidth - 4, // Use full terminal width minus a small margin + fullscreen: false // Keep it expandable but not literally fullscreen + } + ) + ); + } else { + console.log( + boxen( + chalk.hex('#FF8800').bold('No eligible next task found') + + '\n\n' + + 'All pending tasks have dependencies that are not yet completed, or all tasks are done.', + { + padding: 1, + borderColor: '#FF8800', + borderStyle: 'round', + margin: { top: 1, bottom: 1 }, + title: '⚡ NEXT TASK ⚡', + titleAlignment: 'center', + width: terminalWidth - 4 // Use full terminal width minus a small margin + } + ) + ); + } + + // Show next steps + console.log( + boxen( + chalk.white.bold('Suggested Next Steps:') + + '\n\n' + + `${chalk.cyan('1.')} Run ${chalk.yellow('task-master next')} to see what to work on next\n` + + `${chalk.cyan('2.')} Run ${chalk.yellow('task-master expand --id=<id>')} to break down a task into subtasks\n` + + `${chalk.cyan('3.')} Run ${chalk.yellow('task-master set-status --id=<id> --status=done')} to mark a task as complete`, + { + padding: 1, + borderColor: 'gray', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + } catch (error) { + log('error', `Error listing tasks: ${error.message}`); + + if (outputFormat === 'json') { + // Return structured error for JSON output + throw { + code: 'TASK_LIST_ERROR', + message: error.message, + details: error.stack + }; + } + + console.error(chalk.red(`Error: ${error.message}`)); + process.exit(1); + } } /** @@ -1529,13 +1844,13 @@ function listTasks(tasksPath, statusFilter, withSubtasks = false, outputFormat = * @returns {string} Colored text that won't break table layout */ function safeColor(text, colorFn, maxLength = 0) { - if (!text) return ''; - - // If maxLength is provided, truncate the text first - const baseText = maxLength > 0 ? truncate(text, maxLength) : text; - - // Apply color function if provided, otherwise return as is - return colorFn ? colorFn(baseText) : baseText; + if (!text) return ''; + + // If maxLength is provided, truncate the text first + const baseText = maxLength > 0 ? truncate(text, maxLength) : text; + + // Apply color function if provided, otherwise return as is + return colorFn ? colorFn(baseText) : baseText; } /** @@ -1545,148 +1860,202 @@ function safeColor(text, colorFn, maxLength = 0) { * @param {boolean} useResearch - Whether to use research (Perplexity) * @param {string} additionalContext - Additional context */ -async function expandTask(taskId, numSubtasks = CONFIG.defaultSubtasks, useResearch = false, additionalContext = '') { - try { - displayBanner(); - - // Load tasks - const tasksPath = path.join(process.cwd(), 'tasks', 'tasks.json'); - log('info', `Loading tasks from ${tasksPath}...`); - - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - throw new Error(`No valid tasks found in ${tasksPath}`); - } - - // Find the task - const task = data.tasks.find(t => t.id === taskId); - if (!task) { - throw new Error(`Task ${taskId} not found`); - } - - // Check if the task is already completed - if (task.status === 'done' || task.status === 'completed') { - log('warn', `Task ${taskId} is already marked as "${task.status}". Skipping expansion.`); - console.log(chalk.yellow(`Task ${taskId} is already marked as "${task.status}". Skipping expansion.`)); - return; - } - - // Check for complexity report - log('info', 'Checking for complexity analysis...'); - const complexityReport = readComplexityReport(); - let taskAnalysis = null; - - if (complexityReport) { - taskAnalysis = findTaskInComplexityReport(complexityReport, taskId); - - if (taskAnalysis) { - log('info', `Found complexity analysis for task ${taskId}: Score ${taskAnalysis.complexityScore}/10`); - - // Use recommended number of subtasks if available and not overridden - if (taskAnalysis.recommendedSubtasks && numSubtasks === CONFIG.defaultSubtasks) { - numSubtasks = taskAnalysis.recommendedSubtasks; - log('info', `Using recommended number of subtasks: ${numSubtasks}`); - } - - // Use expansion prompt from analysis as additional context if available - if (taskAnalysis.expansionPrompt && !additionalContext) { - additionalContext = taskAnalysis.expansionPrompt; - log('info', 'Using expansion prompt from complexity analysis'); - } - } else { - log('info', `No complexity analysis found for task ${taskId}`); - } - } - - console.log(boxen( - chalk.white.bold(`Expanding Task: #${taskId} - ${task.title}`), - { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 0, bottom: 1 } } - )); - - // Check if the task already has subtasks - if (task.subtasks && task.subtasks.length > 0) { - log('warn', `Task ${taskId} already has ${task.subtasks.length} subtasks. Appending new subtasks.`); - console.log(chalk.yellow(`Task ${taskId} already has ${task.subtasks.length} subtasks. New subtasks will be appended.`)); - } - - // Initialize subtasks array if it doesn't exist - if (!task.subtasks) { - task.subtasks = []; - } - - // Determine the next subtask ID - const nextSubtaskId = task.subtasks.length > 0 ? - Math.max(...task.subtasks.map(st => st.id)) + 1 : 1; - - // Generate subtasks - let subtasks; - if (useResearch) { - log('info', 'Using Perplexity AI for research-backed subtask generation'); - subtasks = await generateSubtasksWithPerplexity(task, numSubtasks, nextSubtaskId, additionalContext); - } else { - log('info', 'Generating subtasks with Claude only'); - subtasks = await generateSubtasks(task, numSubtasks, nextSubtaskId, additionalContext); - } - - // Add the subtasks to the task - task.subtasks = [...task.subtasks, ...subtasks]; - - // Write the updated tasks to the file - writeJSON(tasksPath, data); - - // Generate individual task files - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); - - // Display success message - console.log(boxen( - chalk.green(`Successfully added ${subtasks.length} subtasks to task ${taskId}`), - { padding: 1, borderColor: 'green', borderStyle: 'round' } - )); - - // Show the subtasks table - const table = new Table({ - head: [ - chalk.cyan.bold('ID'), - chalk.cyan.bold('Title'), - chalk.cyan.bold('Dependencies'), - chalk.cyan.bold('Status') - ], - colWidths: [8, 50, 15, 15] - }); - - subtasks.forEach(subtask => { - const deps = subtask.dependencies && subtask.dependencies.length > 0 ? - subtask.dependencies.map(d => `${taskId}.${d}`).join(', ') : - chalk.gray('None'); - - table.push([ - `${taskId}.${subtask.id}`, - truncate(subtask.title, 47), - deps, - getStatusWithColor(subtask.status, true) - ]); - }); - - console.log(table.toString()); - - // Show next steps - console.log(boxen( - chalk.white.bold('Next Steps:') + '\n\n' + - `${chalk.cyan('1.')} Run ${chalk.yellow(`task-master show ${taskId}`)} to see the full task with subtasks\n` + - `${chalk.cyan('2.')} Start working on subtask: ${chalk.yellow(`task-master set-status --id=${taskId}.1 --status=in-progress`)}\n` + - `${chalk.cyan('3.')} Mark subtask as done: ${chalk.yellow(`task-master set-status --id=${taskId}.1 --status=done`)}`, - { padding: 1, borderColor: 'cyan', borderStyle: 'round', margin: { top: 1 } } - )); - } catch (error) { - log('error', `Error expanding task: ${error.message}`); - console.error(chalk.red(`Error: ${error.message}`)); - - if (CONFIG.debug) { - console.error(error); - } - - process.exit(1); - } +async function expandTask( + taskId, + numSubtasks = CONFIG.defaultSubtasks, + useResearch = false, + additionalContext = '' +) { + try { + displayBanner(); + + // Load tasks + const tasksPath = path.join(process.cwd(), 'tasks', 'tasks.json'); + log('info', `Loading tasks from ${tasksPath}...`); + + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + throw new Error(`No valid tasks found in ${tasksPath}`); + } + + // Find the task + const task = data.tasks.find((t) => t.id === taskId); + if (!task) { + throw new Error(`Task ${taskId} not found`); + } + + // Check if the task is already completed + if (task.status === 'done' || task.status === 'completed') { + log( + 'warn', + `Task ${taskId} is already marked as "${task.status}". Skipping expansion.` + ); + console.log( + chalk.yellow( + `Task ${taskId} is already marked as "${task.status}". Skipping expansion.` + ) + ); + return; + } + + // Check for complexity report + log('info', 'Checking for complexity analysis...'); + const complexityReport = readComplexityReport(); + let taskAnalysis = null; + + if (complexityReport) { + taskAnalysis = findTaskInComplexityReport(complexityReport, taskId); + + if (taskAnalysis) { + log( + 'info', + `Found complexity analysis for task ${taskId}: Score ${taskAnalysis.complexityScore}/10` + ); + + // Use recommended number of subtasks if available and not overridden + if ( + taskAnalysis.recommendedSubtasks && + numSubtasks === CONFIG.defaultSubtasks + ) { + numSubtasks = taskAnalysis.recommendedSubtasks; + log('info', `Using recommended number of subtasks: ${numSubtasks}`); + } + + // Use expansion prompt from analysis as additional context if available + if (taskAnalysis.expansionPrompt && !additionalContext) { + additionalContext = taskAnalysis.expansionPrompt; + log('info', 'Using expansion prompt from complexity analysis'); + } + } else { + log('info', `No complexity analysis found for task ${taskId}`); + } + } + + console.log( + boxen(chalk.white.bold(`Expanding Task: #${taskId} - ${task.title}`), { + padding: 1, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 0, bottom: 1 } + }) + ); + + // Check if the task already has subtasks + if (task.subtasks && task.subtasks.length > 0) { + log( + 'warn', + `Task ${taskId} already has ${task.subtasks.length} subtasks. Appending new subtasks.` + ); + console.log( + chalk.yellow( + `Task ${taskId} already has ${task.subtasks.length} subtasks. New subtasks will be appended.` + ) + ); + } + + // Initialize subtasks array if it doesn't exist + if (!task.subtasks) { + task.subtasks = []; + } + + // Determine the next subtask ID + const nextSubtaskId = + task.subtasks.length > 0 + ? Math.max(...task.subtasks.map((st) => st.id)) + 1 + : 1; + + // Generate subtasks + let subtasks; + if (useResearch) { + log('info', 'Using Perplexity AI for research-backed subtask generation'); + subtasks = await generateSubtasksWithPerplexity( + task, + numSubtasks, + nextSubtaskId, + additionalContext + ); + } else { + log('info', 'Generating subtasks with Claude only'); + subtasks = await generateSubtasks( + task, + numSubtasks, + nextSubtaskId, + additionalContext + ); + } + + // Add the subtasks to the task + task.subtasks = [...task.subtasks, ...subtasks]; + + // Write the updated tasks to the file + writeJSON(tasksPath, data); + + // Generate individual task files + await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + + // Display success message + console.log( + boxen( + chalk.green( + `Successfully added ${subtasks.length} subtasks to task ${taskId}` + ), + { padding: 1, borderColor: 'green', borderStyle: 'round' } + ) + ); + + // Show the subtasks table + const table = new Table({ + head: [ + chalk.cyan.bold('ID'), + chalk.cyan.bold('Title'), + chalk.cyan.bold('Dependencies'), + chalk.cyan.bold('Status') + ], + colWidths: [8, 50, 15, 15] + }); + + subtasks.forEach((subtask) => { + const deps = + subtask.dependencies && subtask.dependencies.length > 0 + ? subtask.dependencies.map((d) => `${taskId}.${d}`).join(', ') + : chalk.gray('None'); + + table.push([ + `${taskId}.${subtask.id}`, + truncate(subtask.title, 47), + deps, + getStatusWithColor(subtask.status, true) + ]); + }); + + console.log(table.toString()); + + // Show next steps + console.log( + boxen( + chalk.white.bold('Next Steps:') + + '\n\n' + + `${chalk.cyan('1.')} Run ${chalk.yellow(`task-master show ${taskId}`)} to see the full task with subtasks\n` + + `${chalk.cyan('2.')} Start working on subtask: ${chalk.yellow(`task-master set-status --id=${taskId}.1 --status=in-progress`)}\n` + + `${chalk.cyan('3.')} Mark subtask as done: ${chalk.yellow(`task-master set-status --id=${taskId}.1 --status=done`)}`, + { + padding: 1, + borderColor: 'cyan', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + } catch (error) { + log('error', `Error expanding task: ${error.message}`); + console.error(chalk.red(`Error: ${error.message}`)); + + if (CONFIG.debug) { + console.error(error); + } + + process.exit(1); + } } /** @@ -1696,195 +2065,264 @@ async function expandTask(taskId, numSubtasks = CONFIG.defaultSubtasks, useResea * @param {string} additionalContext - Additional context * @param {boolean} forceFlag - Force regeneration for tasks with subtasks */ -async function expandAllTasks(numSubtasks = CONFIG.defaultSubtasks, useResearch = false, additionalContext = '', forceFlag = false) { - try { - displayBanner(); - - // Load tasks - const tasksPath = path.join(process.cwd(), 'tasks', 'tasks.json'); - log('info', `Loading tasks from ${tasksPath}...`); - - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - throw new Error(`No valid tasks found in ${tasksPath}`); - } - - // Get complexity report if it exists - log('info', 'Checking for complexity analysis...'); - const complexityReport = readComplexityReport(); - - // Filter tasks that are not done and don't have subtasks (unless forced) - const pendingTasks = data.tasks.filter(task => - task.status !== 'done' && - task.status !== 'completed' && - (forceFlag || !task.subtasks || task.subtasks.length === 0) - ); - - if (pendingTasks.length === 0) { - log('info', 'No pending tasks found to expand'); - console.log(boxen( - chalk.yellow('No pending tasks found to expand'), - { padding: 1, borderColor: 'yellow', borderStyle: 'round' } - )); - return; - } - - // Sort tasks by complexity if report exists, otherwise by ID - let tasksToExpand = [...pendingTasks]; - - if (complexityReport && complexityReport.complexityAnalysis) { - log('info', 'Sorting tasks by complexity...'); - - // Create a map of task IDs to complexity scores - const complexityMap = new Map(); - complexityReport.complexityAnalysis.forEach(analysis => { - complexityMap.set(analysis.taskId, analysis.complexityScore); - }); - - // Sort tasks by complexity score (high to low) - tasksToExpand.sort((a, b) => { - const scoreA = complexityMap.get(a.id) || 0; - const scoreB = complexityMap.get(b.id) || 0; - return scoreB - scoreA; - }); - } else { - // Sort by ID if no complexity report - tasksToExpand.sort((a, b) => a.id - b.id); - } - - console.log(boxen( - chalk.white.bold(`Expanding ${tasksToExpand.length} Pending Tasks`), - { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 0, bottom: 1 } } - )); - - // Show tasks to be expanded - const table = new Table({ - head: [ - chalk.cyan.bold('ID'), - chalk.cyan.bold('Title'), - chalk.cyan.bold('Status'), - chalk.cyan.bold('Complexity') - ], - colWidths: [5, 50, 15, 15] - }); - - tasksToExpand.forEach(task => { - const taskAnalysis = complexityReport ? - findTaskInComplexityReport(complexityReport, task.id) : null; - - const complexity = taskAnalysis ? - getComplexityWithColor(taskAnalysis.complexityScore) + '/10' : - chalk.gray('Unknown'); - - table.push([ - task.id, - truncate(task.title, 47), - getStatusWithColor(task.status), - complexity - ]); - }); - - console.log(table.toString()); - - // Confirm expansion - console.log(chalk.yellow(`\nThis will expand ${tasksToExpand.length} tasks with ${numSubtasks} subtasks each.`)); - console.log(chalk.yellow(`Research-backed generation: ${useResearch ? 'Yes' : 'No'}`)); - console.log(chalk.yellow(`Force regeneration: ${forceFlag ? 'Yes' : 'No'}`)); - - // Expand each task - let expandedCount = 0; - for (const task of tasksToExpand) { - try { - log('info', `Expanding task ${task.id}: ${task.title}`); - - // Get task-specific parameters from complexity report - let taskSubtasks = numSubtasks; - let taskContext = additionalContext; - - if (complexityReport) { - const taskAnalysis = findTaskInComplexityReport(complexityReport, task.id); - if (taskAnalysis) { - // Use recommended subtasks if default wasn't overridden - if (taskAnalysis.recommendedSubtasks && numSubtasks === CONFIG.defaultSubtasks) { - taskSubtasks = taskAnalysis.recommendedSubtasks; - log('info', `Using recommended subtasks for task ${task.id}: ${taskSubtasks}`); - } - - // Add expansion prompt if no user context was provided - if (taskAnalysis.expansionPrompt && !additionalContext) { - taskContext = taskAnalysis.expansionPrompt; - log('info', `Using complexity analysis prompt for task ${task.id}`); - } - } - } - - // Check if the task already has subtasks - if (task.subtasks && task.subtasks.length > 0) { - if (forceFlag) { - log('info', `Task ${task.id} already has ${task.subtasks.length} subtasks. Clearing them due to --force flag.`); - task.subtasks = []; // Clear existing subtasks - } else { - log('warn', `Task ${task.id} already has subtasks. Skipping (use --force to regenerate).`); - continue; - } - } - - // Initialize subtasks array if it doesn't exist - if (!task.subtasks) { - task.subtasks = []; - } - - // Determine the next subtask ID - const nextSubtaskId = task.subtasks.length > 0 ? - Math.max(...task.subtasks.map(st => st.id)) + 1 : 1; - - // Generate subtasks - let subtasks; - if (useResearch) { - subtasks = await generateSubtasksWithPerplexity(task, taskSubtasks, nextSubtaskId, taskContext); - } else { - subtasks = await generateSubtasks(task, taskSubtasks, nextSubtaskId, taskContext); - } - - // Add the subtasks to the task - task.subtasks = [...task.subtasks, ...subtasks]; - expandedCount++; - } catch (error) { - log('error', `Error expanding task ${task.id}: ${error.message}`); - console.error(chalk.red(`Error expanding task ${task.id}: ${error.message}`)); - continue; - } - } - - // Write the updated tasks to the file - writeJSON(tasksPath, data); - - // Generate individual task files - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); - - // Display success message - console.log(boxen( - chalk.green(`Successfully expanded ${expandedCount} of ${tasksToExpand.length} tasks`), - { padding: 1, borderColor: 'green', borderStyle: 'round' } - )); - - // Show next steps - console.log(boxen( - chalk.white.bold('Next Steps:') + '\n\n' + - `${chalk.cyan('1.')} Run ${chalk.yellow('task-master list --with-subtasks')} to see all tasks with subtasks\n` + - `${chalk.cyan('2.')} Run ${chalk.yellow('task-master next')} to see what to work on next`, - { padding: 1, borderColor: 'cyan', borderStyle: 'round', margin: { top: 1 } } - )); - } catch (error) { - log('error', `Error expanding tasks: ${error.message}`); - console.error(chalk.red(`Error: ${error.message}`)); - - if (CONFIG.debug) { - console.error(error); - } - - process.exit(1); - } +async function expandAllTasks( + numSubtasks = CONFIG.defaultSubtasks, + useResearch = false, + additionalContext = '', + forceFlag = false +) { + try { + displayBanner(); + + // Load tasks + const tasksPath = path.join(process.cwd(), 'tasks', 'tasks.json'); + log('info', `Loading tasks from ${tasksPath}...`); + + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + throw new Error(`No valid tasks found in ${tasksPath}`); + } + + // Get complexity report if it exists + log('info', 'Checking for complexity analysis...'); + const complexityReport = readComplexityReport(); + + // Filter tasks that are not done and don't have subtasks (unless forced) + const pendingTasks = data.tasks.filter( + (task) => + task.status !== 'done' && + task.status !== 'completed' && + (forceFlag || !task.subtasks || task.subtasks.length === 0) + ); + + if (pendingTasks.length === 0) { + log('info', 'No pending tasks found to expand'); + console.log( + boxen(chalk.yellow('No pending tasks found to expand'), { + padding: 1, + borderColor: 'yellow', + borderStyle: 'round' + }) + ); + return; + } + + // Sort tasks by complexity if report exists, otherwise by ID + let tasksToExpand = [...pendingTasks]; + + if (complexityReport && complexityReport.complexityAnalysis) { + log('info', 'Sorting tasks by complexity...'); + + // Create a map of task IDs to complexity scores + const complexityMap = new Map(); + complexityReport.complexityAnalysis.forEach((analysis) => { + complexityMap.set(analysis.taskId, analysis.complexityScore); + }); + + // Sort tasks by complexity score (high to low) + tasksToExpand.sort((a, b) => { + const scoreA = complexityMap.get(a.id) || 0; + const scoreB = complexityMap.get(b.id) || 0; + return scoreB - scoreA; + }); + } else { + // Sort by ID if no complexity report + tasksToExpand.sort((a, b) => a.id - b.id); + } + + console.log( + boxen( + chalk.white.bold(`Expanding ${tasksToExpand.length} Pending Tasks`), + { + padding: 1, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 0, bottom: 1 } + } + ) + ); + + // Show tasks to be expanded + const table = new Table({ + head: [ + chalk.cyan.bold('ID'), + chalk.cyan.bold('Title'), + chalk.cyan.bold('Status'), + chalk.cyan.bold('Complexity') + ], + colWidths: [5, 50, 15, 15] + }); + + tasksToExpand.forEach((task) => { + const taskAnalysis = complexityReport + ? findTaskInComplexityReport(complexityReport, task.id) + : null; + + const complexity = taskAnalysis + ? getComplexityWithColor(taskAnalysis.complexityScore) + '/10' + : chalk.gray('Unknown'); + + table.push([ + task.id, + truncate(task.title, 47), + getStatusWithColor(task.status), + complexity + ]); + }); + + console.log(table.toString()); + + // Confirm expansion + console.log( + chalk.yellow( + `\nThis will expand ${tasksToExpand.length} tasks with ${numSubtasks} subtasks each.` + ) + ); + console.log( + chalk.yellow(`Research-backed generation: ${useResearch ? 'Yes' : 'No'}`) + ); + console.log( + chalk.yellow(`Force regeneration: ${forceFlag ? 'Yes' : 'No'}`) + ); + + // Expand each task + let expandedCount = 0; + for (const task of tasksToExpand) { + try { + log('info', `Expanding task ${task.id}: ${task.title}`); + + // Get task-specific parameters from complexity report + let taskSubtasks = numSubtasks; + let taskContext = additionalContext; + + if (complexityReport) { + const taskAnalysis = findTaskInComplexityReport( + complexityReport, + task.id + ); + if (taskAnalysis) { + // Use recommended subtasks if default wasn't overridden + if ( + taskAnalysis.recommendedSubtasks && + numSubtasks === CONFIG.defaultSubtasks + ) { + taskSubtasks = taskAnalysis.recommendedSubtasks; + log( + 'info', + `Using recommended subtasks for task ${task.id}: ${taskSubtasks}` + ); + } + + // Add expansion prompt if no user context was provided + if (taskAnalysis.expansionPrompt && !additionalContext) { + taskContext = taskAnalysis.expansionPrompt; + log( + 'info', + `Using complexity analysis prompt for task ${task.id}` + ); + } + } + } + + // Check if the task already has subtasks + if (task.subtasks && task.subtasks.length > 0) { + if (forceFlag) { + log( + 'info', + `Task ${task.id} already has ${task.subtasks.length} subtasks. Clearing them due to --force flag.` + ); + task.subtasks = []; // Clear existing subtasks + } else { + log( + 'warn', + `Task ${task.id} already has subtasks. Skipping (use --force to regenerate).` + ); + continue; + } + } + + // Initialize subtasks array if it doesn't exist + if (!task.subtasks) { + task.subtasks = []; + } + + // Determine the next subtask ID + const nextSubtaskId = + task.subtasks.length > 0 + ? Math.max(...task.subtasks.map((st) => st.id)) + 1 + : 1; + + // Generate subtasks + let subtasks; + if (useResearch) { + subtasks = await generateSubtasksWithPerplexity( + task, + taskSubtasks, + nextSubtaskId, + taskContext + ); + } else { + subtasks = await generateSubtasks( + task, + taskSubtasks, + nextSubtaskId, + taskContext + ); + } + + // Add the subtasks to the task + task.subtasks = [...task.subtasks, ...subtasks]; + expandedCount++; + } catch (error) { + log('error', `Error expanding task ${task.id}: ${error.message}`); + console.error( + chalk.red(`Error expanding task ${task.id}: ${error.message}`) + ); + continue; + } + } + + // Write the updated tasks to the file + writeJSON(tasksPath, data); + + // Generate individual task files + await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + + // Display success message + console.log( + boxen( + chalk.green( + `Successfully expanded ${expandedCount} of ${tasksToExpand.length} tasks` + ), + { padding: 1, borderColor: 'green', borderStyle: 'round' } + ) + ); + + // Show next steps + console.log( + boxen( + chalk.white.bold('Next Steps:') + + '\n\n' + + `${chalk.cyan('1.')} Run ${chalk.yellow('task-master list --with-subtasks')} to see all tasks with subtasks\n` + + `${chalk.cyan('2.')} Run ${chalk.yellow('task-master next')} to see what to work on next`, + { + padding: 1, + borderColor: 'cyan', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + } catch (error) { + log('error', `Error expanding tasks: ${error.message}`); + console.error(chalk.red(`Error: ${error.message}`)); + + if (CONFIG.debug) { + console.error(error); + } + + process.exit(1); + } } /** @@ -1893,104 +2331,132 @@ async function expandAllTasks(numSubtasks = CONFIG.defaultSubtasks, useResearch * @param {string} taskIds - Task IDs to clear subtasks from */ function clearSubtasks(tasksPath, taskIds) { - displayBanner(); - - log('info', `Reading tasks from ${tasksPath}...`); - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - log('error', "No valid tasks found."); - process.exit(1); - } + displayBanner(); - console.log(boxen( - chalk.white.bold('Clearing Subtasks'), - { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 1 } } - )); + log('info', `Reading tasks from ${tasksPath}...`); + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + log('error', 'No valid tasks found.'); + process.exit(1); + } - // Handle multiple task IDs (comma-separated) - const taskIdArray = taskIds.split(',').map(id => id.trim()); - let clearedCount = 0; - - // Create a summary table for the cleared subtasks - const summaryTable = new Table({ - head: [ - chalk.cyan.bold('Task ID'), - chalk.cyan.bold('Task Title'), - chalk.cyan.bold('Subtasks Cleared') - ], - colWidths: [10, 50, 20], - style: { head: [], border: [] } - }); + console.log( + boxen(chalk.white.bold('Clearing Subtasks'), { + padding: 1, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + }) + ); - taskIdArray.forEach(taskId => { - const id = parseInt(taskId, 10); - if (isNaN(id)) { - log('error', `Invalid task ID: ${taskId}`); - return; - } + // Handle multiple task IDs (comma-separated) + const taskIdArray = taskIds.split(',').map((id) => id.trim()); + let clearedCount = 0; - const task = data.tasks.find(t => t.id === id); - if (!task) { - log('error', `Task ${id} not found`); - return; - } + // Create a summary table for the cleared subtasks + const summaryTable = new Table({ + head: [ + chalk.cyan.bold('Task ID'), + chalk.cyan.bold('Task Title'), + chalk.cyan.bold('Subtasks Cleared') + ], + colWidths: [10, 50, 20], + style: { head: [], border: [] } + }); - if (!task.subtasks || task.subtasks.length === 0) { - log('info', `Task ${id} has no subtasks to clear`); - summaryTable.push([ - id.toString(), - truncate(task.title, 47), - chalk.yellow('No subtasks') - ]); - return; - } + taskIdArray.forEach((taskId) => { + const id = parseInt(taskId, 10); + if (isNaN(id)) { + log('error', `Invalid task ID: ${taskId}`); + return; + } - const subtaskCount = task.subtasks.length; - task.subtasks = []; - clearedCount++; - log('info', `Cleared ${subtaskCount} subtasks from task ${id}`); - - summaryTable.push([ - id.toString(), - truncate(task.title, 47), - chalk.green(`${subtaskCount} subtasks cleared`) - ]); - }); + const task = data.tasks.find((t) => t.id === id); + if (!task) { + log('error', `Task ${id} not found`); + return; + } - if (clearedCount > 0) { - writeJSON(tasksPath, data); - - // Show summary table - console.log(boxen( - chalk.white.bold('Subtask Clearing Summary:'), - { padding: { left: 2, right: 2, top: 0, bottom: 0 }, margin: { top: 1, bottom: 0 }, borderColor: 'blue', borderStyle: 'round' } - )); - console.log(summaryTable.toString()); - - // Regenerate task files to reflect changes - log('info', "Regenerating task files..."); - generateTaskFiles(tasksPath, path.dirname(tasksPath)); - - // Success message - console.log(boxen( - chalk.green(`Successfully cleared subtasks from ${chalk.bold(clearedCount)} task(s)`), - { padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1 } } - )); - - // Next steps suggestion - console.log(boxen( - chalk.white.bold('Next Steps:') + '\n\n' + - `${chalk.cyan('1.')} Run ${chalk.yellow('task-master expand --id=<id>')} to generate new subtasks\n` + - `${chalk.cyan('2.')} Run ${chalk.yellow('task-master list --with-subtasks')} to verify changes`, - { padding: 1, borderColor: 'cyan', borderStyle: 'round', margin: { top: 1 } } - )); - - } else { - console.log(boxen( - chalk.yellow('No subtasks were cleared'), - { padding: 1, borderColor: 'yellow', borderStyle: 'round', margin: { top: 1 } } - )); - } + if (!task.subtasks || task.subtasks.length === 0) { + log('info', `Task ${id} has no subtasks to clear`); + summaryTable.push([ + id.toString(), + truncate(task.title, 47), + chalk.yellow('No subtasks') + ]); + return; + } + + const subtaskCount = task.subtasks.length; + task.subtasks = []; + clearedCount++; + log('info', `Cleared ${subtaskCount} subtasks from task ${id}`); + + summaryTable.push([ + id.toString(), + truncate(task.title, 47), + chalk.green(`${subtaskCount} subtasks cleared`) + ]); + }); + + if (clearedCount > 0) { + writeJSON(tasksPath, data); + + // Show summary table + console.log( + boxen(chalk.white.bold('Subtask Clearing Summary:'), { + padding: { left: 2, right: 2, top: 0, bottom: 0 }, + margin: { top: 1, bottom: 0 }, + borderColor: 'blue', + borderStyle: 'round' + }) + ); + console.log(summaryTable.toString()); + + // Regenerate task files to reflect changes + log('info', 'Regenerating task files...'); + generateTaskFiles(tasksPath, path.dirname(tasksPath)); + + // Success message + console.log( + boxen( + chalk.green( + `Successfully cleared subtasks from ${chalk.bold(clearedCount)} task(s)` + ), + { + padding: 1, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + + // Next steps suggestion + console.log( + boxen( + chalk.white.bold('Next Steps:') + + '\n\n' + + `${chalk.cyan('1.')} Run ${chalk.yellow('task-master expand --id=<id>')} to generate new subtasks\n` + + `${chalk.cyan('2.')} Run ${chalk.yellow('task-master list --with-subtasks')} to verify changes`, + { + padding: 1, + borderColor: 'cyan', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + } else { + console.log( + boxen(chalk.yellow('No subtasks were cleared'), { + padding: 1, + borderColor: 'yellow', + borderStyle: 'round', + margin: { top: 1 } + }) + ); + } } /** @@ -2001,62 +2467,79 @@ function clearSubtasks(tasksPath, taskIds) { * @param {string} priority - Task priority * @returns {number} The new task ID */ -async function addTask(tasksPath, prompt, dependencies = [], priority = 'medium') { - displayBanner(); - - // Read the existing tasks - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - log('error', "Invalid or missing tasks.json."); - process.exit(1); - } - - // Find the highest task ID to determine the next ID - const highestId = Math.max(...data.tasks.map(t => t.id)); - const newTaskId = highestId + 1; - - console.log(boxen( - chalk.white.bold(`Creating New Task #${newTaskId}`), - { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 1 } } - )); - - // Validate dependencies before proceeding - const invalidDeps = dependencies.filter(depId => { - return !data.tasks.some(t => t.id === depId); - }); - - if (invalidDeps.length > 0) { - log('warn', `The following dependencies do not exist: ${invalidDeps.join(', ')}`); - log('info', 'Removing invalid dependencies...'); - dependencies = dependencies.filter(depId => !invalidDeps.includes(depId)); - } - - // Create the system prompt for Claude - const systemPrompt = "You are a helpful assistant that creates well-structured tasks for a software development project. Generate a single new task based on the user's description."; - - // Create the user prompt with context from existing tasks - let contextTasks = ''; - if (dependencies.length > 0) { - // Provide context for the dependent tasks - const dependentTasks = data.tasks.filter(t => dependencies.includes(t.id)); - contextTasks = `\nThis task depends on the following tasks:\n${dependentTasks.map(t => - `- Task ${t.id}: ${t.title} - ${t.description}`).join('\n')}`; - } else { - // Provide a few recent tasks as context - const recentTasks = [...data.tasks].sort((a, b) => b.id - a.id).slice(0, 3); - contextTasks = `\nRecent tasks in the project:\n${recentTasks.map(t => - `- Task ${t.id}: ${t.title} - ${t.description}`).join('\n')}`; - } - - const taskStructure = ` +async function addTask( + tasksPath, + prompt, + dependencies = [], + priority = 'medium' +) { + displayBanner(); + + // Read the existing tasks + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + log('error', 'Invalid or missing tasks.json.'); + process.exit(1); + } + + // Find the highest task ID to determine the next ID + const highestId = Math.max(...data.tasks.map((t) => t.id)); + const newTaskId = highestId + 1; + + console.log( + boxen(chalk.white.bold(`Creating New Task #${newTaskId}`), { + padding: 1, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + }) + ); + + // Validate dependencies before proceeding + const invalidDeps = dependencies.filter((depId) => { + return !data.tasks.some((t) => t.id === depId); + }); + + if (invalidDeps.length > 0) { + log( + 'warn', + `The following dependencies do not exist: ${invalidDeps.join(', ')}` + ); + log('info', 'Removing invalid dependencies...'); + dependencies = dependencies.filter((depId) => !invalidDeps.includes(depId)); + } + + // Create the system prompt for Claude + const systemPrompt = + "You are a helpful assistant that creates well-structured tasks for a software development project. Generate a single new task based on the user's description."; + + // Create the user prompt with context from existing tasks + let contextTasks = ''; + if (dependencies.length > 0) { + // Provide context for the dependent tasks + const dependentTasks = data.tasks.filter((t) => + dependencies.includes(t.id) + ); + contextTasks = `\nThis task depends on the following tasks:\n${dependentTasks + .map((t) => `- Task ${t.id}: ${t.title} - ${t.description}`) + .join('\n')}`; + } else { + // Provide a few recent tasks as context + const recentTasks = [...data.tasks].sort((a, b) => b.id - a.id).slice(0, 3); + contextTasks = `\nRecent tasks in the project:\n${recentTasks + .map((t) => `- Task ${t.id}: ${t.title} - ${t.description}`) + .join('\n')}`; + } + + const taskStructure = ` { "title": "Task title goes here", "description": "A concise one or two sentence description of what the task involves", "details": "In-depth details including specifics on implementation, considerations, and anything important for the developer to know. This should be detailed enough to guide implementation.", "testStrategy": "A detailed approach for verifying the task has been correctly implemented. Include specific test cases or validation methods." }`; - - const userPrompt = `Create a comprehensive new task (Task #${newTaskId}) for a software development project based on this description: "${prompt}" + + const userPrompt = `Create a comprehensive new task (Task #${newTaskId}) for a software development project based on this description: "${prompt}" ${contextTasks} @@ -2067,112 +2550,139 @@ async function addTask(tasksPath, prompt, dependencies = [], priority = 'medium' Make sure the details and test strategy are thorough and specific. IMPORTANT: Return ONLY the JSON object, nothing else.`; - - // Start the loading indicator - const loadingIndicator = startLoadingIndicator('Generating new task with Claude AI...'); - - let fullResponse = ''; - let streamingInterval = null; - try { - // Call Claude with streaming enabled - const stream = await anthropic.messages.create({ - max_tokens: CONFIG.maxTokens, - model: CONFIG.model, - temperature: CONFIG.temperature, - messages: [{ role: "user", content: userPrompt }], - system: systemPrompt, - stream: true - }); - - // Update loading indicator to show streaming progress - let dotCount = 0; - streamingInterval = setInterval(() => { - readline.cursorTo(process.stdout, 0); - process.stdout.write(`Receiving streaming response from Claude${'.'.repeat(dotCount)}`); - dotCount = (dotCount + 1) % 4; - }, 500); - - // Process the stream - for await (const chunk of stream) { - if (chunk.type === 'content_block_delta' && chunk.delta.text) { - fullResponse += chunk.delta.text; - } - } - - if (streamingInterval) clearInterval(streamingInterval); - stopLoadingIndicator(loadingIndicator); - - log('info', "Completed streaming response from Claude API!"); - log('debug', `Streaming response length: ${fullResponse.length} characters`); - - // Parse the response - handle potential JSON formatting issues - let taskData; - try { - // Check if the response is wrapped in a code block - const jsonMatch = fullResponse.match(/```(?:json)?([^`]+)```/); - const jsonContent = jsonMatch ? jsonMatch[1] : fullResponse; - - // Parse the JSON - taskData = JSON.parse(jsonContent); - - // Check that we have the required fields - if (!taskData.title || !taskData.description) { - throw new Error("Missing required fields in the generated task"); - } - } catch (error) { - log('error', "Failed to parse Claude's response as valid task JSON:", error); - log('debug', "Response content:", fullResponse); - process.exit(1); - } - - // Create the new task object - const newTask = { - id: newTaskId, - title: taskData.title, - description: taskData.description, - status: "pending", - dependencies: dependencies, - priority: priority, - details: taskData.details || "", - testStrategy: taskData.testStrategy || "Manually verify the implementation works as expected." - }; - - // Add the new task to the tasks array - data.tasks.push(newTask); - - // Validate dependencies in the entire task set - log('info', "Validating dependencies after adding new task..."); - validateAndFixDependencies(data, null); - - // Write the updated tasks back to the file - writeJSON(tasksPath, data); - - // Show success message - const successBox = boxen( - chalk.green(`Successfully added new task #${newTaskId}:\n`) + - chalk.white.bold(newTask.title) + "\n\n" + - chalk.white(newTask.description), - { padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1 } } - ); - console.log(successBox); - - // Next steps suggestion - console.log(boxen( - chalk.white.bold('Next Steps:') + '\n\n' + - `${chalk.cyan('1.')} Run ${chalk.yellow('task-master generate')} to update task files\n` + - `${chalk.cyan('2.')} Run ${chalk.yellow('task-master expand --id=' + newTaskId)} to break it down into subtasks\n` + - `${chalk.cyan('3.')} Run ${chalk.yellow('task-master list --with-subtasks')} to see all tasks`, - { padding: 1, borderColor: 'cyan', borderStyle: 'round', margin: { top: 1 } } - )); - - return newTaskId; - } catch (error) { - if (streamingInterval) clearInterval(streamingInterval); - stopLoadingIndicator(loadingIndicator); - log('error', "Error generating task:", error.message); - process.exit(1); - } + // Start the loading indicator + const loadingIndicator = startLoadingIndicator( + 'Generating new task with Claude AI...' + ); + + let fullResponse = ''; + let streamingInterval = null; + + try { + // Call Claude with streaming enabled + const stream = await anthropic.messages.create({ + max_tokens: CONFIG.maxTokens, + model: CONFIG.model, + temperature: CONFIG.temperature, + messages: [{ role: 'user', content: userPrompt }], + system: systemPrompt, + stream: true + }); + + // Update loading indicator to show streaming progress + let dotCount = 0; + streamingInterval = setInterval(() => { + readline.cursorTo(process.stdout, 0); + process.stdout.write( + `Receiving streaming response from Claude${'.'.repeat(dotCount)}` + ); + dotCount = (dotCount + 1) % 4; + }, 500); + + // Process the stream + for await (const chunk of stream) { + if (chunk.type === 'content_block_delta' && chunk.delta.text) { + fullResponse += chunk.delta.text; + } + } + + if (streamingInterval) clearInterval(streamingInterval); + stopLoadingIndicator(loadingIndicator); + + log('info', 'Completed streaming response from Claude API!'); + log( + 'debug', + `Streaming response length: ${fullResponse.length} characters` + ); + + // Parse the response - handle potential JSON formatting issues + let taskData; + try { + // Check if the response is wrapped in a code block + const jsonMatch = fullResponse.match(/```(?:json)?([^`]+)```/); + const jsonContent = jsonMatch ? jsonMatch[1] : fullResponse; + + // Parse the JSON + taskData = JSON.parse(jsonContent); + + // Check that we have the required fields + if (!taskData.title || !taskData.description) { + throw new Error('Missing required fields in the generated task'); + } + } catch (error) { + log( + 'error', + "Failed to parse Claude's response as valid task JSON:", + error + ); + log('debug', 'Response content:', fullResponse); + process.exit(1); + } + + // Create the new task object + const newTask = { + id: newTaskId, + title: taskData.title, + description: taskData.description, + status: 'pending', + dependencies: dependencies, + priority: priority, + details: taskData.details || '', + testStrategy: + taskData.testStrategy || + 'Manually verify the implementation works as expected.' + }; + + // Add the new task to the tasks array + data.tasks.push(newTask); + + // Validate dependencies in the entire task set + log('info', 'Validating dependencies after adding new task...'); + validateAndFixDependencies(data, null); + + // Write the updated tasks back to the file + writeJSON(tasksPath, data); + + // Show success message + const successBox = boxen( + chalk.green(`Successfully added new task #${newTaskId}:\n`) + + chalk.white.bold(newTask.title) + + '\n\n' + + chalk.white(newTask.description), + { + padding: 1, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1 } + } + ); + console.log(successBox); + + // Next steps suggestion + console.log( + boxen( + chalk.white.bold('Next Steps:') + + '\n\n' + + `${chalk.cyan('1.')} Run ${chalk.yellow('task-master generate')} to update task files\n` + + `${chalk.cyan('2.')} Run ${chalk.yellow('task-master expand --id=' + newTaskId)} to break it down into subtasks\n` + + `${chalk.cyan('3.')} Run ${chalk.yellow('task-master list --with-subtasks')} to see all tasks`, + { + padding: 1, + borderColor: 'cyan', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + + return newTaskId; + } catch (error) { + if (streamingInterval) clearInterval(streamingInterval); + stopLoadingIndicator(loadingIndicator); + log('error', 'Error generating task:', error.message); + process.exit(1); + } } /** @@ -2180,42 +2690,59 @@ async function addTask(tasksPath, prompt, dependencies = [], priority = 'medium' * @param {Object} options Command options */ async function analyzeTaskComplexity(options) { - const tasksPath = options.file || 'tasks/tasks.json'; - const outputPath = options.output || 'scripts/task-complexity-report.json'; - const modelOverride = options.model; - const thresholdScore = parseFloat(options.threshold || '5'); - const useResearch = options.research || false; - - console.log(chalk.blue(`Analyzing task complexity and generating expansion recommendations...`)); - - try { - // Read tasks.json - console.log(chalk.blue(`Reading tasks from ${tasksPath}...`)); - const tasksData = readJSON(tasksPath); - - if (!tasksData || !tasksData.tasks || !Array.isArray(tasksData.tasks) || tasksData.tasks.length === 0) { - throw new Error('No tasks found in the tasks file'); - } - - console.log(chalk.blue(`Found ${tasksData.tasks.length} tasks to analyze.`)); - - // Prepare the prompt for the LLM - const prompt = generateComplexityAnalysisPrompt(tasksData); - - // Start loading indicator - const loadingIndicator = startLoadingIndicator('Calling AI to analyze task complexity...'); - - let fullResponse = ''; - let streamingInterval = null; - - try { - // If research flag is set, use Perplexity first - if (useResearch) { - try { - console.log(chalk.blue('Using Perplexity AI for research-backed complexity analysis...')); - - // Modify prompt to include more context for Perplexity and explicitly request JSON - const researchPrompt = `You are conducting a detailed analysis of software development tasks to determine their complexity and how they should be broken down into subtasks. + const tasksPath = options.file || 'tasks/tasks.json'; + const outputPath = options.output || 'scripts/task-complexity-report.json'; + const modelOverride = options.model; + const thresholdScore = parseFloat(options.threshold || '5'); + const useResearch = options.research || false; + + console.log( + chalk.blue( + `Analyzing task complexity and generating expansion recommendations...` + ) + ); + + try { + // Read tasks.json + console.log(chalk.blue(`Reading tasks from ${tasksPath}...`)); + const tasksData = readJSON(tasksPath); + + if ( + !tasksData || + !tasksData.tasks || + !Array.isArray(tasksData.tasks) || + tasksData.tasks.length === 0 + ) { + throw new Error('No tasks found in the tasks file'); + } + + console.log( + chalk.blue(`Found ${tasksData.tasks.length} tasks to analyze.`) + ); + + // Prepare the prompt for the LLM + const prompt = generateComplexityAnalysisPrompt(tasksData); + + // Start loading indicator + const loadingIndicator = startLoadingIndicator( + 'Calling AI to analyze task complexity...' + ); + + let fullResponse = ''; + let streamingInterval = null; + + try { + // If research flag is set, use Perplexity first + if (useResearch) { + try { + console.log( + chalk.blue( + 'Using Perplexity AI for research-backed complexity analysis...' + ) + ); + + // Modify prompt to include more context for Perplexity and explicitly request JSON + const researchPrompt = `You are conducting a detailed analysis of software development tasks to determine their complexity and how they should be broken down into subtasks. Please research each task thoroughly, considering best practices, industry standards, and potential implementation challenges before providing your analysis. @@ -2237,241 +2764,324 @@ Your response must be a clean JSON array only, following exactly this format: ] DO NOT include any text before or after the JSON array. No explanations, no markdown formatting.`; - - const result = await perplexity.chat.completions.create({ - model: process.env.PERPLEXITY_MODEL || 'sonar-pro', - messages: [ - { - role: "system", - content: "You are a technical analysis AI that only responds with clean, valid JSON. Never include explanatory text or markdown formatting in your response." - }, - { - role: "user", - content: researchPrompt - } - ], - temperature: CONFIG.temperature, - max_tokens: CONFIG.maxTokens, - }); - - // Extract the response text - fullResponse = result.choices[0].message.content; - console.log(chalk.green('Successfully generated complexity analysis with Perplexity AI')); - - if (streamingInterval) clearInterval(streamingInterval); - stopLoadingIndicator(loadingIndicator); - - // ALWAYS log the first part of the response for debugging - console.log(chalk.gray('Response first 200 chars:')); - console.log(chalk.gray(fullResponse.substring(0, 200))); - } catch (perplexityError) { - console.log(chalk.yellow('Falling back to Claude for complexity analysis...')); - console.log(chalk.gray('Perplexity error:'), perplexityError.message); - - // Continue to Claude as fallback - await useClaudeForComplexityAnalysis(); - } - } else { - // Use Claude directly if research flag is not set - await useClaudeForComplexityAnalysis(); - } - - // Helper function to use Claude for complexity analysis - async function useClaudeForComplexityAnalysis() { - // Call the LLM API with streaming - const stream = await anthropic.messages.create({ - max_tokens: CONFIG.maxTokens, - model: modelOverride || CONFIG.model, - temperature: CONFIG.temperature, - messages: [{ role: "user", content: prompt }], - system: "You are an expert software architect and project manager analyzing task complexity. Respond only with valid JSON.", - stream: true - }); - - // Update loading indicator to show streaming progress - let dotCount = 0; - streamingInterval = setInterval(() => { - readline.cursorTo(process.stdout, 0); - process.stdout.write(`Receiving streaming response from Claude${'.'.repeat(dotCount)}`); - dotCount = (dotCount + 1) % 4; - }, 500); - - // Process the stream - for await (const chunk of stream) { - if (chunk.type === 'content_block_delta' && chunk.delta.text) { - fullResponse += chunk.delta.text; - } - } - - clearInterval(streamingInterval); - stopLoadingIndicator(loadingIndicator); - - console.log(chalk.green("Completed streaming response from Claude API!")); - } - - // Parse the JSON response - console.log(chalk.blue(`Parsing complexity analysis...`)); - let complexityAnalysis; - try { - // Clean up the response to ensure it's valid JSON - let cleanedResponse = fullResponse; - - // First check for JSON code blocks (common in markdown responses) - const codeBlockMatch = fullResponse.match(/```(?:json)?\s*([\s\S]*?)\s*```/); - if (codeBlockMatch) { - cleanedResponse = codeBlockMatch[1]; - console.log(chalk.blue("Extracted JSON from code block")); - } else { - // Look for a complete JSON array pattern - // This regex looks for an array of objects starting with [ and ending with ] - const jsonArrayMatch = fullResponse.match(/(\[\s*\{\s*"[^"]*"\s*:[\s\S]*\}\s*\])/); - if (jsonArrayMatch) { - cleanedResponse = jsonArrayMatch[1]; - console.log(chalk.blue("Extracted JSON array pattern")); - } else { - // Try to find the start of a JSON array and capture to the end - const jsonStartMatch = fullResponse.match(/(\[\s*\{[\s\S]*)/); - if (jsonStartMatch) { - cleanedResponse = jsonStartMatch[1]; - // Try to find a proper closing to the array - const properEndMatch = cleanedResponse.match(/([\s\S]*\}\s*\])/); - if (properEndMatch) { - cleanedResponse = properEndMatch[1]; - } - console.log(chalk.blue("Extracted JSON from start of array to end")); - } - } - } - - // Log the cleaned response for debugging - console.log(chalk.gray("Attempting to parse cleaned JSON...")); - console.log(chalk.gray("Cleaned response (first 100 chars):")); - console.log(chalk.gray(cleanedResponse.substring(0, 100))); - console.log(chalk.gray("Last 100 chars:")); - console.log(chalk.gray(cleanedResponse.substring(cleanedResponse.length - 100))); - - // More aggressive cleaning - strip any non-JSON content at the beginning or end - const strictArrayMatch = cleanedResponse.match(/(\[\s*\{[\s\S]*\}\s*\])/); - if (strictArrayMatch) { - cleanedResponse = strictArrayMatch[1]; - console.log(chalk.blue("Applied strict JSON array extraction")); - } - - try { - complexityAnalysis = JSON.parse(cleanedResponse); - } catch (jsonError) { - console.log(chalk.yellow("Initial JSON parsing failed, attempting to fix common JSON issues...")); - - // Try to fix common JSON issues - // 1. Remove any trailing commas in arrays or objects - cleanedResponse = cleanedResponse.replace(/,(\s*[\]}])/g, '$1'); - - // 2. Ensure property names are double-quoted - cleanedResponse = cleanedResponse.replace(/(\s*)(\w+)(\s*):(\s*)/g, '$1"$2"$3:$4'); - - // 3. Replace single quotes with double quotes for property values - cleanedResponse = cleanedResponse.replace(/:(\s*)'([^']*)'(\s*[,}])/g, ':$1"$2"$3'); - - // 4. Fix unterminated strings - common with LLM responses - const untermStringPattern = /:(\s*)"([^"]*)(?=[,}])/g; - cleanedResponse = cleanedResponse.replace(untermStringPattern, ':$1"$2"'); - - // 5. Fix multi-line strings by replacing newlines - cleanedResponse = cleanedResponse.replace(/:(\s*)"([^"]*)\n([^"]*)"/g, ':$1"$2 $3"'); - - try { - complexityAnalysis = JSON.parse(cleanedResponse); - console.log(chalk.green("Successfully parsed JSON after fixing common issues")); - } catch (fixedJsonError) { - console.log(chalk.red("Failed to parse JSON even after fixes, attempting more aggressive cleanup...")); - - // Try to extract and process each task individually - try { - const taskMatches = cleanedResponse.match(/\{\s*"taskId"\s*:\s*(\d+)[^}]*\}/g); - if (taskMatches && taskMatches.length > 0) { - console.log(chalk.yellow(`Found ${taskMatches.length} task objects, attempting to process individually`)); - - complexityAnalysis = []; - for (const taskMatch of taskMatches) { - try { - // Try to parse each task object individually - const fixedTask = taskMatch.replace(/,\s*$/, ''); // Remove trailing commas - const taskObj = JSON.parse(`${fixedTask}`); - if (taskObj && taskObj.taskId) { - complexityAnalysis.push(taskObj); - } - } catch (taskParseError) { - console.log(chalk.yellow(`Could not parse individual task: ${taskMatch.substring(0, 30)}...`)); - } - } - - if (complexityAnalysis.length > 0) { - console.log(chalk.green(`Successfully parsed ${complexityAnalysis.length} tasks individually`)); - } else { - throw new Error("Could not parse any tasks individually"); - } - } else { - throw fixedJsonError; - } - } catch (individualError) { - console.log(chalk.red("All parsing attempts failed")); - throw jsonError; // throw the original error - } - } - } - - // Ensure complexityAnalysis is an array - if (!Array.isArray(complexityAnalysis)) { - console.log(chalk.yellow('Response is not an array, checking if it contains an array property...')); - - // Handle the case where the response might be an object with an array property - if (complexityAnalysis.tasks || complexityAnalysis.analysis || complexityAnalysis.results) { - complexityAnalysis = complexityAnalysis.tasks || complexityAnalysis.analysis || complexityAnalysis.results; - } else { - // If no recognizable array property, wrap it as an array if it's an object - if (typeof complexityAnalysis === 'object' && complexityAnalysis !== null) { - console.log(chalk.yellow('Converting object to array...')); - complexityAnalysis = [complexityAnalysis]; - } else { - throw new Error('Response does not contain a valid array or object'); - } - } - } - - // Final check to ensure we have an array - if (!Array.isArray(complexityAnalysis)) { - throw new Error('Failed to extract an array from the response'); - } - - // Check that we have an analysis for each task in the input file - const taskIds = tasksData.tasks.map(t => t.id); - const analysisTaskIds = complexityAnalysis.map(a => a.taskId); - const missingTaskIds = taskIds.filter(id => !analysisTaskIds.includes(id)); - if (missingTaskIds.length > 0) { - console.log(chalk.yellow(`Missing analysis for ${missingTaskIds.length} tasks: ${missingTaskIds.join(', ')}`)); - console.log(chalk.blue(`Attempting to analyze missing tasks...`)); - - // Create a subset of tasksData with just the missing tasks - const missingTasks = { - meta: tasksData.meta, - tasks: tasksData.tasks.filter(t => missingTaskIds.includes(t.id)) - }; - - // Generate a prompt for just the missing tasks - const missingTasksPrompt = generateComplexityAnalysisPrompt(missingTasks); - - // Call the same AI model to analyze the missing tasks - let missingAnalysisResponse = ''; - - try { - // Start a new loading indicator - const missingTasksLoadingIndicator = startLoadingIndicator('Analyzing missing tasks...'); - - // Use the same AI model as the original analysis - if (useResearch) { - // Create the same research prompt but for missing tasks - const missingTasksResearchPrompt = `You are conducting a detailed analysis of software development tasks to determine their complexity and how they should be broken down into subtasks. + const result = await perplexity.chat.completions.create({ + model: process.env.PERPLEXITY_MODEL || 'sonar-pro', + messages: [ + { + role: 'system', + content: + 'You are a technical analysis AI that only responds with clean, valid JSON. Never include explanatory text or markdown formatting in your response.' + }, + { + role: 'user', + content: researchPrompt + } + ], + temperature: CONFIG.temperature, + max_tokens: CONFIG.maxTokens + }); + + // Extract the response text + fullResponse = result.choices[0].message.content; + console.log( + chalk.green( + 'Successfully generated complexity analysis with Perplexity AI' + ) + ); + + if (streamingInterval) clearInterval(streamingInterval); + stopLoadingIndicator(loadingIndicator); + + // ALWAYS log the first part of the response for debugging + console.log(chalk.gray('Response first 200 chars:')); + console.log(chalk.gray(fullResponse.substring(0, 200))); + } catch (perplexityError) { + console.log( + chalk.yellow('Falling back to Claude for complexity analysis...') + ); + console.log(chalk.gray('Perplexity error:'), perplexityError.message); + + // Continue to Claude as fallback + await useClaudeForComplexityAnalysis(); + } + } else { + // Use Claude directly if research flag is not set + await useClaudeForComplexityAnalysis(); + } + + // Helper function to use Claude for complexity analysis + async function useClaudeForComplexityAnalysis() { + // Call the LLM API with streaming + const stream = await anthropic.messages.create({ + max_tokens: CONFIG.maxTokens, + model: modelOverride || CONFIG.model, + temperature: CONFIG.temperature, + messages: [{ role: 'user', content: prompt }], + system: + 'You are an expert software architect and project manager analyzing task complexity. Respond only with valid JSON.', + stream: true + }); + + // Update loading indicator to show streaming progress + let dotCount = 0; + streamingInterval = setInterval(() => { + readline.cursorTo(process.stdout, 0); + process.stdout.write( + `Receiving streaming response from Claude${'.'.repeat(dotCount)}` + ); + dotCount = (dotCount + 1) % 4; + }, 500); + + // Process the stream + for await (const chunk of stream) { + if (chunk.type === 'content_block_delta' && chunk.delta.text) { + fullResponse += chunk.delta.text; + } + } + + clearInterval(streamingInterval); + stopLoadingIndicator(loadingIndicator); + + console.log( + chalk.green('Completed streaming response from Claude API!') + ); + } + + // Parse the JSON response + console.log(chalk.blue(`Parsing complexity analysis...`)); + let complexityAnalysis; + try { + // Clean up the response to ensure it's valid JSON + let cleanedResponse = fullResponse; + + // First check for JSON code blocks (common in markdown responses) + const codeBlockMatch = fullResponse.match( + /```(?:json)?\s*([\s\S]*?)\s*```/ + ); + if (codeBlockMatch) { + cleanedResponse = codeBlockMatch[1]; + console.log(chalk.blue('Extracted JSON from code block')); + } else { + // Look for a complete JSON array pattern + // This regex looks for an array of objects starting with [ and ending with ] + const jsonArrayMatch = fullResponse.match( + /(\[\s*\{\s*"[^"]*"\s*:[\s\S]*\}\s*\])/ + ); + if (jsonArrayMatch) { + cleanedResponse = jsonArrayMatch[1]; + console.log(chalk.blue('Extracted JSON array pattern')); + } else { + // Try to find the start of a JSON array and capture to the end + const jsonStartMatch = fullResponse.match(/(\[\s*\{[\s\S]*)/); + if (jsonStartMatch) { + cleanedResponse = jsonStartMatch[1]; + // Try to find a proper closing to the array + const properEndMatch = cleanedResponse.match(/([\s\S]*\}\s*\])/); + if (properEndMatch) { + cleanedResponse = properEndMatch[1]; + } + console.log( + chalk.blue('Extracted JSON from start of array to end') + ); + } + } + } + + // Log the cleaned response for debugging + console.log(chalk.gray('Attempting to parse cleaned JSON...')); + console.log(chalk.gray('Cleaned response (first 100 chars):')); + console.log(chalk.gray(cleanedResponse.substring(0, 100))); + console.log(chalk.gray('Last 100 chars:')); + console.log( + chalk.gray(cleanedResponse.substring(cleanedResponse.length - 100)) + ); + + // More aggressive cleaning - strip any non-JSON content at the beginning or end + const strictArrayMatch = cleanedResponse.match( + /(\[\s*\{[\s\S]*\}\s*\])/ + ); + if (strictArrayMatch) { + cleanedResponse = strictArrayMatch[1]; + console.log(chalk.blue('Applied strict JSON array extraction')); + } + + try { + complexityAnalysis = JSON.parse(cleanedResponse); + } catch (jsonError) { + console.log( + chalk.yellow( + 'Initial JSON parsing failed, attempting to fix common JSON issues...' + ) + ); + + // Try to fix common JSON issues + // 1. Remove any trailing commas in arrays or objects + cleanedResponse = cleanedResponse.replace(/,(\s*[\]}])/g, '$1'); + + // 2. Ensure property names are double-quoted + cleanedResponse = cleanedResponse.replace( + /(\s*)(\w+)(\s*):(\s*)/g, + '$1"$2"$3:$4' + ); + + // 3. Replace single quotes with double quotes for property values + cleanedResponse = cleanedResponse.replace( + /:(\s*)'([^']*)'(\s*[,}])/g, + ':$1"$2"$3' + ); + + // 4. Fix unterminated strings - common with LLM responses + const untermStringPattern = /:(\s*)"([^"]*)(?=[,}])/g; + cleanedResponse = cleanedResponse.replace( + untermStringPattern, + ':$1"$2"' + ); + + // 5. Fix multi-line strings by replacing newlines + cleanedResponse = cleanedResponse.replace( + /:(\s*)"([^"]*)\n([^"]*)"/g, + ':$1"$2 $3"' + ); + + try { + complexityAnalysis = JSON.parse(cleanedResponse); + console.log( + chalk.green('Successfully parsed JSON after fixing common issues') + ); + } catch (fixedJsonError) { + console.log( + chalk.red( + 'Failed to parse JSON even after fixes, attempting more aggressive cleanup...' + ) + ); + + // Try to extract and process each task individually + try { + const taskMatches = cleanedResponse.match( + /\{\s*"taskId"\s*:\s*(\d+)[^}]*\}/g + ); + if (taskMatches && taskMatches.length > 0) { + console.log( + chalk.yellow( + `Found ${taskMatches.length} task objects, attempting to process individually` + ) + ); + + complexityAnalysis = []; + for (const taskMatch of taskMatches) { + try { + // Try to parse each task object individually + const fixedTask = taskMatch.replace(/,\s*$/, ''); // Remove trailing commas + const taskObj = JSON.parse(`${fixedTask}`); + if (taskObj && taskObj.taskId) { + complexityAnalysis.push(taskObj); + } + } catch (taskParseError) { + console.log( + chalk.yellow( + `Could not parse individual task: ${taskMatch.substring(0, 30)}...` + ) + ); + } + } + + if (complexityAnalysis.length > 0) { + console.log( + chalk.green( + `Successfully parsed ${complexityAnalysis.length} tasks individually` + ) + ); + } else { + throw new Error('Could not parse any tasks individually'); + } + } else { + throw fixedJsonError; + } + } catch (individualError) { + console.log(chalk.red('All parsing attempts failed')); + throw jsonError; // throw the original error + } + } + } + + // Ensure complexityAnalysis is an array + if (!Array.isArray(complexityAnalysis)) { + console.log( + chalk.yellow( + 'Response is not an array, checking if it contains an array property...' + ) + ); + + // Handle the case where the response might be an object with an array property + if ( + complexityAnalysis.tasks || + complexityAnalysis.analysis || + complexityAnalysis.results + ) { + complexityAnalysis = + complexityAnalysis.tasks || + complexityAnalysis.analysis || + complexityAnalysis.results; + } else { + // If no recognizable array property, wrap it as an array if it's an object + if ( + typeof complexityAnalysis === 'object' && + complexityAnalysis !== null + ) { + console.log(chalk.yellow('Converting object to array...')); + complexityAnalysis = [complexityAnalysis]; + } else { + throw new Error( + 'Response does not contain a valid array or object' + ); + } + } + } + + // Final check to ensure we have an array + if (!Array.isArray(complexityAnalysis)) { + throw new Error('Failed to extract an array from the response'); + } + + // Check that we have an analysis for each task in the input file + const taskIds = tasksData.tasks.map((t) => t.id); + const analysisTaskIds = complexityAnalysis.map((a) => a.taskId); + const missingTaskIds = taskIds.filter( + (id) => !analysisTaskIds.includes(id) + ); + + if (missingTaskIds.length > 0) { + console.log( + chalk.yellow( + `Missing analysis for ${missingTaskIds.length} tasks: ${missingTaskIds.join(', ')}` + ) + ); + console.log(chalk.blue(`Attempting to analyze missing tasks...`)); + + // Create a subset of tasksData with just the missing tasks + const missingTasks = { + meta: tasksData.meta, + tasks: tasksData.tasks.filter((t) => missingTaskIds.includes(t.id)) + }; + + // Generate a prompt for just the missing tasks + const missingTasksPrompt = + generateComplexityAnalysisPrompt(missingTasks); + + // Call the same AI model to analyze the missing tasks + let missingAnalysisResponse = ''; + + try { + // Start a new loading indicator + const missingTasksLoadingIndicator = startLoadingIndicator( + 'Analyzing missing tasks...' + ); + + // Use the same AI model as the original analysis + if (useResearch) { + // Create the same research prompt but for missing tasks + const missingTasksResearchPrompt = `You are conducting a detailed analysis of software development tasks to determine their complexity and how they should be broken down into subtasks. Please research each task thoroughly, considering best practices, industry standards, and potential implementation challenges before providing your analysis. @@ -2494,212 +3104,297 @@ Your response must be a clean JSON array only, following exactly this format: DO NOT include any text before or after the JSON array. No explanations, no markdown formatting.`; - const result = await perplexity.chat.completions.create({ - model: process.env.PERPLEXITY_MODEL || 'sonar-pro', - messages: [ - { - role: "system", - content: "You are a technical analysis AI that only responds with clean, valid JSON. Never include explanatory text or markdown formatting in your response." - }, - { - role: "user", - content: missingTasksResearchPrompt - } - ], - temperature: CONFIG.temperature, - max_tokens: CONFIG.maxTokens, - }); - - // Extract the response - missingAnalysisResponse = result.choices[0].message.content; - } else { - // Use Claude - const stream = await anthropic.messages.create({ - max_tokens: CONFIG.maxTokens, - model: modelOverride || CONFIG.model, - temperature: CONFIG.temperature, - messages: [{ role: "user", content: missingTasksPrompt }], - system: "You are an expert software architect and project manager analyzing task complexity. Respond only with valid JSON.", - stream: true - }); - - // Process the stream - for await (const chunk of stream) { - if (chunk.type === 'content_block_delta' && chunk.delta.text) { - missingAnalysisResponse += chunk.delta.text; - } - } - } - - // Stop the loading indicator - stopLoadingIndicator(missingTasksLoadingIndicator); - - // Parse the response using the same parsing logic as before - let missingAnalysis; - try { - // Clean up the response to ensure it's valid JSON (using same logic as above) - let cleanedResponse = missingAnalysisResponse; - - // Use the same JSON extraction logic as before - // ... (code omitted for brevity, it would be the same as the original parsing) - - // First check for JSON code blocks - const codeBlockMatch = missingAnalysisResponse.match(/```(?:json)?\s*([\s\S]*?)\s*```/); - if (codeBlockMatch) { - cleanedResponse = codeBlockMatch[1]; - console.log(chalk.blue("Extracted JSON from code block for missing tasks")); - } else { - // Look for a complete JSON array pattern - const jsonArrayMatch = missingAnalysisResponse.match(/(\[\s*\{\s*"[^"]*"\s*:[\s\S]*\}\s*\])/); - if (jsonArrayMatch) { - cleanedResponse = jsonArrayMatch[1]; - console.log(chalk.blue("Extracted JSON array pattern for missing tasks")); - } else { - // Try to find the start of a JSON array and capture to the end - const jsonStartMatch = missingAnalysisResponse.match(/(\[\s*\{[\s\S]*)/); - if (jsonStartMatch) { - cleanedResponse = jsonStartMatch[1]; - // Try to find a proper closing to the array - const properEndMatch = cleanedResponse.match(/([\s\S]*\}\s*\])/); - if (properEndMatch) { - cleanedResponse = properEndMatch[1]; - } - console.log(chalk.blue("Extracted JSON from start of array to end for missing tasks")); - } - } - } - - // More aggressive cleaning if needed - const strictArrayMatch = cleanedResponse.match(/(\[\s*\{[\s\S]*\}\s*\])/); - if (strictArrayMatch) { - cleanedResponse = strictArrayMatch[1]; - console.log(chalk.blue("Applied strict JSON array extraction for missing tasks")); - } - - try { - missingAnalysis = JSON.parse(cleanedResponse); - } catch (jsonError) { - // Try to fix common JSON issues (same as before) - cleanedResponse = cleanedResponse.replace(/,(\s*[\]}])/g, '$1'); - cleanedResponse = cleanedResponse.replace(/(\s*)(\w+)(\s*):(\s*)/g, '$1"$2"$3:$4'); - cleanedResponse = cleanedResponse.replace(/:(\s*)'([^']*)'(\s*[,}])/g, ':$1"$2"$3'); - - try { - missingAnalysis = JSON.parse(cleanedResponse); - console.log(chalk.green("Successfully parsed JSON for missing tasks after fixing common issues")); - } catch (fixedJsonError) { - // Try the individual task extraction as a last resort - console.log(chalk.red("Failed to parse JSON for missing tasks, attempting individual extraction...")); - - const taskMatches = cleanedResponse.match(/\{\s*"taskId"\s*:\s*(\d+)[^}]*\}/g); - if (taskMatches && taskMatches.length > 0) { - console.log(chalk.yellow(`Found ${taskMatches.length} task objects, attempting to process individually`)); - - missingAnalysis = []; - for (const taskMatch of taskMatches) { - try { - const fixedTask = taskMatch.replace(/,\s*$/, ''); - const taskObj = JSON.parse(`${fixedTask}`); - if (taskObj && taskObj.taskId) { - missingAnalysis.push(taskObj); - } - } catch (taskParseError) { - console.log(chalk.yellow(`Could not parse individual task: ${taskMatch.substring(0, 30)}...`)); - } - } - - if (missingAnalysis.length === 0) { - throw new Error("Could not parse any missing tasks"); - } - } else { - throw fixedJsonError; - } - } - } - - // Ensure it's an array - if (!Array.isArray(missingAnalysis)) { - if (missingAnalysis && typeof missingAnalysis === 'object') { - missingAnalysis = [missingAnalysis]; - } else { - throw new Error("Missing tasks analysis is not an array or object"); - } - } - - // Add the missing analyses to the main analysis array - console.log(chalk.green(`Successfully analyzed ${missingAnalysis.length} missing tasks`)); - complexityAnalysis = [...complexityAnalysis, ...missingAnalysis]; - - // Re-check for missing tasks - const updatedAnalysisTaskIds = complexityAnalysis.map(a => a.taskId); - const stillMissingTaskIds = taskIds.filter(id => !updatedAnalysisTaskIds.includes(id)); - - if (stillMissingTaskIds.length > 0) { - console.log(chalk.yellow(`Warning: Still missing analysis for ${stillMissingTaskIds.length} tasks: ${stillMissingTaskIds.join(', ')}`)); - } else { - console.log(chalk.green(`All tasks now have complexity analysis!`)); - } - } catch (error) { - console.error(chalk.red(`Error analyzing missing tasks: ${error.message}`)); - console.log(chalk.yellow(`Continuing with partial analysis...`)); - } - } catch (error) { - console.error(chalk.red(`Error during retry for missing tasks: ${error.message}`)); - console.log(chalk.yellow(`Continuing with partial analysis...`)); - } - } - } catch (error) { - console.error(chalk.red(`Failed to parse LLM response as JSON: ${error.message}`)); - if (CONFIG.debug) { - console.debug(chalk.gray(`Raw response: ${fullResponse}`)); - } - throw new Error('Invalid response format from LLM. Expected JSON.'); - } - - // Create the final report - const report = { - meta: { - generatedAt: new Date().toISOString(), - tasksAnalyzed: tasksData.tasks.length, - thresholdScore: thresholdScore, - projectName: tasksData.meta?.projectName || 'Your Project Name', - usedResearch: useResearch - }, - complexityAnalysis: complexityAnalysis - }; - - // Write the report to file - console.log(chalk.blue(`Writing complexity report to ${outputPath}...`)); - writeJSON(outputPath, report); - - console.log(chalk.green(`Task complexity analysis complete. Report written to ${outputPath}`)); - - // Display a summary of findings - const highComplexity = complexityAnalysis.filter(t => t.complexityScore >= 8).length; - const mediumComplexity = complexityAnalysis.filter(t => t.complexityScore >= 5 && t.complexityScore < 8).length; - const lowComplexity = complexityAnalysis.filter(t => t.complexityScore < 5).length; - const totalAnalyzed = complexityAnalysis.length; - - console.log('\nComplexity Analysis Summary:'); - console.log('----------------------------'); - console.log(`Tasks in input file: ${tasksData.tasks.length}`); - console.log(`Tasks successfully analyzed: ${totalAnalyzed}`); - console.log(`High complexity tasks: ${highComplexity}`); - console.log(`Medium complexity tasks: ${mediumComplexity}`); - console.log(`Low complexity tasks: ${lowComplexity}`); - console.log(`Sum verification: ${highComplexity + mediumComplexity + lowComplexity} (should equal ${totalAnalyzed})`); - console.log(`Research-backed analysis: ${useResearch ? 'Yes' : 'No'}`); - console.log(`\nSee ${outputPath} for the full report and expansion commands.`); - - } catch (error) { - if (streamingInterval) clearInterval(streamingInterval); - stopLoadingIndicator(loadingIndicator); - throw error; - } - } catch (error) { - console.error(chalk.red(`Error analyzing task complexity: ${error.message}`)); - process.exit(1); - } + const result = await perplexity.chat.completions.create({ + model: process.env.PERPLEXITY_MODEL || 'sonar-pro', + messages: [ + { + role: 'system', + content: + 'You are a technical analysis AI that only responds with clean, valid JSON. Never include explanatory text or markdown formatting in your response.' + }, + { + role: 'user', + content: missingTasksResearchPrompt + } + ], + temperature: CONFIG.temperature, + max_tokens: CONFIG.maxTokens + }); + + // Extract the response + missingAnalysisResponse = result.choices[0].message.content; + } else { + // Use Claude + const stream = await anthropic.messages.create({ + max_tokens: CONFIG.maxTokens, + model: modelOverride || CONFIG.model, + temperature: CONFIG.temperature, + messages: [{ role: 'user', content: missingTasksPrompt }], + system: + 'You are an expert software architect and project manager analyzing task complexity. Respond only with valid JSON.', + stream: true + }); + + // Process the stream + for await (const chunk of stream) { + if (chunk.type === 'content_block_delta' && chunk.delta.text) { + missingAnalysisResponse += chunk.delta.text; + } + } + } + + // Stop the loading indicator + stopLoadingIndicator(missingTasksLoadingIndicator); + + // Parse the response using the same parsing logic as before + let missingAnalysis; + try { + // Clean up the response to ensure it's valid JSON (using same logic as above) + let cleanedResponse = missingAnalysisResponse; + + // Use the same JSON extraction logic as before + // ... (code omitted for brevity, it would be the same as the original parsing) + + // First check for JSON code blocks + const codeBlockMatch = missingAnalysisResponse.match( + /```(?:json)?\s*([\s\S]*?)\s*```/ + ); + if (codeBlockMatch) { + cleanedResponse = codeBlockMatch[1]; + console.log( + chalk.blue('Extracted JSON from code block for missing tasks') + ); + } else { + // Look for a complete JSON array pattern + const jsonArrayMatch = missingAnalysisResponse.match( + /(\[\s*\{\s*"[^"]*"\s*:[\s\S]*\}\s*\])/ + ); + if (jsonArrayMatch) { + cleanedResponse = jsonArrayMatch[1]; + console.log( + chalk.blue('Extracted JSON array pattern for missing tasks') + ); + } else { + // Try to find the start of a JSON array and capture to the end + const jsonStartMatch = + missingAnalysisResponse.match(/(\[\s*\{[\s\S]*)/); + if (jsonStartMatch) { + cleanedResponse = jsonStartMatch[1]; + // Try to find a proper closing to the array + const properEndMatch = + cleanedResponse.match(/([\s\S]*\}\s*\])/); + if (properEndMatch) { + cleanedResponse = properEndMatch[1]; + } + console.log( + chalk.blue( + 'Extracted JSON from start of array to end for missing tasks' + ) + ); + } + } + } + + // More aggressive cleaning if needed + const strictArrayMatch = cleanedResponse.match( + /(\[\s*\{[\s\S]*\}\s*\])/ + ); + if (strictArrayMatch) { + cleanedResponse = strictArrayMatch[1]; + console.log( + chalk.blue( + 'Applied strict JSON array extraction for missing tasks' + ) + ); + } + + try { + missingAnalysis = JSON.parse(cleanedResponse); + } catch (jsonError) { + // Try to fix common JSON issues (same as before) + cleanedResponse = cleanedResponse.replace(/,(\s*[\]}])/g, '$1'); + cleanedResponse = cleanedResponse.replace( + /(\s*)(\w+)(\s*):(\s*)/g, + '$1"$2"$3:$4' + ); + cleanedResponse = cleanedResponse.replace( + /:(\s*)'([^']*)'(\s*[,}])/g, + ':$1"$2"$3' + ); + + try { + missingAnalysis = JSON.parse(cleanedResponse); + console.log( + chalk.green( + 'Successfully parsed JSON for missing tasks after fixing common issues' + ) + ); + } catch (fixedJsonError) { + // Try the individual task extraction as a last resort + console.log( + chalk.red( + 'Failed to parse JSON for missing tasks, attempting individual extraction...' + ) + ); + + const taskMatches = cleanedResponse.match( + /\{\s*"taskId"\s*:\s*(\d+)[^}]*\}/g + ); + if (taskMatches && taskMatches.length > 0) { + console.log( + chalk.yellow( + `Found ${taskMatches.length} task objects, attempting to process individually` + ) + ); + + missingAnalysis = []; + for (const taskMatch of taskMatches) { + try { + const fixedTask = taskMatch.replace(/,\s*$/, ''); + const taskObj = JSON.parse(`${fixedTask}`); + if (taskObj && taskObj.taskId) { + missingAnalysis.push(taskObj); + } + } catch (taskParseError) { + console.log( + chalk.yellow( + `Could not parse individual task: ${taskMatch.substring(0, 30)}...` + ) + ); + } + } + + if (missingAnalysis.length === 0) { + throw new Error('Could not parse any missing tasks'); + } + } else { + throw fixedJsonError; + } + } + } + + // Ensure it's an array + if (!Array.isArray(missingAnalysis)) { + if (missingAnalysis && typeof missingAnalysis === 'object') { + missingAnalysis = [missingAnalysis]; + } else { + throw new Error( + 'Missing tasks analysis is not an array or object' + ); + } + } + + // Add the missing analyses to the main analysis array + console.log( + chalk.green( + `Successfully analyzed ${missingAnalysis.length} missing tasks` + ) + ); + complexityAnalysis = [...complexityAnalysis, ...missingAnalysis]; + + // Re-check for missing tasks + const updatedAnalysisTaskIds = complexityAnalysis.map( + (a) => a.taskId + ); + const stillMissingTaskIds = taskIds.filter( + (id) => !updatedAnalysisTaskIds.includes(id) + ); + + if (stillMissingTaskIds.length > 0) { + console.log( + chalk.yellow( + `Warning: Still missing analysis for ${stillMissingTaskIds.length} tasks: ${stillMissingTaskIds.join(', ')}` + ) + ); + } else { + console.log( + chalk.green(`All tasks now have complexity analysis!`) + ); + } + } catch (error) { + console.error( + chalk.red(`Error analyzing missing tasks: ${error.message}`) + ); + console.log(chalk.yellow(`Continuing with partial analysis...`)); + } + } catch (error) { + console.error( + chalk.red( + `Error during retry for missing tasks: ${error.message}` + ) + ); + console.log(chalk.yellow(`Continuing with partial analysis...`)); + } + } + } catch (error) { + console.error( + chalk.red(`Failed to parse LLM response as JSON: ${error.message}`) + ); + if (CONFIG.debug) { + console.debug(chalk.gray(`Raw response: ${fullResponse}`)); + } + throw new Error('Invalid response format from LLM. Expected JSON.'); + } + + // Create the final report + const report = { + meta: { + generatedAt: new Date().toISOString(), + tasksAnalyzed: tasksData.tasks.length, + thresholdScore: thresholdScore, + projectName: tasksData.meta?.projectName || 'Your Project Name', + usedResearch: useResearch + }, + complexityAnalysis: complexityAnalysis + }; + + // Write the report to file + console.log(chalk.blue(`Writing complexity report to ${outputPath}...`)); + writeJSON(outputPath, report); + + console.log( + chalk.green( + `Task complexity analysis complete. Report written to ${outputPath}` + ) + ); + + // Display a summary of findings + const highComplexity = complexityAnalysis.filter( + (t) => t.complexityScore >= 8 + ).length; + const mediumComplexity = complexityAnalysis.filter( + (t) => t.complexityScore >= 5 && t.complexityScore < 8 + ).length; + const lowComplexity = complexityAnalysis.filter( + (t) => t.complexityScore < 5 + ).length; + const totalAnalyzed = complexityAnalysis.length; + + console.log('\nComplexity Analysis Summary:'); + console.log('----------------------------'); + console.log(`Tasks in input file: ${tasksData.tasks.length}`); + console.log(`Tasks successfully analyzed: ${totalAnalyzed}`); + console.log(`High complexity tasks: ${highComplexity}`); + console.log(`Medium complexity tasks: ${mediumComplexity}`); + console.log(`Low complexity tasks: ${lowComplexity}`); + console.log( + `Sum verification: ${highComplexity + mediumComplexity + lowComplexity} (should equal ${totalAnalyzed})` + ); + console.log(`Research-backed analysis: ${useResearch ? 'Yes' : 'No'}`); + console.log( + `\nSee ${outputPath} for the full report and expansion commands.` + ); + } catch (error) { + if (streamingInterval) clearInterval(streamingInterval); + stopLoadingIndicator(loadingIndicator); + throw error; + } + } catch (error) { + console.error( + chalk.red(`Error analyzing task complexity: ${error.message}`) + ); + process.exit(1); + } } /** @@ -2708,49 +3403,54 @@ DO NOT include any text before or after the JSON array. No explanations, no mark * @returns {Object|null} The next task to work on or null if no eligible tasks */ function findNextTask(tasks) { - // Get all completed task IDs - const completedTaskIds = new Set( - tasks - .filter(t => t.status === 'done' || t.status === 'completed') - .map(t => t.id) - ); - - // Filter for pending tasks whose dependencies are all satisfied - const eligibleTasks = tasks.filter(task => - (task.status === 'pending' || task.status === 'in-progress') && - task.dependencies && // Make sure dependencies array exists - task.dependencies.every(depId => completedTaskIds.has(depId)) - ); - - if (eligibleTasks.length === 0) { - return null; - } - - // Sort eligible tasks by: - // 1. Priority (high > medium > low) - // 2. Dependencies count (fewer dependencies first) - // 3. ID (lower ID first) - const priorityValues = { 'high': 3, 'medium': 2, 'low': 1 }; - - const nextTask = eligibleTasks.sort((a, b) => { - // Sort by priority first - const priorityA = priorityValues[a.priority || 'medium'] || 2; - const priorityB = priorityValues[b.priority || 'medium'] || 2; - - if (priorityB !== priorityA) { - return priorityB - priorityA; // Higher priority first - } - - // If priority is the same, sort by dependency count - if (a.dependencies && b.dependencies && a.dependencies.length !== b.dependencies.length) { - return a.dependencies.length - b.dependencies.length; // Fewer dependencies first - } - - // If dependency count is the same, sort by ID - return a.id - b.id; // Lower ID first - })[0]; // Return the first (highest priority) task - - return nextTask; + // Get all completed task IDs + const completedTaskIds = new Set( + tasks + .filter((t) => t.status === 'done' || t.status === 'completed') + .map((t) => t.id) + ); + + // Filter for pending tasks whose dependencies are all satisfied + const eligibleTasks = tasks.filter( + (task) => + (task.status === 'pending' || task.status === 'in-progress') && + task.dependencies && // Make sure dependencies array exists + task.dependencies.every((depId) => completedTaskIds.has(depId)) + ); + + if (eligibleTasks.length === 0) { + return null; + } + + // Sort eligible tasks by: + // 1. Priority (high > medium > low) + // 2. Dependencies count (fewer dependencies first) + // 3. ID (lower ID first) + const priorityValues = { high: 3, medium: 2, low: 1 }; + + const nextTask = eligibleTasks.sort((a, b) => { + // Sort by priority first + const priorityA = priorityValues[a.priority || 'medium'] || 2; + const priorityB = priorityValues[b.priority || 'medium'] || 2; + + if (priorityB !== priorityA) { + return priorityB - priorityA; // Higher priority first + } + + // If priority is the same, sort by dependency count + if ( + a.dependencies && + b.dependencies && + a.dependencies.length !== b.dependencies.length + ) { + return a.dependencies.length - b.dependencies.length; // Fewer dependencies first + } + + // If dependency count is the same, sort by ID + return a.id - b.id; // Lower ID first + })[0]; // Return the first (highest priority) task + + return nextTask; } /** @@ -2762,118 +3462,141 @@ function findNextTask(tasks) { * @param {boolean} generateFiles - Whether to regenerate task files after adding the subtask * @returns {Object} The newly created or converted subtask */ -async function addSubtask(tasksPath, parentId, existingTaskId = null, newSubtaskData = null, generateFiles = true) { - try { - log('info', `Adding subtask to parent task ${parentId}...`); - - // Read the existing tasks - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - throw new Error(`Invalid or missing tasks file at ${tasksPath}`); - } - - // Convert parent ID to number - const parentIdNum = parseInt(parentId, 10); - - // Find the parent task - const parentTask = data.tasks.find(t => t.id === parentIdNum); - if (!parentTask) { - throw new Error(`Parent task with ID ${parentIdNum} not found`); - } - - // Initialize subtasks array if it doesn't exist - if (!parentTask.subtasks) { - parentTask.subtasks = []; - } - - let newSubtask; - - // Case 1: Convert an existing task to a subtask - if (existingTaskId !== null) { - const existingTaskIdNum = parseInt(existingTaskId, 10); - - // Find the existing task - const existingTaskIndex = data.tasks.findIndex(t => t.id === existingTaskIdNum); - if (existingTaskIndex === -1) { - throw new Error(`Task with ID ${existingTaskIdNum} not found`); - } - - const existingTask = data.tasks[existingTaskIndex]; - - // Check if task is already a subtask - if (existingTask.parentTaskId) { - throw new Error(`Task ${existingTaskIdNum} is already a subtask of task ${existingTask.parentTaskId}`); - } - - // Check for circular dependency - if (existingTaskIdNum === parentIdNum) { - throw new Error(`Cannot make a task a subtask of itself`); - } - - // Check if parent task is a subtask of the task we're converting - // This would create a circular dependency - if (isTaskDependentOn(data.tasks, parentTask, existingTaskIdNum)) { - throw new Error(`Cannot create circular dependency: task ${parentIdNum} is already a subtask or dependent of task ${existingTaskIdNum}`); - } - - // Find the highest subtask ID to determine the next ID - const highestSubtaskId = parentTask.subtasks.length > 0 - ? Math.max(...parentTask.subtasks.map(st => st.id)) - : 0; - const newSubtaskId = highestSubtaskId + 1; - - // Clone the existing task to be converted to a subtask - newSubtask = { ...existingTask, id: newSubtaskId, parentTaskId: parentIdNum }; - - // Add to parent's subtasks - parentTask.subtasks.push(newSubtask); - - // Remove the task from the main tasks array - data.tasks.splice(existingTaskIndex, 1); - - log('info', `Converted task ${existingTaskIdNum} to subtask ${parentIdNum}.${newSubtaskId}`); - } - // Case 2: Create a new subtask - else if (newSubtaskData) { - // Find the highest subtask ID to determine the next ID - const highestSubtaskId = parentTask.subtasks.length > 0 - ? Math.max(...parentTask.subtasks.map(st => st.id)) - : 0; - const newSubtaskId = highestSubtaskId + 1; - - // Create the new subtask object - newSubtask = { - id: newSubtaskId, - title: newSubtaskData.title, - description: newSubtaskData.description || '', - details: newSubtaskData.details || '', - status: newSubtaskData.status || 'pending', - dependencies: newSubtaskData.dependencies || [], - parentTaskId: parentIdNum - }; - - // Add to parent's subtasks - parentTask.subtasks.push(newSubtask); - - log('info', `Created new subtask ${parentIdNum}.${newSubtaskId}`); - } else { - throw new Error('Either existingTaskId or newSubtaskData must be provided'); - } - - // Write the updated tasks back to the file - writeJSON(tasksPath, data); - - // Generate task files if requested - if (generateFiles) { - log('info', 'Regenerating task files...'); - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); - } - - return newSubtask; - } catch (error) { - log('error', `Error adding subtask: ${error.message}`); - throw error; - } +async function addSubtask( + tasksPath, + parentId, + existingTaskId = null, + newSubtaskData = null, + generateFiles = true +) { + try { + log('info', `Adding subtask to parent task ${parentId}...`); + + // Read the existing tasks + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + throw new Error(`Invalid or missing tasks file at ${tasksPath}`); + } + + // Convert parent ID to number + const parentIdNum = parseInt(parentId, 10); + + // Find the parent task + const parentTask = data.tasks.find((t) => t.id === parentIdNum); + if (!parentTask) { + throw new Error(`Parent task with ID ${parentIdNum} not found`); + } + + // Initialize subtasks array if it doesn't exist + if (!parentTask.subtasks) { + parentTask.subtasks = []; + } + + let newSubtask; + + // Case 1: Convert an existing task to a subtask + if (existingTaskId !== null) { + const existingTaskIdNum = parseInt(existingTaskId, 10); + + // Find the existing task + const existingTaskIndex = data.tasks.findIndex( + (t) => t.id === existingTaskIdNum + ); + if (existingTaskIndex === -1) { + throw new Error(`Task with ID ${existingTaskIdNum} not found`); + } + + const existingTask = data.tasks[existingTaskIndex]; + + // Check if task is already a subtask + if (existingTask.parentTaskId) { + throw new Error( + `Task ${existingTaskIdNum} is already a subtask of task ${existingTask.parentTaskId}` + ); + } + + // Check for circular dependency + if (existingTaskIdNum === parentIdNum) { + throw new Error(`Cannot make a task a subtask of itself`); + } + + // Check if parent task is a subtask of the task we're converting + // This would create a circular dependency + if (isTaskDependentOn(data.tasks, parentTask, existingTaskIdNum)) { + throw new Error( + `Cannot create circular dependency: task ${parentIdNum} is already a subtask or dependent of task ${existingTaskIdNum}` + ); + } + + // Find the highest subtask ID to determine the next ID + const highestSubtaskId = + parentTask.subtasks.length > 0 + ? Math.max(...parentTask.subtasks.map((st) => st.id)) + : 0; + const newSubtaskId = highestSubtaskId + 1; + + // Clone the existing task to be converted to a subtask + newSubtask = { + ...existingTask, + id: newSubtaskId, + parentTaskId: parentIdNum + }; + + // Add to parent's subtasks + parentTask.subtasks.push(newSubtask); + + // Remove the task from the main tasks array + data.tasks.splice(existingTaskIndex, 1); + + log( + 'info', + `Converted task ${existingTaskIdNum} to subtask ${parentIdNum}.${newSubtaskId}` + ); + } + // Case 2: Create a new subtask + else if (newSubtaskData) { + // Find the highest subtask ID to determine the next ID + const highestSubtaskId = + parentTask.subtasks.length > 0 + ? Math.max(...parentTask.subtasks.map((st) => st.id)) + : 0; + const newSubtaskId = highestSubtaskId + 1; + + // Create the new subtask object + newSubtask = { + id: newSubtaskId, + title: newSubtaskData.title, + description: newSubtaskData.description || '', + details: newSubtaskData.details || '', + status: newSubtaskData.status || 'pending', + dependencies: newSubtaskData.dependencies || [], + parentTaskId: parentIdNum + }; + + // Add to parent's subtasks + parentTask.subtasks.push(newSubtask); + + log('info', `Created new subtask ${parentIdNum}.${newSubtaskId}`); + } else { + throw new Error( + 'Either existingTaskId or newSubtaskData must be provided' + ); + } + + // Write the updated tasks back to the file + writeJSON(tasksPath, data); + + // Generate task files if requested + if (generateFiles) { + log('info', 'Regenerating task files...'); + await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + } + + return newSubtask; + } catch (error) { + log('error', `Error adding subtask: ${error.message}`); + throw error; + } } /** @@ -2885,36 +3608,36 @@ async function addSubtask(tasksPath, parentId, existingTaskId = null, newSubtask * @returns {boolean} Whether the task depends on the target task */ function isTaskDependentOn(allTasks, task, targetTaskId) { - // If the task is a subtask, check if its parent is the target - if (task.parentTaskId === targetTaskId) { - return true; - } - - // Check direct dependencies - if (task.dependencies && task.dependencies.includes(targetTaskId)) { - return true; - } - - // Check dependencies of dependencies (recursive) - if (task.dependencies) { - for (const depId of task.dependencies) { - const depTask = allTasks.find(t => t.id === depId); - if (depTask && isTaskDependentOn(allTasks, depTask, targetTaskId)) { - return true; - } - } - } - - // Check subtasks for dependencies - if (task.subtasks) { - for (const subtask of task.subtasks) { - if (isTaskDependentOn(allTasks, subtask, targetTaskId)) { - return true; - } - } - } - - return false; + // If the task is a subtask, check if its parent is the target + if (task.parentTaskId === targetTaskId) { + return true; + } + + // Check direct dependencies + if (task.dependencies && task.dependencies.includes(targetTaskId)) { + return true; + } + + // Check dependencies of dependencies (recursive) + if (task.dependencies) { + for (const depId of task.dependencies) { + const depTask = allTasks.find((t) => t.id === depId); + if (depTask && isTaskDependentOn(allTasks, depTask, targetTaskId)) { + return true; + } + } + } + + // Check subtasks for dependencies + if (task.subtasks) { + for (const subtask of task.subtasks) { + if (isTaskDependentOn(allTasks, subtask, targetTaskId)) { + return true; + } + } + } + + return false; } /** @@ -2925,101 +3648,110 @@ function isTaskDependentOn(allTasks, task, targetTaskId) { * @param {boolean} generateFiles - Whether to regenerate task files after removing the subtask * @returns {Object|null} The removed subtask if convertToTask is true, otherwise null */ -async function removeSubtask(tasksPath, subtaskId, convertToTask = false, generateFiles = true) { - try { - log('info', `Removing subtask ${subtaskId}...`); - - // Read the existing tasks - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - throw new Error(`Invalid or missing tasks file at ${tasksPath}`); - } - - // Parse the subtask ID (format: "parentId.subtaskId") - if (!subtaskId.includes('.')) { - throw new Error(`Invalid subtask ID format: ${subtaskId}. Expected format: "parentId.subtaskId"`); - } - - const [parentIdStr, subtaskIdStr] = subtaskId.split('.'); - const parentId = parseInt(parentIdStr, 10); - const subtaskIdNum = parseInt(subtaskIdStr, 10); - - // Find the parent task - const parentTask = data.tasks.find(t => t.id === parentId); - if (!parentTask) { - throw new Error(`Parent task with ID ${parentId} not found`); - } - - // Check if parent has subtasks - if (!parentTask.subtasks || parentTask.subtasks.length === 0) { - throw new Error(`Parent task ${parentId} has no subtasks`); - } - - // Find the subtask to remove - const subtaskIndex = parentTask.subtasks.findIndex(st => st.id === subtaskIdNum); - if (subtaskIndex === -1) { - throw new Error(`Subtask ${subtaskId} not found`); - } - - // Get a copy of the subtask before removing it - const removedSubtask = { ...parentTask.subtasks[subtaskIndex] }; - - // Remove the subtask from the parent - parentTask.subtasks.splice(subtaskIndex, 1); - - // If parent has no more subtasks, remove the subtasks array - if (parentTask.subtasks.length === 0) { - delete parentTask.subtasks; - } - - let convertedTask = null; - - // Convert the subtask to a standalone task if requested - if (convertToTask) { - log('info', `Converting subtask ${subtaskId} to a standalone task...`); - - // Find the highest task ID to determine the next ID - const highestId = Math.max(...data.tasks.map(t => t.id)); - const newTaskId = highestId + 1; - - // Create the new task from the subtask - convertedTask = { - id: newTaskId, - title: removedSubtask.title, - description: removedSubtask.description || '', - details: removedSubtask.details || '', - status: removedSubtask.status || 'pending', - dependencies: removedSubtask.dependencies || [], - priority: parentTask.priority || 'medium' // Inherit priority from parent - }; - - // Add the parent task as a dependency if not already present - if (!convertedTask.dependencies.includes(parentId)) { - convertedTask.dependencies.push(parentId); - } - - // Add the converted task to the tasks array - data.tasks.push(convertedTask); - - log('info', `Created new task ${newTaskId} from subtask ${subtaskId}`); - } else { - log('info', `Subtask ${subtaskId} deleted`); - } - - // Write the updated tasks back to the file - writeJSON(tasksPath, data); - - // Generate task files if requested - if (generateFiles) { - log('info', 'Regenerating task files...'); - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); - } - - return convertedTask; - } catch (error) { - log('error', `Error removing subtask: ${error.message}`); - throw error; - } +async function removeSubtask( + tasksPath, + subtaskId, + convertToTask = false, + generateFiles = true +) { + try { + log('info', `Removing subtask ${subtaskId}...`); + + // Read the existing tasks + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + throw new Error(`Invalid or missing tasks file at ${tasksPath}`); + } + + // Parse the subtask ID (format: "parentId.subtaskId") + if (!subtaskId.includes('.')) { + throw new Error( + `Invalid subtask ID format: ${subtaskId}. Expected format: "parentId.subtaskId"` + ); + } + + const [parentIdStr, subtaskIdStr] = subtaskId.split('.'); + const parentId = parseInt(parentIdStr, 10); + const subtaskIdNum = parseInt(subtaskIdStr, 10); + + // Find the parent task + const parentTask = data.tasks.find((t) => t.id === parentId); + if (!parentTask) { + throw new Error(`Parent task with ID ${parentId} not found`); + } + + // Check if parent has subtasks + if (!parentTask.subtasks || parentTask.subtasks.length === 0) { + throw new Error(`Parent task ${parentId} has no subtasks`); + } + + // Find the subtask to remove + const subtaskIndex = parentTask.subtasks.findIndex( + (st) => st.id === subtaskIdNum + ); + if (subtaskIndex === -1) { + throw new Error(`Subtask ${subtaskId} not found`); + } + + // Get a copy of the subtask before removing it + const removedSubtask = { ...parentTask.subtasks[subtaskIndex] }; + + // Remove the subtask from the parent + parentTask.subtasks.splice(subtaskIndex, 1); + + // If parent has no more subtasks, remove the subtasks array + if (parentTask.subtasks.length === 0) { + delete parentTask.subtasks; + } + + let convertedTask = null; + + // Convert the subtask to a standalone task if requested + if (convertToTask) { + log('info', `Converting subtask ${subtaskId} to a standalone task...`); + + // Find the highest task ID to determine the next ID + const highestId = Math.max(...data.tasks.map((t) => t.id)); + const newTaskId = highestId + 1; + + // Create the new task from the subtask + convertedTask = { + id: newTaskId, + title: removedSubtask.title, + description: removedSubtask.description || '', + details: removedSubtask.details || '', + status: removedSubtask.status || 'pending', + dependencies: removedSubtask.dependencies || [], + priority: parentTask.priority || 'medium' // Inherit priority from parent + }; + + // Add the parent task as a dependency if not already present + if (!convertedTask.dependencies.includes(parentId)) { + convertedTask.dependencies.push(parentId); + } + + // Add the converted task to the tasks array + data.tasks.push(convertedTask); + + log('info', `Created new task ${newTaskId} from subtask ${subtaskId}`); + } else { + log('info', `Subtask ${subtaskId} deleted`); + } + + // Write the updated tasks back to the file + writeJSON(tasksPath, data); + + // Generate task files if requested + if (generateFiles) { + log('info', 'Regenerating task files...'); + await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + } + + return convertedTask; + } catch (error) { + log('error', `Error removing subtask: ${error.message}`); + throw error; + } } /** @@ -3030,367 +3762,484 @@ async function removeSubtask(tasksPath, subtaskId, convertToTask = false, genera * @param {boolean} useResearch - Whether to use Perplexity AI for research-backed updates * @returns {Object|null} - The updated subtask or null if update failed */ -async function updateSubtaskById(tasksPath, subtaskId, prompt, useResearch = false) { - let loadingIndicator = null; - try { - log('info', `Updating subtask ${subtaskId} with prompt: "${prompt}"`); - - // Validate subtask ID format - if (!subtaskId || typeof subtaskId !== 'string' || !subtaskId.includes('.')) { - throw new Error(`Invalid subtask ID format: ${subtaskId}. Subtask ID must be in format "parentId.subtaskId"`); - } - - // Validate prompt - if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') { - throw new Error('Prompt cannot be empty. Please provide context for the subtask update.'); - } - - // Prepare for fallback handling - let claudeOverloaded = false; - - // Validate tasks file exists - if (!fs.existsSync(tasksPath)) { - throw new Error(`Tasks file not found at path: ${tasksPath}`); - } - - // Read the tasks file - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - throw new Error(`No valid tasks found in ${tasksPath}. The file may be corrupted or have an invalid format.`); - } - - // Parse parent and subtask IDs - const [parentIdStr, subtaskIdStr] = subtaskId.split('.'); - const parentId = parseInt(parentIdStr, 10); - const subtaskIdNum = parseInt(subtaskIdStr, 10); - - if (isNaN(parentId) || parentId <= 0 || isNaN(subtaskIdNum) || subtaskIdNum <= 0) { - throw new Error(`Invalid subtask ID format: ${subtaskId}. Both parent ID and subtask ID must be positive integers.`); - } - - // Find the parent task - const parentTask = data.tasks.find(task => task.id === parentId); - if (!parentTask) { - throw new Error(`Parent task with ID ${parentId} not found. Please verify the task ID and try again.`); - } - - // Find the subtask - if (!parentTask.subtasks || !Array.isArray(parentTask.subtasks)) { - throw new Error(`Parent task ${parentId} has no subtasks.`); - } - - const subtask = parentTask.subtasks.find(st => st.id === subtaskIdNum); - if (!subtask) { - throw new Error(`Subtask with ID ${subtaskId} not found. Please verify the subtask ID and try again.`); - } - - // Check if subtask is already completed - if (subtask.status === 'done' || subtask.status === 'completed') { - log('warn', `Subtask ${subtaskId} is already marked as done and cannot be updated`); - console.log(boxen( - chalk.yellow(`Subtask ${subtaskId} is already marked as ${subtask.status} and cannot be updated.`) + '\n\n' + - chalk.white('Completed subtasks are locked to maintain consistency. To modify a completed subtask, you must first:') + '\n' + - chalk.white('1. Change its status to "pending" or "in-progress"') + '\n' + - chalk.white('2. Then run the update-subtask command'), - { padding: 1, borderColor: 'yellow', borderStyle: 'round' } - )); - return null; - } - - // Show the subtask that will be updated - const table = new Table({ - head: [ - chalk.cyan.bold('ID'), - chalk.cyan.bold('Title'), - chalk.cyan.bold('Status') - ], - colWidths: [10, 55, 10] - }); - - table.push([ - subtaskId, - truncate(subtask.title, 52), - getStatusWithColor(subtask.status) - ]); - - console.log(boxen( - chalk.white.bold(`Updating Subtask #${subtaskId}`), - { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 0 } } - )); - - console.log(table.toString()); - - // Start the loading indicator - loadingIndicator = startLoadingIndicator('Generating additional information with AI...'); +async function updateSubtaskById( + tasksPath, + subtaskId, + prompt, + useResearch = false +) { + let loadingIndicator = null; + try { + log('info', `Updating subtask ${subtaskId} with prompt: "${prompt}"`); - // Create the system prompt (as before) - const systemPrompt = `You are an AI assistant helping to update software development subtasks with additional information. + // Validate subtask ID format + if ( + !subtaskId || + typeof subtaskId !== 'string' || + !subtaskId.includes('.') + ) { + throw new Error( + `Invalid subtask ID format: ${subtaskId}. Subtask ID must be in format "parentId.subtaskId"` + ); + } + + // Validate prompt + if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') { + throw new Error( + 'Prompt cannot be empty. Please provide context for the subtask update.' + ); + } + + // Prepare for fallback handling + let claudeOverloaded = false; + + // Validate tasks file exists + if (!fs.existsSync(tasksPath)) { + throw new Error(`Tasks file not found at path: ${tasksPath}`); + } + + // Read the tasks file + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + throw new Error( + `No valid tasks found in ${tasksPath}. The file may be corrupted or have an invalid format.` + ); + } + + // Parse parent and subtask IDs + const [parentIdStr, subtaskIdStr] = subtaskId.split('.'); + const parentId = parseInt(parentIdStr, 10); + const subtaskIdNum = parseInt(subtaskIdStr, 10); + + if ( + isNaN(parentId) || + parentId <= 0 || + isNaN(subtaskIdNum) || + subtaskIdNum <= 0 + ) { + throw new Error( + `Invalid subtask ID format: ${subtaskId}. Both parent ID and subtask ID must be positive integers.` + ); + } + + // Find the parent task + const parentTask = data.tasks.find((task) => task.id === parentId); + if (!parentTask) { + throw new Error( + `Parent task with ID ${parentId} not found. Please verify the task ID and try again.` + ); + } + + // Find the subtask + if (!parentTask.subtasks || !Array.isArray(parentTask.subtasks)) { + throw new Error(`Parent task ${parentId} has no subtasks.`); + } + + const subtask = parentTask.subtasks.find((st) => st.id === subtaskIdNum); + if (!subtask) { + throw new Error( + `Subtask with ID ${subtaskId} not found. Please verify the subtask ID and try again.` + ); + } + + // Check if subtask is already completed + if (subtask.status === 'done' || subtask.status === 'completed') { + log( + 'warn', + `Subtask ${subtaskId} is already marked as done and cannot be updated` + ); + console.log( + boxen( + chalk.yellow( + `Subtask ${subtaskId} is already marked as ${subtask.status} and cannot be updated.` + ) + + '\n\n' + + chalk.white( + 'Completed subtasks are locked to maintain consistency. To modify a completed subtask, you must first:' + ) + + '\n' + + chalk.white('1. Change its status to "pending" or "in-progress"') + + '\n' + + chalk.white('2. Then run the update-subtask command'), + { padding: 1, borderColor: 'yellow', borderStyle: 'round' } + ) + ); + return null; + } + + // Show the subtask that will be updated + const table = new Table({ + head: [ + chalk.cyan.bold('ID'), + chalk.cyan.bold('Title'), + chalk.cyan.bold('Status') + ], + colWidths: [10, 55, 10] + }); + + table.push([ + subtaskId, + truncate(subtask.title, 52), + getStatusWithColor(subtask.status) + ]); + + console.log( + boxen(chalk.white.bold(`Updating Subtask #${subtaskId}`), { + padding: 1, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, bottom: 0 } + }) + ); + + console.log(table.toString()); + + // Start the loading indicator + loadingIndicator = startLoadingIndicator( + 'Generating additional information with AI...' + ); + + // Create the system prompt (as before) + const systemPrompt = `You are an AI assistant helping to update software development subtasks with additional information. Given a subtask, you will provide additional details, implementation notes, or technical insights based on user request. Focus only on adding content that enhances the subtask - don't repeat existing information. Be technical, specific, and implementation-focused rather than general. Provide concrete examples, code snippets, or implementation details when relevant.`; - // Replace the old research/Claude code with the new model selection approach - let additionalInformation = ''; - let modelAttempts = 0; - const maxModelAttempts = 2; // Try up to 2 models before giving up - - while (modelAttempts < maxModelAttempts && !additionalInformation) { - modelAttempts++; // Increment attempt counter at the start - const isLastAttempt = modelAttempts >= maxModelAttempts; - let modelType = null; // Declare modelType outside the try block + // Replace the old research/Claude code with the new model selection approach + let additionalInformation = ''; + let modelAttempts = 0; + const maxModelAttempts = 2; // Try up to 2 models before giving up - try { - // Get the best available model based on our current state - const result = getAvailableAIModel({ - claudeOverloaded, - requiresResearch: useResearch - }); - modelType = result.type; - const client = result.client; - - log('info', `Attempt ${modelAttempts}/${maxModelAttempts}: Generating subtask info using ${modelType}`); - // Update loading indicator text - stopLoadingIndicator(loadingIndicator); // Stop previous indicator - loadingIndicator = startLoadingIndicator(`Attempt ${modelAttempts}: Using ${modelType.toUpperCase()}...`); + while (modelAttempts < maxModelAttempts && !additionalInformation) { + modelAttempts++; // Increment attempt counter at the start + const isLastAttempt = modelAttempts >= maxModelAttempts; + let modelType = null; // Declare modelType outside the try block - const subtaskData = JSON.stringify(subtask, null, 2); - const userMessageContent = `Here is the subtask to enhance:\n${subtaskData}\n\nPlease provide additional information addressing this request:\n${prompt}\n\nReturn ONLY the new information to add - do not repeat existing content.`; + try { + // Get the best available model based on our current state + const result = getAvailableAIModel({ + claudeOverloaded, + requiresResearch: useResearch + }); + modelType = result.type; + const client = result.client; - if (modelType === 'perplexity') { - // Construct Perplexity payload - const perplexityModel = process.env.PERPLEXITY_MODEL || 'sonar-pro'; - const response = await client.chat.completions.create({ - model: perplexityModel, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userMessageContent } - ], - temperature: parseFloat(process.env.TEMPERATURE || CONFIG.temperature), - max_tokens: parseInt(process.env.MAX_TOKENS || CONFIG.maxTokens), - }); - additionalInformation = response.choices[0].message.content.trim(); - } else { // Claude - let responseText = ''; - let streamingInterval = null; - let dotCount = 0; - const readline = await import('readline'); + log( + 'info', + `Attempt ${modelAttempts}/${maxModelAttempts}: Generating subtask info using ${modelType}` + ); + // Update loading indicator text + stopLoadingIndicator(loadingIndicator); // Stop previous indicator + loadingIndicator = startLoadingIndicator( + `Attempt ${modelAttempts}: Using ${modelType.toUpperCase()}...` + ); - try { - streamingInterval = setInterval(() => { - readline.cursorTo(process.stdout, 0); - process.stdout.write(`Receiving streaming response from Claude${'.'.repeat(dotCount)}`); - dotCount = (dotCount + 1) % 4; - }, 500); + const subtaskData = JSON.stringify(subtask, null, 2); + const userMessageContent = `Here is the subtask to enhance:\n${subtaskData}\n\nPlease provide additional information addressing this request:\n${prompt}\n\nReturn ONLY the new information to add - do not repeat existing content.`; - // Construct Claude payload - const stream = await client.messages.create({ - model: CONFIG.model, - max_tokens: CONFIG.maxTokens, - temperature: CONFIG.temperature, - system: systemPrompt, - messages: [ - { role: 'user', content: userMessageContent } - ], - stream: true - }); + if (modelType === 'perplexity') { + // Construct Perplexity payload + const perplexityModel = process.env.PERPLEXITY_MODEL || 'sonar-pro'; + const response = await client.chat.completions.create({ + model: perplexityModel, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessageContent } + ], + temperature: parseFloat( + process.env.TEMPERATURE || CONFIG.temperature + ), + max_tokens: parseInt(process.env.MAX_TOKENS || CONFIG.maxTokens) + }); + additionalInformation = response.choices[0].message.content.trim(); + } else { + // Claude + let responseText = ''; + let streamingInterval = null; + let dotCount = 0; + const readline = await import('readline'); - for await (const chunk of stream) { - if (chunk.type === 'content_block_delta' && chunk.delta.text) { - responseText += chunk.delta.text; - } - } - } finally { - if (streamingInterval) clearInterval(streamingInterval); - // Clear the loading dots line - readline.cursorTo(process.stdout, 0); - process.stdout.clearLine(0); - } + try { + streamingInterval = setInterval(() => { + readline.cursorTo(process.stdout, 0); + process.stdout.write( + `Receiving streaming response from Claude${'.'.repeat(dotCount)}` + ); + dotCount = (dotCount + 1) % 4; + }, 500); - log('info', `Completed streaming response from Claude API! (Attempt ${modelAttempts})`); - additionalInformation = responseText.trim(); - } + // Construct Claude payload + const stream = await client.messages.create({ + model: CONFIG.model, + max_tokens: CONFIG.maxTokens, + temperature: CONFIG.temperature, + system: systemPrompt, + messages: [{ role: 'user', content: userMessageContent }], + stream: true + }); - // Success - break the loop - if (additionalInformation) { - log('info', `Successfully generated information using ${modelType} on attempt ${modelAttempts}.`); - break; - } else { - // Handle case where AI gave empty response without erroring - log('warn', `AI (${modelType}) returned empty response on attempt ${modelAttempts}.`); - if (isLastAttempt) { - throw new Error('AI returned empty response after maximum attempts.'); - } - // Allow loop to continue to try another model/attempt if possible - } + for await (const chunk of stream) { + if (chunk.type === 'content_block_delta' && chunk.delta.text) { + responseText += chunk.delta.text; + } + } + } finally { + if (streamingInterval) clearInterval(streamingInterval); + // Clear the loading dots line + readline.cursorTo(process.stdout, 0); + process.stdout.clearLine(0); + } - } catch (modelError) { - const failedModel = modelType || (modelError.modelType || 'unknown model'); - log('warn', `Attempt ${modelAttempts} failed using ${failedModel}: ${modelError.message}`); + log( + 'info', + `Completed streaming response from Claude API! (Attempt ${modelAttempts})` + ); + additionalInformation = responseText.trim(); + } - // --- More robust overload check --- - let isOverload = false; - // Check 1: SDK specific property (common pattern) - if (modelError.type === 'overloaded_error') { - isOverload = true; - } - // Check 2: Check nested error property (as originally intended) - else if (modelError.error?.type === 'overloaded_error') { - isOverload = true; - } - // Check 3: Check status code if available (e.g., 429 Too Many Requests or 529 Overloaded) - else if (modelError.status === 429 || modelError.status === 529) { - isOverload = true; - } - // Check 4: Check the message string itself (less reliable) - else if (modelError.message?.toLowerCase().includes('overloaded')) { - isOverload = true; - } - // --- End robust check --- + // Success - break the loop + if (additionalInformation) { + log( + 'info', + `Successfully generated information using ${modelType} on attempt ${modelAttempts}.` + ); + break; + } else { + // Handle case where AI gave empty response without erroring + log( + 'warn', + `AI (${modelType}) returned empty response on attempt ${modelAttempts}.` + ); + if (isLastAttempt) { + throw new Error( + 'AI returned empty response after maximum attempts.' + ); + } + // Allow loop to continue to try another model/attempt if possible + } + } catch (modelError) { + const failedModel = + modelType || modelError.modelType || 'unknown model'; + log( + 'warn', + `Attempt ${modelAttempts} failed using ${failedModel}: ${modelError.message}` + ); - if (isOverload) { // Use the result of the check - claudeOverloaded = true; // Mark Claude as overloaded for the *next* potential attempt - if (!isLastAttempt) { - log('info', 'Claude overloaded. Will attempt fallback model if available.'); - // Stop the current indicator before continuing - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - loadingIndicator = null; // Reset indicator - } - continue; // Go to next iteration of the while loop to try fallback - } else { - // It was the last attempt, and it failed due to overload - log('error', `Overload error on final attempt (${modelAttempts}/${maxModelAttempts}). No fallback possible.`); - // Let the error be thrown after the loop finishes, as additionalInformation will be empty. - // We don't throw immediately here, let the loop exit and the check after the loop handle it. - } // <<<< ADD THIS CLOSING BRACE - } else { // Error was NOT an overload - // If it's not an overload, throw it immediately to be caught by the outer catch. - log('error', `Non-overload error on attempt ${modelAttempts}: ${modelError.message}`); - throw modelError; // Re-throw non-overload errors immediately. - } - } // End inner catch - } // End while loop + // --- More robust overload check --- + let isOverload = false; + // Check 1: SDK specific property (common pattern) + if (modelError.type === 'overloaded_error') { + isOverload = true; + } + // Check 2: Check nested error property (as originally intended) + else if (modelError.error?.type === 'overloaded_error') { + isOverload = true; + } + // Check 3: Check status code if available (e.g., 429 Too Many Requests or 529 Overloaded) + else if (modelError.status === 429 || modelError.status === 529) { + isOverload = true; + } + // Check 4: Check the message string itself (less reliable) + else if (modelError.message?.toLowerCase().includes('overloaded')) { + isOverload = true; + } + // --- End robust check --- - // If loop finished without getting information - if (!additionalInformation) { - console.log('>>> DEBUG: additionalInformation is falsy! Value:', additionalInformation); // <<< ADD THIS - throw new Error('Failed to generate additional information after all attempts.'); - } + if (isOverload) { + // Use the result of the check + claudeOverloaded = true; // Mark Claude as overloaded for the *next* potential attempt + if (!isLastAttempt) { + log( + 'info', + 'Claude overloaded. Will attempt fallback model if available.' + ); + // Stop the current indicator before continuing + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + loadingIndicator = null; // Reset indicator + } + continue; // Go to next iteration of the while loop to try fallback + } else { + // It was the last attempt, and it failed due to overload + log( + 'error', + `Overload error on final attempt (${modelAttempts}/${maxModelAttempts}). No fallback possible.` + ); + // Let the error be thrown after the loop finishes, as additionalInformation will be empty. + // We don't throw immediately here, let the loop exit and the check after the loop handle it. + } // <<<< ADD THIS CLOSING BRACE + } else { + // Error was NOT an overload + // If it's not an overload, throw it immediately to be caught by the outer catch. + log( + 'error', + `Non-overload error on attempt ${modelAttempts}: ${modelError.message}` + ); + throw modelError; // Re-throw non-overload errors immediately. + } + } // End inner catch + } // End while loop - console.log('>>> DEBUG: Got additionalInformation:', additionalInformation.substring(0, 50) + '...'); // <<< ADD THIS + // If loop finished without getting information + if (!additionalInformation) { + console.log( + '>>> DEBUG: additionalInformation is falsy! Value:', + additionalInformation + ); // <<< ADD THIS + throw new Error( + 'Failed to generate additional information after all attempts.' + ); + } - // Create timestamp - const currentDate = new Date(); - const timestamp = currentDate.toISOString(); + console.log( + '>>> DEBUG: Got additionalInformation:', + additionalInformation.substring(0, 50) + '...' + ); // <<< ADD THIS - // Format the additional information with timestamp - const formattedInformation = `\n\n<info added on ${timestamp}>\n${additionalInformation}\n</info added on ${timestamp}>`; - console.log('>>> DEBUG: formattedInformation:', formattedInformation.substring(0, 70) + '...'); // <<< ADD THIS + // Create timestamp + const currentDate = new Date(); + const timestamp = currentDate.toISOString(); - // Append to subtask details and description - console.log('>>> DEBUG: Subtask details BEFORE append:', subtask.details); // <<< ADD THIS - if (subtask.details) { - subtask.details += formattedInformation; - } else { - subtask.details = `${formattedInformation}`; - } - console.log('>>> DEBUG: Subtask details AFTER append:', subtask.details); // <<< ADD THIS + // Format the additional information with timestamp + const formattedInformation = `\n\n<info added on ${timestamp}>\n${additionalInformation}\n</info added on ${timestamp}>`; + console.log( + '>>> DEBUG: formattedInformation:', + formattedInformation.substring(0, 70) + '...' + ); // <<< ADD THIS + // Append to subtask details and description + console.log('>>> DEBUG: Subtask details BEFORE append:', subtask.details); // <<< ADD THIS + if (subtask.details) { + subtask.details += formattedInformation; + } else { + subtask.details = `${formattedInformation}`; + } + console.log('>>> DEBUG: Subtask details AFTER append:', subtask.details); // <<< ADD THIS - if (subtask.description) { - // Only append to description if it makes sense (for shorter updates) - if (additionalInformation.length < 200) { - console.log('>>> DEBUG: Subtask description BEFORE append:', subtask.description); // <<< ADD THIS - subtask.description += ` [Updated: ${currentDate.toLocaleDateString()}]`; - console.log('>>> DEBUG: Subtask description AFTER append:', subtask.description); // <<< ADD THIS - } - } + if (subtask.description) { + // Only append to description if it makes sense (for shorter updates) + if (additionalInformation.length < 200) { + console.log( + '>>> DEBUG: Subtask description BEFORE append:', + subtask.description + ); // <<< ADD THIS + subtask.description += ` [Updated: ${currentDate.toLocaleDateString()}]`; + console.log( + '>>> DEBUG: Subtask description AFTER append:', + subtask.description + ); // <<< ADD THIS + } + } - // Update the subtask in the parent task (add log before write) - // ... index finding logic ... - console.log('>>> DEBUG: About to call writeJSON with updated data...'); // <<< ADD THIS - // Write the updated tasks to the file - writeJSON(tasksPath, data); - console.log('>>> DEBUG: writeJSON call completed.'); // <<< ADD THIS + // Update the subtask in the parent task (add log before write) + // ... index finding logic ... + console.log('>>> DEBUG: About to call writeJSON with updated data...'); // <<< ADD THIS + // Write the updated tasks to the file + writeJSON(tasksPath, data); + console.log('>>> DEBUG: writeJSON call completed.'); // <<< ADD THIS + log('success', `Successfully updated subtask ${subtaskId}`); - log('success', `Successfully updated subtask ${subtaskId}`); + // Generate individual task files + await generateTaskFiles(tasksPath, path.dirname(tasksPath)); // <<< Maybe log after this too - // Generate individual task files - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); // <<< Maybe log after this too + // Stop indicator *before* final console output + stopLoadingIndicator(loadingIndicator); + loadingIndicator = null; - // Stop indicator *before* final console output - stopLoadingIndicator(loadingIndicator); - loadingIndicator = null; + console.log( + boxen( + chalk.green(`Successfully updated subtask #${subtaskId}`) + + '\n\n' + + chalk.white.bold('Title:') + + ' ' + + subtask.title + + '\n\n' + + chalk.white.bold('Information Added:') + + '\n' + + chalk.white(truncate(additionalInformation, 300, true)), + { padding: 1, borderColor: 'green', borderStyle: 'round' } + ) + ); - console.log(boxen( - chalk.green(`Successfully updated subtask #${subtaskId}`) + '\n\n' + - chalk.white.bold('Title:') + ' ' + subtask.title + '\n\n' + - chalk.white.bold('Information Added:') + '\n' + - chalk.white(truncate(additionalInformation, 300, true)), - { padding: 1, borderColor: 'green', borderStyle: 'round' } - )); + return subtask; + } catch (error) { + // Outer catch block handles final errors after loop/attempts + stopLoadingIndicator(loadingIndicator); // Ensure indicator is stopped on error + loadingIndicator = null; + log('error', `Error updating subtask: ${error.message}`); + console.error(chalk.red(`Error: ${error.message}`)); - return subtask; + // ... (existing helpful error message logic based on error type) ... + if (error.message?.includes('ANTHROPIC_API_KEY')) { + console.log( + chalk.yellow('\nTo fix this issue, set your Anthropic API key:') + ); + console.log(' export ANTHROPIC_API_KEY=your_api_key_here'); + } else if (error.message?.includes('PERPLEXITY_API_KEY')) { + console.log(chalk.yellow('\nTo fix this issue:')); + console.log( + ' 1. Set your Perplexity API key: export PERPLEXITY_API_KEY=your_api_key_here' + ); + console.log( + ' 2. Or run without the research flag: task-master update-subtask --id=<id> --prompt=\"...\"' + ); + } else if (error.message?.includes('overloaded')) { + // Catch final overload error + console.log( + chalk.yellow( + '\nAI model overloaded, and fallback failed or was unavailable:' + ) + ); + console.log(' 1. Try again in a few minutes.'); + console.log(' 2. Ensure PERPLEXITY_API_KEY is set for fallback.'); + console.log(' 3. Consider breaking your prompt into smaller updates.'); + } else if (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('empty response from AI')) { + console.log( + chalk.yellow( + '\nThe AI model returned an empty response. This might be due to the prompt or API issues. Try rephrasing or trying again later.' + ) + ); + } - } catch (error) { - // Outer catch block handles final errors after loop/attempts - stopLoadingIndicator(loadingIndicator); // Ensure indicator is stopped on error - loadingIndicator = null; - log('error', `Error updating subtask: ${error.message}`); - console.error(chalk.red(`Error: ${error.message}`)); + if (CONFIG.debug) { + console.error(error); + } - // ... (existing helpful error message logic based on error type) ... - if (error.message?.includes('ANTHROPIC_API_KEY')) { - console.log(chalk.yellow('\nTo fix this issue, set your Anthropic API key:')); - console.log(' export ANTHROPIC_API_KEY=your_api_key_here'); - } else if (error.message?.includes('PERPLEXITY_API_KEY')) { - console.log(chalk.yellow('\nTo fix this issue:')); - console.log(' 1. Set your Perplexity API key: export PERPLEXITY_API_KEY=your_api_key_here'); - console.log(' 2. Or run without the research flag: task-master update-subtask --id=<id> --prompt=\"...\"'); - } else if (error.message?.includes('overloaded')) { // Catch final overload error - console.log(chalk.yellow('\nAI model overloaded, and fallback failed or was unavailable:')); - console.log(' 1. Try again in a few minutes.'); - console.log(' 2. Ensure PERPLEXITY_API_KEY is set for fallback.'); - console.log(' 3. Consider breaking your prompt into smaller updates.'); - } else if (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('empty response from AI')) { - console.log(chalk.yellow('\nThe AI model returned an empty response. This might be due to the prompt or API issues. Try rephrasing or trying again later.')); - } - - if (CONFIG.debug) { - console.error(error); - } - - return null; - } finally { - // Final cleanup check for the indicator, although it should be stopped by now - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - } - } + return null; + } finally { + // Final cleanup check for the indicator, although it should be stopped by now + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + } + } } // Export task manager functions export { - parsePRD, - updateTasks, - updateTaskById, - updateSubtaskById, - generateTaskFiles, - setTaskStatus, - updateSingleTaskStatus, - listTasks, - expandTask, - expandAllTasks, - clearSubtasks, - addTask, - addSubtask, - removeSubtask, - findNextTask, - analyzeTaskComplexity, -}; \ No newline at end of file + parsePRD, + updateTasks, + updateTaskById, + updateSubtaskById, + generateTaskFiles, + setTaskStatus, + updateSingleTaskStatus, + listTasks, + expandTask, + expandAllTasks, + clearSubtasks, + addTask, + addSubtask, + removeSubtask, + findNextTask, + analyzeTaskComplexity +}; diff --git a/scripts/modules/ui.js b/scripts/modules/ui.js index ccfd0649..b1678d26 100644 --- a/scripts/modules/ui.js +++ b/scripts/modules/ui.js @@ -9,7 +9,14 @@ import boxen from 'boxen'; import ora from 'ora'; import Table from 'cli-table3'; import gradient from 'gradient-string'; -import { CONFIG, log, findTaskById, readJSON, readComplexityReport, truncate } from './utils.js'; +import { + CONFIG, + log, + findTaskById, + readJSON, + readComplexityReport, + truncate +} from './utils.js'; import path from 'path'; import fs from 'fs'; import { findNextTask, analyzeTaskComplexity } from './task-manager.js'; @@ -22,36 +29,45 @@ const warmGradient = gradient(['#fb8b24', '#e36414', '#9a031e']); * Display a fancy banner for the CLI */ function displayBanner() { - console.clear(); - const bannerText = figlet.textSync('Task Master', { - font: 'Standard', - horizontalLayout: 'default', - verticalLayout: 'default' - }); - - console.log(coolGradient(bannerText)); - - // Add creator credit line below the banner - console.log(chalk.dim('by ') + chalk.cyan.underline('https://x.com/eyaltoledano')); - - // Read version directly from package.json - let version = CONFIG.projectVersion; // Default fallback - try { - const packageJsonPath = path.join(process.cwd(), 'package.json'); - if (fs.existsSync(packageJsonPath)) { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - version = packageJson.version; - } - } catch (error) { - // Silently fall back to default version - } - - console.log(boxen(chalk.white(`${chalk.bold('Version:')} ${version} ${chalk.bold('Project:')} ${CONFIG.projectName}`), { - padding: 1, - margin: { top: 0, bottom: 1 }, - borderStyle: 'round', - borderColor: 'cyan' - })); + console.clear(); + const bannerText = figlet.textSync('Task Master', { + font: 'Standard', + horizontalLayout: 'default', + verticalLayout: 'default' + }); + + console.log(coolGradient(bannerText)); + + // Add creator credit line below the banner + console.log( + chalk.dim('by ') + chalk.cyan.underline('https://x.com/eyaltoledano') + ); + + // Read version directly from package.json + let version = CONFIG.projectVersion; // Default fallback + try { + const packageJsonPath = path.join(process.cwd(), 'package.json'); + if (fs.existsSync(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + version = packageJson.version; + } + } catch (error) { + // Silently fall back to default version + } + + console.log( + boxen( + chalk.white( + `${chalk.bold('Version:')} ${version} ${chalk.bold('Project:')} ${CONFIG.projectName}` + ), + { + padding: 1, + margin: { top: 0, bottom: 1 }, + borderStyle: 'round', + borderColor: 'cyan' + } + ) + ); } /** @@ -60,12 +76,12 @@ function displayBanner() { * @returns {Object} Spinner object */ function startLoadingIndicator(message) { - const spinner = ora({ - text: message, - color: 'cyan' - }).start(); - - return spinner; + const spinner = ora({ + text: message, + color: 'cyan' + }).start(); + + return spinner; } /** @@ -73,9 +89,9 @@ function startLoadingIndicator(message) { * @param {Object} spinner - Spinner object to stop */ function stopLoadingIndicator(spinner) { - if (spinner && spinner.stop) { - spinner.stop(); - } + if (spinner && spinner.stop) { + spinner.stop(); + } } /** @@ -85,13 +101,13 @@ function stopLoadingIndicator(spinner) { * @returns {string} Formatted progress bar */ function createProgressBar(percent, length = 30) { - const filled = Math.round(percent * length / 100); - const empty = length - filled; - - const filledBar = '█'.repeat(filled); - const emptyBar = '░'.repeat(empty); - - return `${filledBar}${emptyBar} ${percent.toFixed(0)}%`; + const filled = Math.round((percent * length) / 100); + const empty = length - filled; + + const filledBar = '█'.repeat(filled); + const emptyBar = '░'.repeat(empty); + + return `${filledBar}${emptyBar} ${percent.toFixed(0)}%`; } /** @@ -101,39 +117,43 @@ function createProgressBar(percent, length = 30) { * @returns {string} Colored status string */ function getStatusWithColor(status, forTable = false) { - if (!status) { - return chalk.gray('❓ unknown'); - } - - const statusConfig = { - 'done': { color: chalk.green, icon: '✅', tableIcon: '✓' }, - 'completed': { color: chalk.green, icon: '✅', tableIcon: '✓' }, - 'pending': { color: chalk.yellow, icon: '⏱️', tableIcon: '⏱' }, - 'in-progress': { color: chalk.hex('#FFA500'), icon: '🔄', tableIcon: '►' }, - 'deferred': { color: chalk.gray, icon: '⏱️', tableIcon: '⏱' }, - 'blocked': { color: chalk.red, icon: '❌', tableIcon: '✗' }, - 'review': { color: chalk.magenta, icon: '👀', tableIcon: '👁' } - }; - - const config = statusConfig[status.toLowerCase()] || { color: chalk.red, icon: '❌', tableIcon: '✗' }; - - // Use simpler icons for table display to prevent border issues - if (forTable) { - // Use ASCII characters instead of Unicode for completely stable display - const simpleIcons = { - 'done': '✓', - 'completed': '✓', - 'pending': '○', - 'in-progress': '►', - 'deferred': 'x', - 'blocked': '!', // Using plain x character for better compatibility - 'review': '?' // Using circled dot symbol - }; - const simpleIcon = simpleIcons[status.toLowerCase()] || 'x'; - return config.color(`${simpleIcon} ${status}`); - } - - return config.color(`${config.icon} ${status}`); + if (!status) { + return chalk.gray('❓ unknown'); + } + + const statusConfig = { + done: { color: chalk.green, icon: '✅', tableIcon: '✓' }, + completed: { color: chalk.green, icon: '✅', tableIcon: '✓' }, + pending: { color: chalk.yellow, icon: '⏱️', tableIcon: '⏱' }, + 'in-progress': { color: chalk.hex('#FFA500'), icon: '🔄', tableIcon: '►' }, + deferred: { color: chalk.gray, icon: '⏱️', tableIcon: '⏱' }, + blocked: { color: chalk.red, icon: '❌', tableIcon: '✗' }, + review: { color: chalk.magenta, icon: '👀', tableIcon: '👁' } + }; + + const config = statusConfig[status.toLowerCase()] || { + color: chalk.red, + icon: '❌', + tableIcon: '✗' + }; + + // Use simpler icons for table display to prevent border issues + if (forTable) { + // Use ASCII characters instead of Unicode for completely stable display + const simpleIcons = { + done: '✓', + completed: '✓', + pending: '○', + 'in-progress': '►', + deferred: 'x', + blocked: '!', // Using plain x character for better compatibility + review: '?' // Using circled dot symbol + }; + const simpleIcon = simpleIcons[status.toLowerCase()] || 'x'; + return config.color(`${simpleIcon} ${status}`); + } + + return config.color(`${config.icon} ${status}`); } /** @@ -143,265 +163,375 @@ function getStatusWithColor(status, forTable = false) { * @param {boolean} forConsole - Whether the output is for console display * @returns {string} Formatted dependencies string */ -function formatDependenciesWithStatus(dependencies, allTasks, forConsole = false) { - if (!dependencies || !Array.isArray(dependencies) || dependencies.length === 0) { - return forConsole ? chalk.gray('None') : 'None'; - } - - const formattedDeps = dependencies.map(depId => { - const depIdStr = depId.toString(); // Ensure string format for display - - // Check if it's already a fully qualified subtask ID (like "22.1") - if (depIdStr.includes('.')) { - const [parentId, subtaskId] = depIdStr.split('.').map(id => parseInt(id, 10)); - - // Find the parent task - const parentTask = allTasks.find(t => t.id === parentId); - if (!parentTask || !parentTask.subtasks) { - return forConsole ? - chalk.red(`${depIdStr} (Not found)`) : - `${depIdStr} (Not found)`; - } - - // Find the subtask - const subtask = parentTask.subtasks.find(st => st.id === subtaskId); - if (!subtask) { - return forConsole ? - chalk.red(`${depIdStr} (Not found)`) : - `${depIdStr} (Not found)`; - } - - // Format with status - const status = subtask.status || 'pending'; - const isDone = status.toLowerCase() === 'done' || status.toLowerCase() === 'completed'; - const isInProgress = status.toLowerCase() === 'in-progress'; - - if (forConsole) { - if (isDone) { - return chalk.green.bold(depIdStr); - } else if (isInProgress) { - return chalk.hex('#FFA500').bold(depIdStr); - } else { - return chalk.red.bold(depIdStr); - } - } - - // For plain text output (task files), return just the ID without any formatting or emoji - return depIdStr; - } - - // If depId is a number less than 100, it's likely a reference to a subtask ID in the current task - // This case is typically handled elsewhere (in task-specific code) before calling this function - - // For regular task dependencies (not subtasks) - // Convert string depId to number if needed - const numericDepId = typeof depId === 'string' ? parseInt(depId, 10) : depId; - - // Look up the task using the numeric ID - const depTask = findTaskById(allTasks, numericDepId); - - if (!depTask) { - return forConsole ? - chalk.red(`${depIdStr} (Not found)`) : - `${depIdStr} (Not found)`; - } - - // Format with status - const status = depTask.status || 'pending'; - const isDone = status.toLowerCase() === 'done' || status.toLowerCase() === 'completed'; - const isInProgress = status.toLowerCase() === 'in-progress'; - - if (forConsole) { - if (isDone) { - return chalk.green.bold(depIdStr); - } else if (isInProgress) { - return chalk.yellow.bold(depIdStr); - } else { - return chalk.red.bold(depIdStr); - } - } - - // For plain text output (task files), return just the ID without any formatting or emoji - return depIdStr; - }); - - return formattedDeps.join(', '); +function formatDependenciesWithStatus( + dependencies, + allTasks, + forConsole = false +) { + if ( + !dependencies || + !Array.isArray(dependencies) || + dependencies.length === 0 + ) { + return forConsole ? chalk.gray('None') : 'None'; + } + + const formattedDeps = dependencies.map((depId) => { + const depIdStr = depId.toString(); // Ensure string format for display + + // Check if it's already a fully qualified subtask ID (like "22.1") + if (depIdStr.includes('.')) { + const [parentId, subtaskId] = depIdStr + .split('.') + .map((id) => parseInt(id, 10)); + + // Find the parent task + const parentTask = allTasks.find((t) => t.id === parentId); + if (!parentTask || !parentTask.subtasks) { + return forConsole + ? chalk.red(`${depIdStr} (Not found)`) + : `${depIdStr} (Not found)`; + } + + // Find the subtask + const subtask = parentTask.subtasks.find((st) => st.id === subtaskId); + if (!subtask) { + return forConsole + ? chalk.red(`${depIdStr} (Not found)`) + : `${depIdStr} (Not found)`; + } + + // Format with status + const status = subtask.status || 'pending'; + const isDone = + status.toLowerCase() === 'done' || status.toLowerCase() === 'completed'; + const isInProgress = status.toLowerCase() === 'in-progress'; + + if (forConsole) { + if (isDone) { + return chalk.green.bold(depIdStr); + } else if (isInProgress) { + return chalk.hex('#FFA500').bold(depIdStr); + } else { + return chalk.red.bold(depIdStr); + } + } + + // For plain text output (task files), return just the ID without any formatting or emoji + return depIdStr; + } + + // If depId is a number less than 100, it's likely a reference to a subtask ID in the current task + // This case is typically handled elsewhere (in task-specific code) before calling this function + + // For regular task dependencies (not subtasks) + // Convert string depId to number if needed + const numericDepId = + typeof depId === 'string' ? parseInt(depId, 10) : depId; + + // Look up the task using the numeric ID + const depTask = findTaskById(allTasks, numericDepId); + + if (!depTask) { + return forConsole + ? chalk.red(`${depIdStr} (Not found)`) + : `${depIdStr} (Not found)`; + } + + // Format with status + const status = depTask.status || 'pending'; + const isDone = + status.toLowerCase() === 'done' || status.toLowerCase() === 'completed'; + const isInProgress = status.toLowerCase() === 'in-progress'; + + if (forConsole) { + if (isDone) { + return chalk.green.bold(depIdStr); + } else if (isInProgress) { + return chalk.yellow.bold(depIdStr); + } else { + return chalk.red.bold(depIdStr); + } + } + + // For plain text output (task files), return just the ID without any formatting or emoji + return depIdStr; + }); + + return formattedDeps.join(', '); } /** * Display a comprehensive help guide */ function displayHelp() { - displayBanner(); - - console.log(boxen( - chalk.white.bold('Task Master CLI'), - { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 1 } } - )); - - // Command categories - const commandCategories = [ - { - title: 'Task Generation', - color: 'cyan', - commands: [ - { name: 'parse-prd', args: '--input=<file.txt> [--tasks=10]', - desc: 'Generate tasks from a PRD document' }, - { name: 'generate', args: '', - desc: 'Create individual task files from tasks.json' } - ] - }, - { - title: 'Task Management', - color: 'green', - commands: [ - { name: 'list', args: '[--status=<status>] [--with-subtasks]', - desc: 'List all tasks with their status' }, - { name: 'set-status', args: '--id=<id> --status=<status>', - desc: 'Update task status (done, pending, etc.)' }, - { name: 'update', args: '--from=<id> --prompt="<context>"', - desc: 'Update tasks based on new requirements' }, - { name: 'add-task', args: '--prompt="<text>" [--dependencies=<ids>] [--priority=<priority>]', - desc: 'Add a new task using AI' }, - { name: 'add-dependency', args: '--id=<id> --depends-on=<id>', - desc: 'Add a dependency to a task' }, - { name: 'remove-dependency', args: '--id=<id> --depends-on=<id>', - desc: 'Remove a dependency from a task' } - ] - }, - { - title: 'Task Analysis & Detail', - color: 'yellow', - commands: [ - { name: 'analyze-complexity', args: '[--research] [--threshold=5]', - desc: 'Analyze tasks and generate expansion recommendations' }, - { name: 'complexity-report', args: '[--file=<path>]', - desc: 'Display the complexity analysis report' }, - { name: 'expand', args: '--id=<id> [--num=5] [--research] [--prompt="<context>"]', - desc: 'Break down tasks into detailed subtasks' }, - { name: 'expand --all', args: '[--force] [--research]', - desc: 'Expand all pending tasks with subtasks' }, - { name: 'clear-subtasks', args: '--id=<id>', - desc: 'Remove subtasks from specified tasks' } - ] - }, - { - title: 'Task Navigation & Viewing', - color: 'magenta', - commands: [ - { name: 'next', args: '', - desc: 'Show the next task to work on based on dependencies' }, - { name: 'show', args: '<id>', - desc: 'Display detailed information about a specific task' } - ] - }, - { - title: 'Dependency Management', - color: 'blue', - commands: [ - { name: 'validate-dependencies', args: '', - desc: 'Identify invalid dependencies without fixing them' }, - { name: 'fix-dependencies', args: '', - desc: 'Fix invalid dependencies automatically' } - ] - } - ]; - - // Display each category - commandCategories.forEach(category => { - console.log(boxen( - chalk[category.color].bold(category.title), - { - padding: { left: 2, right: 2, top: 0, bottom: 0 }, - margin: { top: 1, bottom: 0 }, - borderColor: category.color, - borderStyle: 'round' - } - )); - - const commandTable = new Table({ - colWidths: [25, 40, 45], - chars: { - 'top': '', 'top-mid': '', 'top-left': '', 'top-right': '', - 'bottom': '', 'bottom-mid': '', 'bottom-left': '', 'bottom-right': '', - 'left': '', 'left-mid': '', 'mid': '', 'mid-mid': '', - 'right': '', 'right-mid': '', 'middle': ' ' - }, - style: { border: [], 'padding-left': 4 } - }); - - category.commands.forEach((cmd, index) => { - commandTable.push([ - `${chalk.yellow.bold(cmd.name)}${chalk.reset('')}`, - `${chalk.white(cmd.args)}${chalk.reset('')}`, - `${chalk.dim(cmd.desc)}${chalk.reset('')}` - ]); - }); - - console.log(commandTable.toString()); - console.log(''); - }); - - // Display environment variables section - console.log(boxen( - chalk.cyan.bold('Environment Variables'), - { - padding: { left: 2, right: 2, top: 0, bottom: 0 }, - margin: { top: 1, bottom: 0 }, - borderColor: 'cyan', - borderStyle: 'round' - } - )); - - const envTable = new Table({ - colWidths: [30, 50, 30], - chars: { - 'top': '', 'top-mid': '', 'top-left': '', 'top-right': '', - 'bottom': '', 'bottom-mid': '', 'bottom-left': '', 'bottom-right': '', - 'left': '', 'left-mid': '', 'mid': '', 'mid-mid': '', - 'right': '', 'right-mid': '', 'middle': ' ' - }, - style: { border: [], 'padding-left': 4 } - }); - - envTable.push( - [`${chalk.yellow('ANTHROPIC_API_KEY')}${chalk.reset('')}`, - `${chalk.white('Your Anthropic API key')}${chalk.reset('')}`, - `${chalk.dim('Required')}${chalk.reset('')}`], - [`${chalk.yellow('MODEL')}${chalk.reset('')}`, - `${chalk.white('Claude model to use')}${chalk.reset('')}`, - `${chalk.dim(`Default: ${CONFIG.model}`)}${chalk.reset('')}`], - [`${chalk.yellow('MAX_TOKENS')}${chalk.reset('')}`, - `${chalk.white('Maximum tokens for responses')}${chalk.reset('')}`, - `${chalk.dim(`Default: ${CONFIG.maxTokens}`)}${chalk.reset('')}`], - [`${chalk.yellow('TEMPERATURE')}${chalk.reset('')}`, - `${chalk.white('Temperature for model responses')}${chalk.reset('')}`, - `${chalk.dim(`Default: ${CONFIG.temperature}`)}${chalk.reset('')}`], - [`${chalk.yellow('PERPLEXITY_API_KEY')}${chalk.reset('')}`, - `${chalk.white('Perplexity API key for research')}${chalk.reset('')}`, - `${chalk.dim('Optional')}${chalk.reset('')}`], - [`${chalk.yellow('PERPLEXITY_MODEL')}${chalk.reset('')}`, - `${chalk.white('Perplexity model to use')}${chalk.reset('')}`, - `${chalk.dim('Default: sonar-pro')}${chalk.reset('')}`], - [`${chalk.yellow('DEBUG')}${chalk.reset('')}`, - `${chalk.white('Enable debug logging')}${chalk.reset('')}`, - `${chalk.dim(`Default: ${CONFIG.debug}`)}${chalk.reset('')}`], - [`${chalk.yellow('LOG_LEVEL')}${chalk.reset('')}`, - `${chalk.white('Console output level (debug,info,warn,error)')}${chalk.reset('')}`, - `${chalk.dim(`Default: ${CONFIG.logLevel}`)}${chalk.reset('')}`], - [`${chalk.yellow('DEFAULT_SUBTASKS')}${chalk.reset('')}`, - `${chalk.white('Default number of subtasks to generate')}${chalk.reset('')}`, - `${chalk.dim(`Default: ${CONFIG.defaultSubtasks}`)}${chalk.reset('')}`], - [`${chalk.yellow('DEFAULT_PRIORITY')}${chalk.reset('')}`, - `${chalk.white('Default task priority')}${chalk.reset('')}`, - `${chalk.dim(`Default: ${CONFIG.defaultPriority}`)}${chalk.reset('')}`], - [`${chalk.yellow('PROJECT_NAME')}${chalk.reset('')}`, - `${chalk.white('Project name displayed in UI')}${chalk.reset('')}`, - `${chalk.dim(`Default: ${CONFIG.projectName}`)}${chalk.reset('')}`] - ); - - console.log(envTable.toString()); - console.log(''); + displayBanner(); + + console.log( + boxen(chalk.white.bold('Task Master CLI'), { + padding: 1, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + }) + ); + + // Command categories + const commandCategories = [ + { + title: 'Task Generation', + color: 'cyan', + commands: [ + { + name: 'parse-prd', + args: '--input=<file.txt> [--tasks=10]', + desc: 'Generate tasks from a PRD document' + }, + { + name: 'generate', + args: '', + desc: 'Create individual task files from tasks.json' + } + ] + }, + { + title: 'Task Management', + color: 'green', + commands: [ + { + name: 'list', + args: '[--status=<status>] [--with-subtasks]', + desc: 'List all tasks with their status' + }, + { + name: 'set-status', + args: '--id=<id> --status=<status>', + desc: 'Update task status (done, pending, etc.)' + }, + { + name: 'update', + args: '--from=<id> --prompt="<context>"', + desc: 'Update tasks based on new requirements' + }, + { + name: 'add-task', + args: '--prompt="<text>" [--dependencies=<ids>] [--priority=<priority>]', + desc: 'Add a new task using AI' + }, + { + name: 'add-dependency', + args: '--id=<id> --depends-on=<id>', + desc: 'Add a dependency to a task' + }, + { + name: 'remove-dependency', + args: '--id=<id> --depends-on=<id>', + desc: 'Remove a dependency from a task' + } + ] + }, + { + title: 'Task Analysis & Detail', + color: 'yellow', + commands: [ + { + name: 'analyze-complexity', + args: '[--research] [--threshold=5]', + desc: 'Analyze tasks and generate expansion recommendations' + }, + { + name: 'complexity-report', + args: '[--file=<path>]', + desc: 'Display the complexity analysis report' + }, + { + name: 'expand', + args: '--id=<id> [--num=5] [--research] [--prompt="<context>"]', + desc: 'Break down tasks into detailed subtasks' + }, + { + name: 'expand --all', + args: '[--force] [--research]', + desc: 'Expand all pending tasks with subtasks' + }, + { + name: 'clear-subtasks', + args: '--id=<id>', + desc: 'Remove subtasks from specified tasks' + } + ] + }, + { + title: 'Task Navigation & Viewing', + color: 'magenta', + commands: [ + { + name: 'next', + args: '', + desc: 'Show the next task to work on based on dependencies' + }, + { + name: 'show', + args: '<id>', + desc: 'Display detailed information about a specific task' + } + ] + }, + { + title: 'Dependency Management', + color: 'blue', + commands: [ + { + name: 'validate-dependencies', + args: '', + desc: 'Identify invalid dependencies without fixing them' + }, + { + name: 'fix-dependencies', + args: '', + desc: 'Fix invalid dependencies automatically' + } + ] + } + ]; + + // Display each category + commandCategories.forEach((category) => { + console.log( + boxen(chalk[category.color].bold(category.title), { + padding: { left: 2, right: 2, top: 0, bottom: 0 }, + margin: { top: 1, bottom: 0 }, + borderColor: category.color, + borderStyle: 'round' + }) + ); + + const commandTable = new Table({ + colWidths: [25, 40, 45], + chars: { + top: '', + 'top-mid': '', + 'top-left': '', + 'top-right': '', + bottom: '', + 'bottom-mid': '', + 'bottom-left': '', + 'bottom-right': '', + left: '', + 'left-mid': '', + mid: '', + 'mid-mid': '', + right: '', + 'right-mid': '', + middle: ' ' + }, + style: { border: [], 'padding-left': 4 } + }); + + category.commands.forEach((cmd, index) => { + commandTable.push([ + `${chalk.yellow.bold(cmd.name)}${chalk.reset('')}`, + `${chalk.white(cmd.args)}${chalk.reset('')}`, + `${chalk.dim(cmd.desc)}${chalk.reset('')}` + ]); + }); + + console.log(commandTable.toString()); + console.log(''); + }); + + // Display environment variables section + console.log( + boxen(chalk.cyan.bold('Environment Variables'), { + padding: { left: 2, right: 2, top: 0, bottom: 0 }, + margin: { top: 1, bottom: 0 }, + borderColor: 'cyan', + borderStyle: 'round' + }) + ); + + const envTable = new Table({ + colWidths: [30, 50, 30], + chars: { + top: '', + 'top-mid': '', + 'top-left': '', + 'top-right': '', + bottom: '', + 'bottom-mid': '', + 'bottom-left': '', + 'bottom-right': '', + left: '', + 'left-mid': '', + mid: '', + 'mid-mid': '', + right: '', + 'right-mid': '', + middle: ' ' + }, + style: { border: [], 'padding-left': 4 } + }); + + envTable.push( + [ + `${chalk.yellow('ANTHROPIC_API_KEY')}${chalk.reset('')}`, + `${chalk.white('Your Anthropic API key')}${chalk.reset('')}`, + `${chalk.dim('Required')}${chalk.reset('')}` + ], + [ + `${chalk.yellow('MODEL')}${chalk.reset('')}`, + `${chalk.white('Claude model to use')}${chalk.reset('')}`, + `${chalk.dim(`Default: ${CONFIG.model}`)}${chalk.reset('')}` + ], + [ + `${chalk.yellow('MAX_TOKENS')}${chalk.reset('')}`, + `${chalk.white('Maximum tokens for responses')}${chalk.reset('')}`, + `${chalk.dim(`Default: ${CONFIG.maxTokens}`)}${chalk.reset('')}` + ], + [ + `${chalk.yellow('TEMPERATURE')}${chalk.reset('')}`, + `${chalk.white('Temperature for model responses')}${chalk.reset('')}`, + `${chalk.dim(`Default: ${CONFIG.temperature}`)}${chalk.reset('')}` + ], + [ + `${chalk.yellow('PERPLEXITY_API_KEY')}${chalk.reset('')}`, + `${chalk.white('Perplexity API key for research')}${chalk.reset('')}`, + `${chalk.dim('Optional')}${chalk.reset('')}` + ], + [ + `${chalk.yellow('PERPLEXITY_MODEL')}${chalk.reset('')}`, + `${chalk.white('Perplexity model to use')}${chalk.reset('')}`, + `${chalk.dim('Default: sonar-pro')}${chalk.reset('')}` + ], + [ + `${chalk.yellow('DEBUG')}${chalk.reset('')}`, + `${chalk.white('Enable debug logging')}${chalk.reset('')}`, + `${chalk.dim(`Default: ${CONFIG.debug}`)}${chalk.reset('')}` + ], + [ + `${chalk.yellow('LOG_LEVEL')}${chalk.reset('')}`, + `${chalk.white('Console output level (debug,info,warn,error)')}${chalk.reset('')}`, + `${chalk.dim(`Default: ${CONFIG.logLevel}`)}${chalk.reset('')}` + ], + [ + `${chalk.yellow('DEFAULT_SUBTASKS')}${chalk.reset('')}`, + `${chalk.white('Default number of subtasks to generate')}${chalk.reset('')}`, + `${chalk.dim(`Default: ${CONFIG.defaultSubtasks}`)}${chalk.reset('')}` + ], + [ + `${chalk.yellow('DEFAULT_PRIORITY')}${chalk.reset('')}`, + `${chalk.white('Default task priority')}${chalk.reset('')}`, + `${chalk.dim(`Default: ${CONFIG.defaultPriority}`)}${chalk.reset('')}` + ], + [ + `${chalk.yellow('PROJECT_NAME')}${chalk.reset('')}`, + `${chalk.white('Project name displayed in UI')}${chalk.reset('')}`, + `${chalk.dim(`Default: ${CONFIG.projectName}`)}${chalk.reset('')}` + ] + ); + + console.log(envTable.toString()); + console.log(''); } /** @@ -410,9 +540,9 @@ function displayHelp() { * @returns {string} Colored complexity score */ function getComplexityWithColor(score) { - if (score <= 3) return chalk.green(`🟢 ${score}`); - if (score <= 6) return chalk.yellow(`🟡 ${score}`); - return chalk.red(`🔴 ${score}`); + if (score <= 3) return chalk.green(`🟢 ${score}`); + if (score <= 6) return chalk.yellow(`🟡 ${score}`); + return chalk.red(`🔴 ${score}`); } /** @@ -422,9 +552,9 @@ function getComplexityWithColor(score) { * @returns {string} Truncated string */ function truncateString(str, maxLength) { - if (!str) return ''; - if (str.length <= maxLength) return str; - return str.substring(0, maxLength - 3) + '...'; + if (!str) return ''; + if (str.length <= maxLength) return str; + return str.substring(0, maxLength - 3) + '...'; } /** @@ -432,189 +562,247 @@ function truncateString(str, maxLength) { * @param {string} tasksPath - Path to the tasks.json file */ async function displayNextTask(tasksPath) { - displayBanner(); - - // Read the tasks file - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - log('error', "No valid tasks found."); - process.exit(1); - } - - // Find the next task - const nextTask = findNextTask(data.tasks); - - if (!nextTask) { - console.log(boxen( - chalk.yellow('No eligible tasks found!\n\n') + - 'All pending tasks have unsatisfied dependencies, or all tasks are completed.', - { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: 'yellow', borderStyle: 'round', margin: { top: 1 } } - )); - return; - } - - // Display the task in a nice format - console.log(boxen( - chalk.white.bold(`Next Task: #${nextTask.id} - ${nextTask.title}`), - { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 0 } } - )); - - // Create a table with task details - const taskTable = new Table({ - style: { - head: [], - border: [], - 'padding-top': 0, - 'padding-bottom': 0, - compact: true - }, - chars: { - 'mid': '', 'left-mid': '', 'mid-mid': '', 'right-mid': '' - }, - colWidths: [15, Math.min(75, (process.stdout.columns - 20) || 60)], - wordWrap: true - }); - - // Priority with color - const priorityColors = { - 'high': chalk.red.bold, - 'medium': chalk.yellow, - 'low': chalk.gray - }; - const priorityColor = priorityColors[nextTask.priority || 'medium'] || chalk.white; - - // Add task details to table - taskTable.push( - [chalk.cyan.bold('ID:'), nextTask.id.toString()], - [chalk.cyan.bold('Title:'), nextTask.title], - [chalk.cyan.bold('Priority:'), priorityColor(nextTask.priority || 'medium')], - [chalk.cyan.bold('Dependencies:'), formatDependenciesWithStatus(nextTask.dependencies, data.tasks, true)], - [chalk.cyan.bold('Description:'), nextTask.description] - ); - - console.log(taskTable.toString()); - - // If task has details, show them in a separate box - if (nextTask.details && nextTask.details.trim().length > 0) { - console.log(boxen( - chalk.white.bold('Implementation Details:') + '\n\n' + - nextTask.details, - { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: 'cyan', borderStyle: 'round', margin: { top: 1, bottom: 0 } } - )); - } - - // Show subtasks if they exist - if (nextTask.subtasks && nextTask.subtasks.length > 0) { - console.log(boxen( - chalk.white.bold('Subtasks'), - { padding: { top: 0, bottom: 0, left: 1, right: 1 }, margin: { top: 1, bottom: 0 }, borderColor: 'magenta', borderStyle: 'round' } - )); - - // Calculate available width for the subtask table - const availableWidth = process.stdout.columns - 10 || 100; // Default to 100 if can't detect - - // Define percentage-based column widths - const idWidthPct = 8; - const statusWidthPct = 15; - const depsWidthPct = 25; - const titleWidthPct = 100 - idWidthPct - statusWidthPct - depsWidthPct; - - // Calculate actual column widths - const idWidth = Math.floor(availableWidth * (idWidthPct / 100)); - const statusWidth = Math.floor(availableWidth * (statusWidthPct / 100)); - const depsWidth = Math.floor(availableWidth * (depsWidthPct / 100)); - const titleWidth = Math.floor(availableWidth * (titleWidthPct / 100)); - - // Create a table for subtasks with improved handling - const subtaskTable = new Table({ - head: [ - chalk.magenta.bold('ID'), - chalk.magenta.bold('Status'), - chalk.magenta.bold('Title'), - chalk.magenta.bold('Deps') - ], - colWidths: [idWidth, statusWidth, titleWidth, depsWidth], - style: { - head: [], - border: [], - 'padding-top': 0, - 'padding-bottom': 0, - compact: true - }, - chars: { - 'mid': '', 'left-mid': '', 'mid-mid': '', 'right-mid': '' - }, - wordWrap: true - }); - - // Add subtasks to table - nextTask.subtasks.forEach(st => { - const statusColor = { - 'done': chalk.green, - 'completed': chalk.green, - 'pending': chalk.yellow, - 'in-progress': chalk.blue - }[st.status || 'pending'] || chalk.white; - - // Format subtask dependencies - let subtaskDeps = 'None'; - if (st.dependencies && st.dependencies.length > 0) { - // Format dependencies with correct notation - const formattedDeps = st.dependencies.map(depId => { - if (typeof depId === 'number' && depId < 100) { - const foundSubtask = nextTask.subtasks.find(st => st.id === depId); - if (foundSubtask) { - const isDone = foundSubtask.status === 'done' || foundSubtask.status === 'completed'; - const isInProgress = foundSubtask.status === 'in-progress'; - - // Use consistent color formatting instead of emojis - if (isDone) { - return chalk.green.bold(`${nextTask.id}.${depId}`); - } else if (isInProgress) { - return chalk.hex('#FFA500').bold(`${nextTask.id}.${depId}`); - } else { - return chalk.red.bold(`${nextTask.id}.${depId}`); - } - } - return chalk.red(`${nextTask.id}.${depId} (Not found)`); - } - return depId; - }); - - // Join the formatted dependencies directly instead of passing to formatDependenciesWithStatus again - subtaskDeps = formattedDeps.length === 1 - ? formattedDeps[0] - : formattedDeps.join(chalk.white(', ')); - } - - subtaskTable.push([ - `${nextTask.id}.${st.id}`, - statusColor(st.status || 'pending'), - st.title, - subtaskDeps - ]); - }); - - console.log(subtaskTable.toString()); - } else { - // Suggest expanding if no subtasks - console.log(boxen( - chalk.yellow('No subtasks found. Consider breaking down this task:') + '\n' + - chalk.white(`Run: ${chalk.cyan(`task-master expand --id=${nextTask.id}`)}`), - { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: 'yellow', borderStyle: 'round', margin: { top: 1, bottom: 0 } } - )); - } - - // Show action suggestions - console.log(boxen( - chalk.white.bold('Suggested Actions:') + '\n' + - `${chalk.cyan('1.')} Mark as in-progress: ${chalk.yellow(`task-master set-status --id=${nextTask.id} --status=in-progress`)}\n` + - `${chalk.cyan('2.')} Mark as done when completed: ${chalk.yellow(`task-master set-status --id=${nextTask.id} --status=done`)}\n` + - (nextTask.subtasks && nextTask.subtasks.length > 0 - ? `${chalk.cyan('3.')} Update subtask status: ${chalk.yellow(`task-master set-status --id=${nextTask.id}.1 --status=done`)}` - : `${chalk.cyan('3.')} Break down into subtasks: ${chalk.yellow(`task-master expand --id=${nextTask.id}`)}`), - { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: 'green', borderStyle: 'round', margin: { top: 1 } } - )); + displayBanner(); + + // Read the tasks file + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + log('error', 'No valid tasks found.'); + process.exit(1); + } + + // Find the next task + const nextTask = findNextTask(data.tasks); + + if (!nextTask) { + console.log( + boxen( + chalk.yellow('No eligible tasks found!\n\n') + + 'All pending tasks have unsatisfied dependencies, or all tasks are completed.', + { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderColor: 'yellow', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + return; + } + + // Display the task in a nice format + console.log( + boxen(chalk.white.bold(`Next Task: #${nextTask.id} - ${nextTask.title}`), { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, bottom: 0 } + }) + ); + + // Create a table with task details + const taskTable = new Table({ + style: { + head: [], + border: [], + 'padding-top': 0, + 'padding-bottom': 0, + compact: true + }, + chars: { + mid: '', + 'left-mid': '', + 'mid-mid': '', + 'right-mid': '' + }, + colWidths: [15, Math.min(75, process.stdout.columns - 20 || 60)], + wordWrap: true + }); + + // Priority with color + const priorityColors = { + high: chalk.red.bold, + medium: chalk.yellow, + low: chalk.gray + }; + const priorityColor = + priorityColors[nextTask.priority || 'medium'] || chalk.white; + + // Add task details to table + taskTable.push( + [chalk.cyan.bold('ID:'), nextTask.id.toString()], + [chalk.cyan.bold('Title:'), nextTask.title], + [ + chalk.cyan.bold('Priority:'), + priorityColor(nextTask.priority || 'medium') + ], + [ + chalk.cyan.bold('Dependencies:'), + formatDependenciesWithStatus(nextTask.dependencies, data.tasks, true) + ], + [chalk.cyan.bold('Description:'), nextTask.description] + ); + + console.log(taskTable.toString()); + + // If task has details, show them in a separate box + if (nextTask.details && nextTask.details.trim().length > 0) { + console.log( + boxen( + chalk.white.bold('Implementation Details:') + '\n\n' + nextTask.details, + { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderColor: 'cyan', + borderStyle: 'round', + margin: { top: 1, bottom: 0 } + } + ) + ); + } + + // Show subtasks if they exist + if (nextTask.subtasks && nextTask.subtasks.length > 0) { + console.log( + boxen(chalk.white.bold('Subtasks'), { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + margin: { top: 1, bottom: 0 }, + borderColor: 'magenta', + borderStyle: 'round' + }) + ); + + // Calculate available width for the subtask table + const availableWidth = process.stdout.columns - 10 || 100; // Default to 100 if can't detect + + // Define percentage-based column widths + const idWidthPct = 8; + const statusWidthPct = 15; + const depsWidthPct = 25; + const titleWidthPct = 100 - idWidthPct - statusWidthPct - depsWidthPct; + + // Calculate actual column widths + const idWidth = Math.floor(availableWidth * (idWidthPct / 100)); + const statusWidth = Math.floor(availableWidth * (statusWidthPct / 100)); + const depsWidth = Math.floor(availableWidth * (depsWidthPct / 100)); + const titleWidth = Math.floor(availableWidth * (titleWidthPct / 100)); + + // Create a table for subtasks with improved handling + const subtaskTable = new Table({ + head: [ + chalk.magenta.bold('ID'), + chalk.magenta.bold('Status'), + chalk.magenta.bold('Title'), + chalk.magenta.bold('Deps') + ], + colWidths: [idWidth, statusWidth, titleWidth, depsWidth], + style: { + head: [], + border: [], + 'padding-top': 0, + 'padding-bottom': 0, + compact: true + }, + chars: { + mid: '', + 'left-mid': '', + 'mid-mid': '', + 'right-mid': '' + }, + wordWrap: true + }); + + // Add subtasks to table + nextTask.subtasks.forEach((st) => { + const statusColor = + { + done: chalk.green, + completed: chalk.green, + pending: chalk.yellow, + 'in-progress': chalk.blue + }[st.status || 'pending'] || chalk.white; + + // Format subtask dependencies + let subtaskDeps = 'None'; + if (st.dependencies && st.dependencies.length > 0) { + // Format dependencies with correct notation + const formattedDeps = st.dependencies.map((depId) => { + if (typeof depId === 'number' && depId < 100) { + const foundSubtask = nextTask.subtasks.find( + (st) => st.id === depId + ); + if (foundSubtask) { + const isDone = + foundSubtask.status === 'done' || + foundSubtask.status === 'completed'; + const isInProgress = foundSubtask.status === 'in-progress'; + + // Use consistent color formatting instead of emojis + if (isDone) { + return chalk.green.bold(`${nextTask.id}.${depId}`); + } else if (isInProgress) { + return chalk.hex('#FFA500').bold(`${nextTask.id}.${depId}`); + } else { + return chalk.red.bold(`${nextTask.id}.${depId}`); + } + } + return chalk.red(`${nextTask.id}.${depId} (Not found)`); + } + return depId; + }); + + // Join the formatted dependencies directly instead of passing to formatDependenciesWithStatus again + subtaskDeps = + formattedDeps.length === 1 + ? formattedDeps[0] + : formattedDeps.join(chalk.white(', ')); + } + + subtaskTable.push([ + `${nextTask.id}.${st.id}`, + statusColor(st.status || 'pending'), + st.title, + subtaskDeps + ]); + }); + + console.log(subtaskTable.toString()); + } else { + // Suggest expanding if no subtasks + console.log( + boxen( + chalk.yellow('No subtasks found. Consider breaking down this task:') + + '\n' + + chalk.white( + `Run: ${chalk.cyan(`task-master expand --id=${nextTask.id}`)}` + ), + { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderColor: 'yellow', + borderStyle: 'round', + margin: { top: 1, bottom: 0 } + } + ) + ); + } + + // Show action suggestions + console.log( + boxen( + chalk.white.bold('Suggested Actions:') + + '\n' + + `${chalk.cyan('1.')} Mark as in-progress: ${chalk.yellow(`task-master set-status --id=${nextTask.id} --status=in-progress`)}\n` + + `${chalk.cyan('2.')} Mark as done when completed: ${chalk.yellow(`task-master set-status --id=${nextTask.id} --status=done`)}\n` + + (nextTask.subtasks && nextTask.subtasks.length > 0 + ? `${chalk.cyan('3.')} Update subtask status: ${chalk.yellow(`task-master set-status --id=${nextTask.id}.1 --status=done`)}` + : `${chalk.cyan('3.')} Break down into subtasks: ${chalk.yellow(`task-master expand --id=${nextTask.id}`)}`), + { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); } /** @@ -623,253 +811,344 @@ async function displayNextTask(tasksPath) { * @param {string|number} taskId - The ID of the task to display */ async function displayTaskById(tasksPath, taskId) { - displayBanner(); - - // Read the tasks file - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - log('error', "No valid tasks found."); - process.exit(1); - } - - // Find the task by ID - const task = findTaskById(data.tasks, taskId); - - if (!task) { - console.log(boxen( - chalk.yellow(`Task with ID ${taskId} not found!`), - { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: 'yellow', borderStyle: 'round', margin: { top: 1 } } - )); - return; - } - - // Handle subtask display specially - if (task.isSubtask || task.parentTask) { - console.log(boxen( - chalk.white.bold(`Subtask: #${task.parentTask.id}.${task.id} - ${task.title}`), - { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: 'magenta', borderStyle: 'round', margin: { top: 1, bottom: 0 } } - )); - - // Create a table with subtask details - const taskTable = new Table({ - style: { - head: [], - border: [], - 'padding-top': 0, - 'padding-bottom': 0, - compact: true - }, - chars: { - 'mid': '', 'left-mid': '', 'mid-mid': '', 'right-mid': '' - }, - colWidths: [15, Math.min(75, (process.stdout.columns - 20) || 60)], - wordWrap: true - }); - - // Add subtask details to table - taskTable.push( - [chalk.cyan.bold('ID:'), `${task.parentTask.id}.${task.id}`], - [chalk.cyan.bold('Parent Task:'), `#${task.parentTask.id} - ${task.parentTask.title}`], - [chalk.cyan.bold('Title:'), task.title], - [chalk.cyan.bold('Status:'), getStatusWithColor(task.status || 'pending', true)], - [chalk.cyan.bold('Description:'), task.description || 'No description provided.'] - ); - - console.log(taskTable.toString()); - - // Show details if they exist for subtasks - if (task.details && task.details.trim().length > 0) { - console.log(boxen( - chalk.white.bold('Implementation Details:') + '\n\n' + - task.details, - { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: 'cyan', borderStyle: 'round', margin: { top: 1, bottom: 0 } } - )); - } - - // Show action suggestions for subtask - console.log(boxen( - chalk.white.bold('Suggested Actions:') + '\n' + - `${chalk.cyan('1.')} Mark as in-progress: ${chalk.yellow(`task-master set-status --id=${task.parentTask.id}.${task.id} --status=in-progress`)}\n` + - `${chalk.cyan('2.')} Mark as done when completed: ${chalk.yellow(`task-master set-status --id=${task.parentTask.id}.${task.id} --status=done`)}\n` + - `${chalk.cyan('3.')} View parent task: ${chalk.yellow(`task-master show --id=${task.parentTask.id}`)}`, - { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: 'green', borderStyle: 'round', margin: { top: 1 } } - )); - - return; - } - - // Display a regular task - console.log(boxen( - chalk.white.bold(`Task: #${task.id} - ${task.title}`), - { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 0 } } - )); - - // Create a table with task details with improved handling - const taskTable = new Table({ - style: { - head: [], - border: [], - 'padding-top': 0, - 'padding-bottom': 0, - compact: true - }, - chars: { - 'mid': '', 'left-mid': '', 'mid-mid': '', 'right-mid': '' - }, - colWidths: [15, Math.min(75, (process.stdout.columns - 20) || 60)], - wordWrap: true - }); - - // Priority with color - const priorityColors = { - 'high': chalk.red.bold, - 'medium': chalk.yellow, - 'low': chalk.gray - }; - const priorityColor = priorityColors[task.priority || 'medium'] || chalk.white; - - // Add task details to table - taskTable.push( - [chalk.cyan.bold('ID:'), task.id.toString()], - [chalk.cyan.bold('Title:'), task.title], - [chalk.cyan.bold('Status:'), getStatusWithColor(task.status || 'pending', true)], - [chalk.cyan.bold('Priority:'), priorityColor(task.priority || 'medium')], - [chalk.cyan.bold('Dependencies:'), formatDependenciesWithStatus(task.dependencies, data.tasks, true)], - [chalk.cyan.bold('Description:'), task.description] - ); - - console.log(taskTable.toString()); - - // If task has details, show them in a separate box - if (task.details && task.details.trim().length > 0) { - console.log(boxen( - chalk.white.bold('Implementation Details:') + '\n\n' + - task.details, - { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: 'cyan', borderStyle: 'round', margin: { top: 1, bottom: 0 } } - )); - } - - // Show test strategy if available - if (task.testStrategy && task.testStrategy.trim().length > 0) { - console.log(boxen( - chalk.white.bold('Test Strategy:') + '\n\n' + - task.testStrategy, - { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: 'cyan', borderStyle: 'round', margin: { top: 1, bottom: 0 } } - )); - } - - // Show subtasks if they exist - if (task.subtasks && task.subtasks.length > 0) { - console.log(boxen( - chalk.white.bold('Subtasks'), - { padding: { top: 0, bottom: 0, left: 1, right: 1 }, margin: { top: 1, bottom: 0 }, borderColor: 'magenta', borderStyle: 'round' } - )); - - // Calculate available width for the subtask table - const availableWidth = process.stdout.columns - 10 || 100; // Default to 100 if can't detect - - // Define percentage-based column widths - const idWidthPct = 10; - const statusWidthPct = 15; - const depsWidthPct = 25; - const titleWidthPct = 100 - idWidthPct - statusWidthPct - depsWidthPct; - - // Calculate actual column widths - const idWidth = Math.floor(availableWidth * (idWidthPct / 100)); - const statusWidth = Math.floor(availableWidth * (statusWidthPct / 100)); - const depsWidth = Math.floor(availableWidth * (depsWidthPct / 100)); - const titleWidth = Math.floor(availableWidth * (titleWidthPct / 100)); - - // Create a table for subtasks with improved handling - const subtaskTable = new Table({ - head: [ - chalk.magenta.bold('ID'), - chalk.magenta.bold('Status'), - chalk.magenta.bold('Title'), - chalk.magenta.bold('Deps') - ], - colWidths: [idWidth, statusWidth, titleWidth, depsWidth], - style: { - head: [], - border: [], - 'padding-top': 0, - 'padding-bottom': 0, - compact: true - }, - chars: { - 'mid': '', 'left-mid': '', 'mid-mid': '', 'right-mid': '' - }, - wordWrap: true - }); - - // Add subtasks to table - task.subtasks.forEach(st => { - const statusColor = { - 'done': chalk.green, - 'completed': chalk.green, - 'pending': chalk.yellow, - 'in-progress': chalk.blue - }[st.status || 'pending'] || chalk.white; - - // Format subtask dependencies - let subtaskDeps = 'None'; - if (st.dependencies && st.dependencies.length > 0) { - // Format dependencies with correct notation - const formattedDeps = st.dependencies.map(depId => { - if (typeof depId === 'number' && depId < 100) { - const foundSubtask = task.subtasks.find(st => st.id === depId); - if (foundSubtask) { - const isDone = foundSubtask.status === 'done' || foundSubtask.status === 'completed'; - const isInProgress = foundSubtask.status === 'in-progress'; - - // Use consistent color formatting instead of emojis - if (isDone) { - return chalk.green.bold(`${task.id}.${depId}`); - } else if (isInProgress) { - return chalk.hex('#FFA500').bold(`${task.id}.${depId}`); - } else { - return chalk.red.bold(`${task.id}.${depId}`); - } - } - return chalk.red(`${task.id}.${depId} (Not found)`); - } - return depId; - }); - - // Join the formatted dependencies directly instead of passing to formatDependenciesWithStatus again - subtaskDeps = formattedDeps.length === 1 - ? formattedDeps[0] - : formattedDeps.join(chalk.white(', ')); - } - - subtaskTable.push([ - `${task.id}.${st.id}`, - statusColor(st.status || 'pending'), - st.title, - subtaskDeps - ]); - }); - - console.log(subtaskTable.toString()); - } else { - // Suggest expanding if no subtasks - console.log(boxen( - chalk.yellow('No subtasks found. Consider breaking down this task:') + '\n' + - chalk.white(`Run: ${chalk.cyan(`task-master expand --id=${task.id}`)}`), - { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: 'yellow', borderStyle: 'round', margin: { top: 1, bottom: 0 } } - )); - } - - // Show action suggestions - console.log(boxen( - chalk.white.bold('Suggested Actions:') + '\n' + - `${chalk.cyan('1.')} Mark as in-progress: ${chalk.yellow(`task-master set-status --id=${task.id} --status=in-progress`)}\n` + - `${chalk.cyan('2.')} Mark as done when completed: ${chalk.yellow(`task-master set-status --id=${task.id} --status=done`)}\n` + - (task.subtasks && task.subtasks.length > 0 - ? `${chalk.cyan('3.')} Update subtask status: ${chalk.yellow(`task-master set-status --id=${task.id}.1 --status=done`)}` - : `${chalk.cyan('3.')} Break down into subtasks: ${chalk.yellow(`task-master expand --id=${task.id}`)}`), - { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderColor: 'green', borderStyle: 'round', margin: { top: 1 } } - )); + displayBanner(); + + // Read the tasks file + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + log('error', 'No valid tasks found.'); + process.exit(1); + } + + // Find the task by ID + const task = findTaskById(data.tasks, taskId); + + if (!task) { + console.log( + boxen(chalk.yellow(`Task with ID ${taskId} not found!`), { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderColor: 'yellow', + borderStyle: 'round', + margin: { top: 1 } + }) + ); + return; + } + + // Handle subtask display specially + if (task.isSubtask || task.parentTask) { + console.log( + boxen( + chalk.white.bold( + `Subtask: #${task.parentTask.id}.${task.id} - ${task.title}` + ), + { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderColor: 'magenta', + borderStyle: 'round', + margin: { top: 1, bottom: 0 } + } + ) + ); + + // Create a table with subtask details + const taskTable = new Table({ + style: { + head: [], + border: [], + 'padding-top': 0, + 'padding-bottom': 0, + compact: true + }, + chars: { + mid: '', + 'left-mid': '', + 'mid-mid': '', + 'right-mid': '' + }, + colWidths: [15, Math.min(75, process.stdout.columns - 20 || 60)], + wordWrap: true + }); + + // Add subtask details to table + taskTable.push( + [chalk.cyan.bold('ID:'), `${task.parentTask.id}.${task.id}`], + [ + chalk.cyan.bold('Parent Task:'), + `#${task.parentTask.id} - ${task.parentTask.title}` + ], + [chalk.cyan.bold('Title:'), task.title], + [ + chalk.cyan.bold('Status:'), + getStatusWithColor(task.status || 'pending', true) + ], + [ + chalk.cyan.bold('Description:'), + task.description || 'No description provided.' + ] + ); + + console.log(taskTable.toString()); + + // Show details if they exist for subtasks + if (task.details && task.details.trim().length > 0) { + console.log( + boxen( + chalk.white.bold('Implementation Details:') + '\n\n' + task.details, + { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderColor: 'cyan', + borderStyle: 'round', + margin: { top: 1, bottom: 0 } + } + ) + ); + } + + // Show action suggestions for subtask + console.log( + boxen( + chalk.white.bold('Suggested Actions:') + + '\n' + + `${chalk.cyan('1.')} Mark as in-progress: ${chalk.yellow(`task-master set-status --id=${task.parentTask.id}.${task.id} --status=in-progress`)}\n` + + `${chalk.cyan('2.')} Mark as done when completed: ${chalk.yellow(`task-master set-status --id=${task.parentTask.id}.${task.id} --status=done`)}\n` + + `${chalk.cyan('3.')} View parent task: ${chalk.yellow(`task-master show --id=${task.parentTask.id}`)}`, + { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + + return; + } + + // Display a regular task + console.log( + boxen(chalk.white.bold(`Task: #${task.id} - ${task.title}`), { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, bottom: 0 } + }) + ); + + // Create a table with task details with improved handling + const taskTable = new Table({ + style: { + head: [], + border: [], + 'padding-top': 0, + 'padding-bottom': 0, + compact: true + }, + chars: { + mid: '', + 'left-mid': '', + 'mid-mid': '', + 'right-mid': '' + }, + colWidths: [15, Math.min(75, process.stdout.columns - 20 || 60)], + wordWrap: true + }); + + // Priority with color + const priorityColors = { + high: chalk.red.bold, + medium: chalk.yellow, + low: chalk.gray + }; + const priorityColor = + priorityColors[task.priority || 'medium'] || chalk.white; + + // Add task details to table + taskTable.push( + [chalk.cyan.bold('ID:'), task.id.toString()], + [chalk.cyan.bold('Title:'), task.title], + [ + chalk.cyan.bold('Status:'), + getStatusWithColor(task.status || 'pending', true) + ], + [chalk.cyan.bold('Priority:'), priorityColor(task.priority || 'medium')], + [ + chalk.cyan.bold('Dependencies:'), + formatDependenciesWithStatus(task.dependencies, data.tasks, true) + ], + [chalk.cyan.bold('Description:'), task.description] + ); + + console.log(taskTable.toString()); + + // If task has details, show them in a separate box + if (task.details && task.details.trim().length > 0) { + console.log( + boxen( + chalk.white.bold('Implementation Details:') + '\n\n' + task.details, + { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderColor: 'cyan', + borderStyle: 'round', + margin: { top: 1, bottom: 0 } + } + ) + ); + } + + // Show test strategy if available + if (task.testStrategy && task.testStrategy.trim().length > 0) { + console.log( + boxen(chalk.white.bold('Test Strategy:') + '\n\n' + task.testStrategy, { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderColor: 'cyan', + borderStyle: 'round', + margin: { top: 1, bottom: 0 } + }) + ); + } + + // Show subtasks if they exist + if (task.subtasks && task.subtasks.length > 0) { + console.log( + boxen(chalk.white.bold('Subtasks'), { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + margin: { top: 1, bottom: 0 }, + borderColor: 'magenta', + borderStyle: 'round' + }) + ); + + // Calculate available width for the subtask table + const availableWidth = process.stdout.columns - 10 || 100; // Default to 100 if can't detect + + // Define percentage-based column widths + const idWidthPct = 10; + const statusWidthPct = 15; + const depsWidthPct = 25; + const titleWidthPct = 100 - idWidthPct - statusWidthPct - depsWidthPct; + + // Calculate actual column widths + const idWidth = Math.floor(availableWidth * (idWidthPct / 100)); + const statusWidth = Math.floor(availableWidth * (statusWidthPct / 100)); + const depsWidth = Math.floor(availableWidth * (depsWidthPct / 100)); + const titleWidth = Math.floor(availableWidth * (titleWidthPct / 100)); + + // Create a table for subtasks with improved handling + const subtaskTable = new Table({ + head: [ + chalk.magenta.bold('ID'), + chalk.magenta.bold('Status'), + chalk.magenta.bold('Title'), + chalk.magenta.bold('Deps') + ], + colWidths: [idWidth, statusWidth, titleWidth, depsWidth], + style: { + head: [], + border: [], + 'padding-top': 0, + 'padding-bottom': 0, + compact: true + }, + chars: { + mid: '', + 'left-mid': '', + 'mid-mid': '', + 'right-mid': '' + }, + wordWrap: true + }); + + // Add subtasks to table + task.subtasks.forEach((st) => { + const statusColor = + { + done: chalk.green, + completed: chalk.green, + pending: chalk.yellow, + 'in-progress': chalk.blue + }[st.status || 'pending'] || chalk.white; + + // Format subtask dependencies + let subtaskDeps = 'None'; + if (st.dependencies && st.dependencies.length > 0) { + // Format dependencies with correct notation + const formattedDeps = st.dependencies.map((depId) => { + if (typeof depId === 'number' && depId < 100) { + const foundSubtask = task.subtasks.find((st) => st.id === depId); + if (foundSubtask) { + const isDone = + foundSubtask.status === 'done' || + foundSubtask.status === 'completed'; + const isInProgress = foundSubtask.status === 'in-progress'; + + // Use consistent color formatting instead of emojis + if (isDone) { + return chalk.green.bold(`${task.id}.${depId}`); + } else if (isInProgress) { + return chalk.hex('#FFA500').bold(`${task.id}.${depId}`); + } else { + return chalk.red.bold(`${task.id}.${depId}`); + } + } + return chalk.red(`${task.id}.${depId} (Not found)`); + } + return depId; + }); + + // Join the formatted dependencies directly instead of passing to formatDependenciesWithStatus again + subtaskDeps = + formattedDeps.length === 1 + ? formattedDeps[0] + : formattedDeps.join(chalk.white(', ')); + } + + subtaskTable.push([ + `${task.id}.${st.id}`, + statusColor(st.status || 'pending'), + st.title, + subtaskDeps + ]); + }); + + console.log(subtaskTable.toString()); + } else { + // Suggest expanding if no subtasks + console.log( + boxen( + chalk.yellow('No subtasks found. Consider breaking down this task:') + + '\n' + + chalk.white( + `Run: ${chalk.cyan(`task-master expand --id=${task.id}`)}` + ), + { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderColor: 'yellow', + borderStyle: 'round', + margin: { top: 1, bottom: 0 } + } + ) + ); + } + + // Show action suggestions + console.log( + boxen( + chalk.white.bold('Suggested Actions:') + + '\n' + + `${chalk.cyan('1.')} Mark as in-progress: ${chalk.yellow(`task-master set-status --id=${task.id} --status=in-progress`)}\n` + + `${chalk.cyan('2.')} Mark as done when completed: ${chalk.yellow(`task-master set-status --id=${task.id} --status=done`)}\n` + + (task.subtasks && task.subtasks.length > 0 + ? `${chalk.cyan('3.')} Update subtask status: ${chalk.yellow(`task-master set-status --id=${task.id}.1 --status=done`)}` + : `${chalk.cyan('3.')} Break down into subtasks: ${chalk.yellow(`task-master expand --id=${task.id}`)}`), + { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); } /** @@ -877,188 +1156,243 @@ async function displayTaskById(tasksPath, taskId) { * @param {string} reportPath - Path to the complexity report file */ async function displayComplexityReport(reportPath) { - displayBanner(); - - // Check if the report exists - if (!fs.existsSync(reportPath)) { - console.log(boxen( - chalk.yellow(`No complexity report found at ${reportPath}\n\n`) + - 'Would you like to generate one now?', - { padding: 1, borderColor: 'yellow', borderStyle: 'round', margin: { top: 1 } } - )); - - const readline = require('readline').createInterface({ - input: process.stdin, - output: process.stdout - }); - - const answer = await new Promise(resolve => { - readline.question(chalk.cyan('Generate complexity report? (y/n): '), resolve); - }); - readline.close(); - - if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') { - // Call the analyze-complexity command - console.log(chalk.blue('Generating complexity report...')); - await analyzeTaskComplexity({ - output: reportPath, - research: false, // Default to no research for speed - file: 'tasks/tasks.json' - }); - // Read the newly generated report - return displayComplexityReport(reportPath); - } else { - console.log(chalk.yellow('Report generation cancelled.')); - return; - } - } - - // Read the report - let report; - try { - report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); - } catch (error) { - log('error', `Error reading complexity report: ${error.message}`); - return; - } - - // Display report header - console.log(boxen( - chalk.white.bold('Task Complexity Analysis Report'), - { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 1 } } - )); - - // Display metadata - const metaTable = new Table({ - style: { - head: [], - border: [], - 'padding-top': 0, - 'padding-bottom': 0, - compact: true - }, - chars: { - 'mid': '', 'left-mid': '', 'mid-mid': '', 'right-mid': '' - }, - colWidths: [20, 50] - }); - - metaTable.push( - [chalk.cyan.bold('Generated:'), new Date(report.meta.generatedAt).toLocaleString()], - [chalk.cyan.bold('Tasks Analyzed:'), report.meta.tasksAnalyzed], - [chalk.cyan.bold('Threshold Score:'), report.meta.thresholdScore], - [chalk.cyan.bold('Project:'), report.meta.projectName], - [chalk.cyan.bold('Research-backed:'), report.meta.usedResearch ? 'Yes' : 'No'] - ); - - console.log(metaTable.toString()); - - // Sort tasks by complexity score (highest first) - const sortedTasks = [...report.complexityAnalysis].sort((a, b) => b.complexityScore - a.complexityScore); - - // Determine which tasks need expansion based on threshold - const tasksNeedingExpansion = sortedTasks.filter(task => task.complexityScore >= report.meta.thresholdScore); - const simpleTasks = sortedTasks.filter(task => task.complexityScore < report.meta.thresholdScore); - - // Create progress bar to show complexity distribution - const complexityDistribution = [0, 0, 0]; // Low (0-4), Medium (5-7), High (8-10) - sortedTasks.forEach(task => { - if (task.complexityScore < 5) complexityDistribution[0]++; - else if (task.complexityScore < 8) complexityDistribution[1]++; - else complexityDistribution[2]++; - }); - - const percentLow = Math.round((complexityDistribution[0] / sortedTasks.length) * 100); - const percentMedium = Math.round((complexityDistribution[1] / sortedTasks.length) * 100); - const percentHigh = Math.round((complexityDistribution[2] / sortedTasks.length) * 100); - - console.log(boxen( - chalk.white.bold('Complexity Distribution\n\n') + - `${chalk.green.bold('Low (1-4):')} ${complexityDistribution[0]} tasks (${percentLow}%)\n` + - `${chalk.yellow.bold('Medium (5-7):')} ${complexityDistribution[1]} tasks (${percentMedium}%)\n` + - `${chalk.red.bold('High (8-10):')} ${complexityDistribution[2]} tasks (${percentHigh}%)`, - { padding: 1, borderColor: 'cyan', borderStyle: 'round', margin: { top: 1, bottom: 1 } } - )); - - // Get terminal width - const terminalWidth = process.stdout.columns || 100; // Default to 100 if can't detect + displayBanner(); - // Calculate dynamic column widths - const idWidth = 12; - const titleWidth = Math.floor(terminalWidth * 0.25); // 25% of width - const scoreWidth = 8; - const subtasksWidth = 8; - // Command column gets the remaining space (minus some buffer for borders) - const commandWidth = terminalWidth - idWidth - titleWidth - scoreWidth - subtasksWidth - 10; + // Check if the report exists + if (!fs.existsSync(reportPath)) { + console.log( + boxen( + chalk.yellow(`No complexity report found at ${reportPath}\n\n`) + + 'Would you like to generate one now?', + { + padding: 1, + borderColor: 'yellow', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); - // Create table with new column widths and word wrapping - const complexTable = new Table({ - head: [ - chalk.yellow.bold('ID'), - chalk.yellow.bold('Title'), - chalk.yellow.bold('Score'), - chalk.yellow.bold('Subtasks'), - chalk.yellow.bold('Expansion Command') - ], - colWidths: [idWidth, titleWidth, scoreWidth, subtasksWidth, commandWidth], - style: { head: [], border: [] }, - wordWrap: true, - wrapOnWordBoundary: true - }); + const readline = require('readline').createInterface({ + input: process.stdin, + output: process.stdout + }); - // When adding rows, don't truncate the expansion command - tasksNeedingExpansion.forEach(task => { - const expansionCommand = `task-master expand --id=${task.taskId} --num=${task.recommendedSubtasks}${task.expansionPrompt ? ` --prompt="${task.expansionPrompt}"` : ''}`; - - complexTable.push([ - task.taskId, - truncate(task.taskTitle, titleWidth - 3), // Still truncate title for readability - getComplexityWithColor(task.complexityScore), - task.recommendedSubtasks, - chalk.cyan(expansionCommand) // Don't truncate - allow wrapping - ]); - }); - - console.log(complexTable.toString()); - - // Create table for simple tasks - if (simpleTasks.length > 0) { - console.log(boxen( - chalk.green.bold(`Simple Tasks (${simpleTasks.length})`), - { padding: { left: 2, right: 2, top: 0, bottom: 0 }, margin: { top: 1, bottom: 0 }, borderColor: 'green', borderStyle: 'round' } - )); - - const simpleTable = new Table({ - head: [ - chalk.green.bold('ID'), - chalk.green.bold('Title'), - chalk.green.bold('Score'), - chalk.green.bold('Reasoning') - ], - colWidths: [5, 40, 8, 50], - style: { head: [], border: [] } - }); - - simpleTasks.forEach(task => { - simpleTable.push([ - task.taskId, - truncate(task.taskTitle, 37), - getComplexityWithColor(task.complexityScore), - truncate(task.reasoning, 47) - ]); - }); - - console.log(simpleTable.toString()); - } - - // Show action suggestions - console.log(boxen( - chalk.white.bold('Suggested Actions:') + '\n\n' + - `${chalk.cyan('1.')} Expand all complex tasks: ${chalk.yellow(`task-master expand --all`)}\n` + - `${chalk.cyan('2.')} Expand a specific task: ${chalk.yellow(`task-master expand --id=<id>`)}\n` + - `${chalk.cyan('3.')} Regenerate with research: ${chalk.yellow(`task-master analyze-complexity --research`)}`, - { padding: 1, borderColor: 'cyan', borderStyle: 'round', margin: { top: 1 } } - )); + const answer = await new Promise((resolve) => { + readline.question( + chalk.cyan('Generate complexity report? (y/n): '), + resolve + ); + }); + readline.close(); + + if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') { + // Call the analyze-complexity command + console.log(chalk.blue('Generating complexity report...')); + await analyzeTaskComplexity({ + output: reportPath, + research: false, // Default to no research for speed + file: 'tasks/tasks.json' + }); + // Read the newly generated report + return displayComplexityReport(reportPath); + } else { + console.log(chalk.yellow('Report generation cancelled.')); + return; + } + } + + // Read the report + let report; + try { + report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + } catch (error) { + log('error', `Error reading complexity report: ${error.message}`); + return; + } + + // Display report header + console.log( + boxen(chalk.white.bold('Task Complexity Analysis Report'), { + padding: 1, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + }) + ); + + // Display metadata + const metaTable = new Table({ + style: { + head: [], + border: [], + 'padding-top': 0, + 'padding-bottom': 0, + compact: true + }, + chars: { + mid: '', + 'left-mid': '', + 'mid-mid': '', + 'right-mid': '' + }, + colWidths: [20, 50] + }); + + metaTable.push( + [ + chalk.cyan.bold('Generated:'), + new Date(report.meta.generatedAt).toLocaleString() + ], + [chalk.cyan.bold('Tasks Analyzed:'), report.meta.tasksAnalyzed], + [chalk.cyan.bold('Threshold Score:'), report.meta.thresholdScore], + [chalk.cyan.bold('Project:'), report.meta.projectName], + [ + chalk.cyan.bold('Research-backed:'), + report.meta.usedResearch ? 'Yes' : 'No' + ] + ); + + console.log(metaTable.toString()); + + // Sort tasks by complexity score (highest first) + const sortedTasks = [...report.complexityAnalysis].sort( + (a, b) => b.complexityScore - a.complexityScore + ); + + // Determine which tasks need expansion based on threshold + const tasksNeedingExpansion = sortedTasks.filter( + (task) => task.complexityScore >= report.meta.thresholdScore + ); + const simpleTasks = sortedTasks.filter( + (task) => task.complexityScore < report.meta.thresholdScore + ); + + // Create progress bar to show complexity distribution + const complexityDistribution = [0, 0, 0]; // Low (0-4), Medium (5-7), High (8-10) + sortedTasks.forEach((task) => { + if (task.complexityScore < 5) complexityDistribution[0]++; + else if (task.complexityScore < 8) complexityDistribution[1]++; + else complexityDistribution[2]++; + }); + + const percentLow = Math.round( + (complexityDistribution[0] / sortedTasks.length) * 100 + ); + const percentMedium = Math.round( + (complexityDistribution[1] / sortedTasks.length) * 100 + ); + const percentHigh = Math.round( + (complexityDistribution[2] / sortedTasks.length) * 100 + ); + + console.log( + boxen( + chalk.white.bold('Complexity Distribution\n\n') + + `${chalk.green.bold('Low (1-4):')} ${complexityDistribution[0]} tasks (${percentLow}%)\n` + + `${chalk.yellow.bold('Medium (5-7):')} ${complexityDistribution[1]} tasks (${percentMedium}%)\n` + + `${chalk.red.bold('High (8-10):')} ${complexityDistribution[2]} tasks (${percentHigh}%)`, + { + padding: 1, + borderColor: 'cyan', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + } + ) + ); + + // Get terminal width + const terminalWidth = process.stdout.columns || 100; // Default to 100 if can't detect + + // Calculate dynamic column widths + const idWidth = 12; + const titleWidth = Math.floor(terminalWidth * 0.25); // 25% of width + const scoreWidth = 8; + const subtasksWidth = 8; + // Command column gets the remaining space (minus some buffer for borders) + const commandWidth = + terminalWidth - idWidth - titleWidth - scoreWidth - subtasksWidth - 10; + + // Create table with new column widths and word wrapping + const complexTable = new Table({ + head: [ + chalk.yellow.bold('ID'), + chalk.yellow.bold('Title'), + chalk.yellow.bold('Score'), + chalk.yellow.bold('Subtasks'), + chalk.yellow.bold('Expansion Command') + ], + colWidths: [idWidth, titleWidth, scoreWidth, subtasksWidth, commandWidth], + style: { head: [], border: [] }, + wordWrap: true, + wrapOnWordBoundary: true + }); + + // When adding rows, don't truncate the expansion command + tasksNeedingExpansion.forEach((task) => { + const expansionCommand = `task-master expand --id=${task.taskId} --num=${task.recommendedSubtasks}${task.expansionPrompt ? ` --prompt="${task.expansionPrompt}"` : ''}`; + + complexTable.push([ + task.taskId, + truncate(task.taskTitle, titleWidth - 3), // Still truncate title for readability + getComplexityWithColor(task.complexityScore), + task.recommendedSubtasks, + chalk.cyan(expansionCommand) // Don't truncate - allow wrapping + ]); + }); + + console.log(complexTable.toString()); + + // Create table for simple tasks + if (simpleTasks.length > 0) { + console.log( + boxen(chalk.green.bold(`Simple Tasks (${simpleTasks.length})`), { + padding: { left: 2, right: 2, top: 0, bottom: 0 }, + margin: { top: 1, bottom: 0 }, + borderColor: 'green', + borderStyle: 'round' + }) + ); + + const simpleTable = new Table({ + head: [ + chalk.green.bold('ID'), + chalk.green.bold('Title'), + chalk.green.bold('Score'), + chalk.green.bold('Reasoning') + ], + colWidths: [5, 40, 8, 50], + style: { head: [], border: [] } + }); + + simpleTasks.forEach((task) => { + simpleTable.push([ + task.taskId, + truncate(task.taskTitle, 37), + getComplexityWithColor(task.complexityScore), + truncate(task.reasoning, 47) + ]); + }); + + console.log(simpleTable.toString()); + } + + // Show action suggestions + console.log( + boxen( + chalk.white.bold('Suggested Actions:') + + '\n\n' + + `${chalk.cyan('1.')} Expand all complex tasks: ${chalk.yellow(`task-master expand --all`)}\n` + + `${chalk.cyan('2.')} Expand a specific task: ${chalk.yellow(`task-master expand --id=<id>`)}\n` + + `${chalk.cyan('3.')} Regenerate with research: ${chalk.yellow(`task-master analyze-complexity --research`)}`, + { + padding: 1, + borderColor: 'cyan', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); } /** @@ -1067,39 +1401,53 @@ async function displayComplexityReport(reportPath) { * @returns {Promise<boolean>} - Promise resolving to true if user confirms, false otherwise */ async function confirmTaskOverwrite(tasksPath) { - console.log(boxen( - chalk.yellow('It looks like you\'ve already generated tasks for this project.\n') + - chalk.yellow('Executing this command will overwrite any existing tasks.'), - { padding: 1, borderColor: 'yellow', borderStyle: 'round', margin: { top: 1 } } - )); - - // Use dynamic import to get the readline module - const readline = await import('readline'); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); - - const answer = await new Promise(resolve => { - rl.question(chalk.cyan('Are you sure you wish to continue? (y/N): '), resolve); - }); - rl.close(); - - return answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'; + console.log( + boxen( + chalk.yellow( + "It looks like you've already generated tasks for this project.\n" + ) + + chalk.yellow( + 'Executing this command will overwrite any existing tasks.' + ), + { + padding: 1, + borderColor: 'yellow', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + + // Use dynamic import to get the readline module + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + const answer = await new Promise((resolve) => { + rl.question( + chalk.cyan('Are you sure you wish to continue? (y/N): '), + resolve + ); + }); + rl.close(); + + return answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'; } // Export UI functions export { - displayBanner, - startLoadingIndicator, - stopLoadingIndicator, - createProgressBar, - getStatusWithColor, - formatDependenciesWithStatus, - displayHelp, - getComplexityWithColor, - displayNextTask, - displayTaskById, - displayComplexityReport, - confirmTaskOverwrite -}; \ No newline at end of file + displayBanner, + startLoadingIndicator, + stopLoadingIndicator, + createProgressBar, + getStatusWithColor, + formatDependenciesWithStatus, + displayHelp, + getComplexityWithColor, + displayNextTask, + displayTaskById, + displayComplexityReport, + confirmTaskOverwrite +}; diff --git a/scripts/modules/utils.js b/scripts/modules/utils.js index 46ed49db..0da5b865 100644 --- a/scripts/modules/utils.js +++ b/scripts/modules/utils.js @@ -9,23 +9,23 @@ 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 + 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 + debug: 0, + info: 1, + warn: 2, + error: 3 }; /** @@ -34,18 +34,18 @@ const LOG_LEVELS = { * @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(' ')}`); - } + 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(' ')}`); + } } /** @@ -54,16 +54,16 @@ function log(level, ...args) { * @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; - } + 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; + } } /** @@ -72,14 +72,14 @@ function readJSON(filepath) { * @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); - } - } + 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); + } + } } /** @@ -88,8 +88,8 @@ function writeJSON(filepath, data) { * @returns {string} Sanitized prompt */ function sanitizePrompt(prompt) { - // Replace double quotes with escaped double quotes - return prompt.replace(/"/g, '\\"'); + // Replace double quotes with escaped double quotes + return prompt.replace(/"/g, '\\"'); } /** @@ -98,18 +98,20 @@ function sanitizePrompt(prompt) { * @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; - } + 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; + } } /** @@ -119,11 +121,15 @@ function readComplexityReport(customPath = null) { * @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); + if ( + !report || + !report.complexityAnalysis || + !Array.isArray(report.complexityAnalysis) + ) { + return null; + } + + return report.complexityAnalysis.find((task) => task.taskId === taskId); } /** @@ -133,24 +139,26 @@ function findTaskInComplexityReport(report, taskId) { * @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); + 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); } /** @@ -159,15 +167,15 @@ function taskExists(tasks, taskId) { * @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; + 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; } /** @@ -177,35 +185,37 @@ function formatTaskId(id) { * @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; + 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; } /** @@ -215,11 +225,11 @@ function findTaskById(tasks, taskId) { * @returns {string} The truncated text */ function truncate(text, maxLength) { - if (!text || text.length <= maxLength) { - return text; - } - - return text.slice(0, maxLength - 3) + '...'; + if (!text || text.length <= maxLength) { + return text; + } + + return text.slice(0, maxLength - 3) + '...'; } /** @@ -230,39 +240,47 @@ function truncate(text, maxLength) { * @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; +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; } /** @@ -271,23 +289,23 @@ function findCycles(subtaskId, dependencyMap, visited = new Set(), recursionStac * @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 + // 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 }; /** @@ -296,46 +314,46 @@ const toKebabCase = (str) => { * @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; + 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 -}; \ No newline at end of file + CONFIG, + LOG_LEVELS, + log, + readJSON, + writeJSON, + sanitizePrompt, + readComplexityReport, + findTaskInComplexityReport, + taskExists, + formatTaskId, + findTaskById, + truncate, + findCycles, + toKebabCase, + detectCamelCaseFlags +}; diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js index 095f9ed5..d6241689 100755 --- a/scripts/prepare-package.js +++ b/scripts/prepare-package.js @@ -3,7 +3,7 @@ /** * This script prepares the package for publication to NPM. * It ensures all necessary files are included and properly configured. - * + * * Additional options: * --patch: Increment patch version (default) * --minor: Increment minor version @@ -22,175 +22,189 @@ const __dirname = dirname(__filename); // Define colors for console output const COLORS = { - reset: '\x1b[0m', - bright: '\x1b[1m', - dim: '\x1b[2m', - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - magenta: '\x1b[35m', - cyan: '\x1b[36m' + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m' }; // Parse command line arguments const args = process.argv.slice(2); -const versionBump = args.includes('--major') ? 'major' : - args.includes('--minor') ? 'minor' : - 'patch'; +const versionBump = args.includes('--major') + ? 'major' + : args.includes('--minor') + ? 'minor' + : 'patch'; // Check for explicit version -const versionArg = args.find(arg => arg.startsWith('--version=')); +const versionArg = args.find((arg) => arg.startsWith('--version=')); const explicitVersion = versionArg ? versionArg.split('=')[1] : null; // Log function with color support function log(level, ...args) { - const prefix = { - info: `${COLORS.blue}[INFO]${COLORS.reset}`, - warn: `${COLORS.yellow}[WARN]${COLORS.reset}`, - error: `${COLORS.red}[ERROR]${COLORS.reset}`, - success: `${COLORS.green}[SUCCESS]${COLORS.reset}` - }[level.toLowerCase()]; - - console.log(prefix, ...args); + const prefix = { + info: `${COLORS.blue}[INFO]${COLORS.reset}`, + warn: `${COLORS.yellow}[WARN]${COLORS.reset}`, + error: `${COLORS.red}[ERROR]${COLORS.reset}`, + success: `${COLORS.green}[SUCCESS]${COLORS.reset}` + }[level.toLowerCase()]; + + console.log(prefix, ...args); } // Function to check if a file exists function fileExists(filePath) { - return fs.existsSync(filePath); + return fs.existsSync(filePath); } // Function to ensure a file is executable function ensureExecutable(filePath) { - try { - fs.chmodSync(filePath, '755'); - log('info', `Made ${filePath} executable`); - } catch (error) { - log('error', `Failed to make ${filePath} executable:`, error.message); - return false; - } - return true; + try { + fs.chmodSync(filePath, '755'); + log('info', `Made ${filePath} executable`); + } catch (error) { + log('error', `Failed to make ${filePath} executable:`, error.message); + return false; + } + return true; } // Function to sync template files function syncTemplateFiles() { - // We no longer need to sync files since we're using them directly - log('info', 'Template syncing has been deprecated - using source files directly'); - return true; + // We no longer need to sync files since we're using them directly + log( + 'info', + 'Template syncing has been deprecated - using source files directly' + ); + return true; } // Function to increment version function incrementVersion(currentVersion, type = 'patch') { - const [major, minor, patch] = currentVersion.split('.').map(Number); - - switch (type) { - case 'major': - return `${major + 1}.0.0`; - case 'minor': - return `${major}.${minor + 1}.0`; - case 'patch': - default: - return `${major}.${minor}.${patch + 1}`; - } + const [major, minor, patch] = currentVersion.split('.').map(Number); + + switch (type) { + case 'major': + return `${major + 1}.0.0`; + case 'minor': + return `${major}.${minor + 1}.0`; + case 'patch': + default: + return `${major}.${minor}.${patch + 1}`; + } } // Main function to prepare the package function preparePackage() { - const rootDir = path.join(__dirname, '..'); - log('info', `Preparing package in ${rootDir}`); - - // Update version in package.json - const packageJsonPath = path.join(rootDir, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - const currentVersion = packageJson.version; - - let newVersion; - if (explicitVersion) { - newVersion = explicitVersion; - log('info', `Setting version to specified ${newVersion} (was ${currentVersion})`); - } else { - newVersion = incrementVersion(currentVersion, versionBump); - log('info', `Incrementing ${versionBump} version to ${newVersion} (was ${currentVersion})`); - } - - packageJson.version = newVersion; - fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); - log('success', `Updated package.json version to ${newVersion}`); - - // Check for required files - const requiredFiles = [ - 'package.json', - 'README-task-master.md', - 'index.js', - 'scripts/init.js', - 'scripts/dev.js', - 'assets/env.example', - 'assets/gitignore', - 'assets/example_prd.txt', - 'assets/scripts_README.md', - '.cursor/rules/dev_workflow.mdc', - '.cursor/rules/cursor_rules.mdc', - '.cursor/rules/self_improve.mdc' - ]; - - let allFilesExist = true; - for (const file of requiredFiles) { - const filePath = path.join(rootDir, file); - if (!fileExists(filePath)) { - log('error', `Required file ${file} does not exist`); - allFilesExist = false; - } - } - - if (!allFilesExist) { - log('error', 'Some required files are missing. Package preparation failed.'); - process.exit(1); - } - - // Ensure scripts are executable - const executableScripts = [ - 'scripts/init.js', - 'scripts/dev.js' - ]; - - let allScriptsExecutable = true; - for (const script of executableScripts) { - const scriptPath = path.join(rootDir, script); - if (!ensureExecutable(scriptPath)) { - allScriptsExecutable = false; - } - } - - if (!allScriptsExecutable) { - log('warn', 'Some scripts could not be made executable. This may cause issues.'); - } - - // Run npm pack to test package creation - try { - log('info', 'Running npm pack to test package creation...'); - const output = execSync('npm pack --dry-run', { cwd: rootDir }).toString(); - log('info', output); - } catch (error) { - log('error', 'Failed to run npm pack:', error.message); - process.exit(1); - } - - // Make scripts executable - log('info', 'Making scripts executable...'); - try { - execSync('chmod +x scripts/init.js', { stdio: 'ignore' }); - log('info', 'Made scripts/init.js executable'); - execSync('chmod +x scripts/dev.js', { stdio: 'ignore' }); - log('info', 'Made scripts/dev.js executable'); - } catch (error) { - log('error', 'Failed to make scripts executable:', error.message); - } - - log('success', `Package preparation completed successfully! 🎉`); - log('success', `Version updated to ${newVersion}`); - log('info', 'You can now publish the package with:'); - log('info', ' npm publish'); + const rootDir = path.join(__dirname, '..'); + log('info', `Preparing package in ${rootDir}`); + + // Update version in package.json + const packageJsonPath = path.join(rootDir, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const currentVersion = packageJson.version; + + let newVersion; + if (explicitVersion) { + newVersion = explicitVersion; + log( + 'info', + `Setting version to specified ${newVersion} (was ${currentVersion})` + ); + } else { + newVersion = incrementVersion(currentVersion, versionBump); + log( + 'info', + `Incrementing ${versionBump} version to ${newVersion} (was ${currentVersion})` + ); + } + + packageJson.version = newVersion; + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); + log('success', `Updated package.json version to ${newVersion}`); + + // Check for required files + const requiredFiles = [ + 'package.json', + 'README-task-master.md', + 'index.js', + 'scripts/init.js', + 'scripts/dev.js', + 'assets/env.example', + 'assets/gitignore', + 'assets/example_prd.txt', + 'assets/scripts_README.md', + '.cursor/rules/dev_workflow.mdc', + '.cursor/rules/cursor_rules.mdc', + '.cursor/rules/self_improve.mdc' + ]; + + let allFilesExist = true; + for (const file of requiredFiles) { + const filePath = path.join(rootDir, file); + if (!fileExists(filePath)) { + log('error', `Required file ${file} does not exist`); + allFilesExist = false; + } + } + + if (!allFilesExist) { + log( + 'error', + 'Some required files are missing. Package preparation failed.' + ); + process.exit(1); + } + + // Ensure scripts are executable + const executableScripts = ['scripts/init.js', 'scripts/dev.js']; + + let allScriptsExecutable = true; + for (const script of executableScripts) { + const scriptPath = path.join(rootDir, script); + if (!ensureExecutable(scriptPath)) { + allScriptsExecutable = false; + } + } + + if (!allScriptsExecutable) { + log( + 'warn', + 'Some scripts could not be made executable. This may cause issues.' + ); + } + + // Run npm pack to test package creation + try { + log('info', 'Running npm pack to test package creation...'); + const output = execSync('npm pack --dry-run', { cwd: rootDir }).toString(); + log('info', output); + } catch (error) { + log('error', 'Failed to run npm pack:', error.message); + process.exit(1); + } + + // Make scripts executable + log('info', 'Making scripts executable...'); + try { + execSync('chmod +x scripts/init.js', { stdio: 'ignore' }); + log('info', 'Made scripts/init.js executable'); + execSync('chmod +x scripts/dev.js', { stdio: 'ignore' }); + log('info', 'Made scripts/dev.js executable'); + } catch (error) { + log('error', 'Failed to make scripts executable:', error.message); + } + + log('success', `Package preparation completed successfully! 🎉`); + log('success', `Version updated to ${newVersion}`); + log('info', 'You can now publish the package with:'); + log('info', ' npm publish'); } // Run the preparation -preparePackage(); \ No newline at end of file +preparePackage(); diff --git a/scripts/task-complexity-report.json b/scripts/task-complexity-report.json index 5b0b8e01..d8588b38 100644 --- a/scripts/task-complexity-report.json +++ b/scripts/task-complexity-report.json @@ -1,203 +1,203 @@ { - "meta": { - "generatedAt": "2025-03-24T20:01:35.986Z", - "tasksAnalyzed": 24, - "thresholdScore": 5, - "projectName": "Your Project Name", - "usedResearch": false - }, - "complexityAnalysis": [ - { - "taskId": 1, - "taskTitle": "Implement Task Data Structure", - "complexityScore": 7, - "recommendedSubtasks": 5, - "expansionPrompt": "Break down the implementation of the core tasks.json data structure into subtasks that cover schema design, model implementation, validation, file operations, and error handling. For each subtask, include specific technical requirements and acceptance criteria.", - "reasoning": "This task requires designing a foundational data structure that will be used throughout the system. It involves schema design, validation logic, and file system operations, which together represent moderate to high complexity. The task is critical as many other tasks depend on it." - }, - { - "taskId": 2, - "taskTitle": "Develop Command Line Interface Foundation", - "complexityScore": 6, - "recommendedSubtasks": 4, - "expansionPrompt": "Divide the CLI foundation implementation into subtasks covering Commander.js setup, help documentation creation, console output formatting, and global options handling. Each subtask should specify implementation details and how it integrates with the overall CLI structure.", - "reasoning": "Setting up the CLI foundation requires integrating Commander.js, implementing various command-line options, and establishing the output formatting system. The complexity is moderate as it involves creating the interface layer that users will interact with." - }, - { - "taskId": 3, - "taskTitle": "Implement Basic Task Operations", - "complexityScore": 8, - "recommendedSubtasks": 5, - "expansionPrompt": "Break down the implementation of basic task operations into subtasks covering CRUD operations, status management, dependency handling, and priority management. Each subtask should detail the specific operations, validation requirements, and error cases to handle.", - "reasoning": "This task encompasses multiple operations (create, read, update, delete) along with status changes, dependency management, and priority handling. It represents high complexity due to the breadth of functionality and the need to ensure data integrity across operations." - }, - { - "taskId": 4, - "taskTitle": "Create Task File Generation System", - "complexityScore": 7, - "recommendedSubtasks": 4, - "expansionPrompt": "Divide the task file generation system into subtasks covering template creation, file generation logic, bi-directional synchronization, and file organization. Each subtask should specify the technical approach, edge cases to handle, and integration points with the task data structure.", - "reasoning": "Implementing file generation with bi-directional synchronization presents significant complexity due to the need to maintain consistency between individual files and the central tasks.json. The system must handle updates in either direction and resolve potential conflicts." - }, - { - "taskId": 5, - "taskTitle": "Integrate Anthropic Claude API", - "complexityScore": 6, - "recommendedSubtasks": 4, - "expansionPrompt": "Break down the Claude API integration into subtasks covering authentication setup, prompt template creation, response handling, and error management with retries. Each subtask should detail the specific implementation approach, including security considerations and performance optimizations.", - "reasoning": "Integrating with the Claude API involves setting up authentication, creating effective prompts, and handling responses and errors. The complexity is moderate, focusing on establishing a reliable connection to the external service with proper error handling and retry logic." - }, - { - "taskId": 6, - "taskTitle": "Build PRD Parsing System", - "complexityScore": 8, - "recommendedSubtasks": 5, - "expansionPrompt": "Divide the PRD parsing system into subtasks covering file reading, prompt engineering, content-to-task conversion, dependency inference, priority assignment, and handling large documents. Each subtask should specify the AI interaction approach, data transformation steps, and validation requirements.", - "reasoning": "Parsing PRDs into structured tasks requires sophisticated prompt engineering and intelligent processing of unstructured text. The complexity is high due to the need to accurately extract tasks, infer dependencies, and handle potentially large documents with varying formats." - }, - { - "taskId": 7, - "taskTitle": "Implement Task Expansion with Claude", - "complexityScore": 7, - "recommendedSubtasks": 4, - "expansionPrompt": "Break down the task expansion functionality into subtasks covering prompt creation for subtask generation, expansion workflow implementation, parent-child relationship management, and regeneration mechanisms. Each subtask should detail the AI interaction patterns, data structures, and user experience considerations.", - "reasoning": "Task expansion involves complex AI interactions to generate meaningful subtasks and manage their relationships with parent tasks. The complexity comes from creating effective prompts that produce useful subtasks and implementing a smooth workflow for users to generate and refine these subtasks." - }, - { - "taskId": 8, - "taskTitle": "Develop Implementation Drift Handling", - "complexityScore": 9, - "recommendedSubtasks": 5, - "expansionPrompt": "Divide the implementation drift handling into subtasks covering change detection, task rewriting based on new context, dependency chain updates, work preservation, and update suggestion analysis. Each subtask should specify the algorithms, heuristics, and AI prompts needed to effectively manage implementation changes.", - "reasoning": "This task involves the complex challenge of updating future tasks based on changes in implementation. It requires sophisticated analysis of completed work, understanding how it affects pending tasks, and intelligently updating those tasks while preserving dependencies. This represents high complexity due to the need for context-aware AI reasoning." - }, - { - "taskId": 9, - "taskTitle": "Integrate Perplexity API", - "complexityScore": 5, - "recommendedSubtasks": 3, - "expansionPrompt": "Break down the Perplexity API integration into subtasks covering authentication setup, research-oriented prompt creation, response handling, and fallback mechanisms. Each subtask should detail the implementation approach, integration with existing systems, and quality comparison metrics.", - "reasoning": "Similar to the Claude integration but slightly less complex, this task focuses on connecting to the Perplexity API for research capabilities. The complexity is moderate, involving API authentication, prompt templates, and response handling with fallback mechanisms to Claude." - }, - { - "taskId": 10, - "taskTitle": "Create Research-Backed Subtask Generation", - "complexityScore": 7, - "recommendedSubtasks": 4, - "expansionPrompt": "Divide the research-backed subtask generation into subtasks covering domain-specific prompt creation, context enrichment from research, knowledge incorporation, and detailed subtask generation. Each subtask should specify the approach for leveraging research data and integrating it into the generation process.", - "reasoning": "This task builds on previous work to enhance subtask generation with research capabilities. The complexity comes from effectively incorporating research results into the generation process and creating domain-specific prompts that produce high-quality, detailed subtasks with best practices." - }, - { - "taskId": 11, - "taskTitle": "Implement Batch Operations", - "complexityScore": 6, - "recommendedSubtasks": 4, - "expansionPrompt": "Break down the batch operations functionality into subtasks covering multi-task status updates, bulk subtask generation, task filtering/querying, and batch prioritization. Each subtask should detail the command interface, implementation approach, and performance considerations for handling multiple tasks.", - "reasoning": "Implementing batch operations requires extending existing functionality to work with multiple tasks simultaneously. The complexity is moderate, focusing on efficient processing of task sets, filtering capabilities, and maintaining data consistency across bulk operations." - }, - { - "taskId": 12, - "taskTitle": "Develop Project Initialization System", - "complexityScore": 6, - "recommendedSubtasks": 4, - "expansionPrompt": "Divide the project initialization system into subtasks covering project templating, interactive setup wizard, environment configuration, directory structure creation, and example generation. Each subtask should specify the user interaction flow, template design, and integration with existing components.", - "reasoning": "Creating a project initialization system involves setting up templates, an interactive wizard, and generating initial files and directories. The complexity is moderate, focusing on providing a smooth setup experience for new projects with appropriate defaults and configuration." - }, - { - "taskId": 13, - "taskTitle": "Create Cursor Rules Implementation", - "complexityScore": 5, - "recommendedSubtasks": 3, - "expansionPrompt": "Break down the Cursor rules implementation into subtasks covering documentation creation (dev_workflow.mdc, cursor_rules.mdc, self_improve.mdc), directory structure setup, and integration documentation. Each subtask should detail the specific content to include and how it enables effective AI interaction.", - "reasoning": "This task focuses on creating documentation and rules for Cursor AI integration. The complexity is moderate, involving the creation of structured documentation files that define how AI should interact with the system and setting up the appropriate directory structure." - }, - { - "taskId": 14, - "taskTitle": "Develop Agent Workflow Guidelines", - "complexityScore": 5, - "recommendedSubtasks": 3, - "expansionPrompt": "Divide the agent workflow guidelines into subtasks covering task discovery documentation, selection guidelines, implementation guidance, verification procedures, and prioritization rules. Each subtask should specify the specific guidance to provide and how it enables effective agent workflows.", - "reasoning": "Creating comprehensive guidelines for AI agents involves documenting workflows, selection criteria, and implementation guidance. The complexity is moderate, focusing on clear documentation that helps agents interact effectively with the task system." - }, - { - "taskId": 15, - "taskTitle": "Optimize Agent Integration with Cursor and dev.js Commands", - "complexityScore": 6, - "recommendedSubtasks": 4, - "expansionPrompt": "Break down the agent integration optimization into subtasks covering existing pattern documentation, Cursor-dev.js command integration enhancement, workflow documentation improvement, and feature additions. Each subtask should specify the specific improvements to make and how they enhance agent interaction.", - "reasoning": "This task involves enhancing and documenting existing agent interaction patterns with Cursor and dev.js commands. The complexity is moderate, focusing on improving integration between different components and ensuring agents can effectively utilize the system's capabilities." - }, - { - "taskId": 16, - "taskTitle": "Create Configuration Management System", - "complexityScore": 6, - "recommendedSubtasks": 4, - "expansionPrompt": "Divide the configuration management system into subtasks covering environment variable handling, .env file support, configuration validation, defaults with overrides, and secure API key handling. Each subtask should specify the implementation approach, security considerations, and user experience for configuration.", - "reasoning": "Implementing robust configuration management involves handling environment variables, .env files, validation, and secure storage of sensitive information. The complexity is moderate, focusing on creating a flexible system that works across different environments with appropriate security measures." - }, - { - "taskId": 17, - "taskTitle": "Implement Comprehensive Logging System", - "complexityScore": 5, - "recommendedSubtasks": 3, - "expansionPrompt": "Break down the logging system implementation into subtasks covering log level configuration, output destination management, specialized logging (commands, APIs, errors), and performance metrics. Each subtask should detail the implementation approach, configuration options, and integration with existing components.", - "reasoning": "Creating a comprehensive logging system involves implementing multiple log levels, configurable destinations, and specialized logging for different components. The complexity is moderate, focusing on providing useful information for debugging and monitoring while maintaining performance." - }, - { - "taskId": 18, - "taskTitle": "Create Comprehensive User Documentation", - "complexityScore": 7, - "recommendedSubtasks": 5, - "expansionPrompt": "Divide the user documentation creation into subtasks covering README with installation instructions, command reference, configuration guide, example workflows, troubleshooting guides, and advanced usage. Each subtask should specify the content to include, format, and organization to ensure comprehensive coverage.", - "reasoning": "Creating comprehensive documentation requires covering installation, usage, configuration, examples, and troubleshooting across multiple components. The complexity is moderate to high due to the breadth of functionality to document and the need to make it accessible to different user levels." - }, - { - "taskId": 19, - "taskTitle": "Implement Error Handling and Recovery", - "complexityScore": 8, - "recommendedSubtasks": 5, - "expansionPrompt": "Break down the error handling implementation into subtasks covering consistent error formatting, helpful error messages, API error handling with retries, file system error recovery, validation errors, and system state recovery. Each subtask should detail the specific error types to handle, recovery strategies, and user communication approach.", - "reasoning": "Implementing robust error handling across the entire system represents high complexity due to the variety of error types, the need for meaningful messages, and the implementation of recovery mechanisms. This task is critical for system reliability and user experience." - }, - { - "taskId": 20, - "taskTitle": "Create Token Usage Tracking and Cost Management", - "complexityScore": 7, - "recommendedSubtasks": 4, - "expansionPrompt": "Divide the token tracking and cost management into subtasks covering usage tracking implementation, configurable limits, reporting features, cost estimation, caching for optimization, and usage alerts. Each subtask should specify the implementation approach, data storage, and user interface for monitoring and managing usage.", - "reasoning": "Implementing token usage tracking involves monitoring API calls, calculating costs, implementing limits, and optimizing usage through caching. The complexity is moderate to high, focusing on providing users with visibility into their API consumption and tools to manage costs." - }, - { - "taskId": 21, - "taskTitle": "Refactor dev.js into Modular Components", - "complexityScore": 8, - "recommendedSubtasks": 5, - "expansionPrompt": "Break down the refactoring of dev.js into subtasks covering module design (commands.js, ai-services.js, task-manager.js, ui.js, utils.js), entry point restructuring, dependency management, error handling standardization, and documentation. Each subtask should detail the specific code to extract, interfaces to define, and integration points between modules.", - "reasoning": "Refactoring a monolithic file into modular components represents high complexity due to the need to identify appropriate boundaries, manage dependencies between modules, and ensure all functionality is preserved. This requires deep understanding of the existing codebase and careful restructuring." - }, - { - "taskId": 22, - "taskTitle": "Create Comprehensive Test Suite for Task Master CLI", - "complexityScore": 9, - "recommendedSubtasks": 5, - "expansionPrompt": "Divide the test suite creation into subtasks covering unit test implementation, integration test development, end-to-end test creation, mocking setup, and CI integration. Each subtask should specify the testing approach, coverage goals, test data preparation, and specific functionality to test.", - "reasoning": "Developing a comprehensive test suite represents high complexity due to the need to cover unit, integration, and end-to-end tests across all functionality, implement appropriate mocking, and ensure good test coverage. This requires significant test engineering and understanding of the entire system." - }, - { - "taskId": 23, - "taskTitle": "Implement MCP (Model Context Protocol) Server Functionality for Task Master", - "complexityScore": 9, - "recommendedSubtasks": 5, - "expansionPrompt": "Break down the MCP server implementation into subtasks covering core server module creation, endpoint implementation (/context, /models, /execute), context management system, authentication mechanisms, and performance optimization. Each subtask should detail the API design, data structures, and integration with existing Task Master functionality.", - "reasoning": "Implementing an MCP server represents high complexity due to the need to create a RESTful API with multiple endpoints, manage context data efficiently, handle authentication, and ensure compatibility with the MCP specification. This requires significant API design and server-side development work." - }, - { - "taskId": 24, - "taskTitle": "Implement AI-Powered Test Generation Command", - "complexityScore": 7, - "recommendedSubtasks": 4, - "expansionPrompt": "Divide the test generation command implementation into subtasks covering command structure and parameter handling, task analysis logic, AI prompt construction, and test file generation. Each subtask should specify the implementation approach, AI interaction pattern, and output formatting requirements.", - "reasoning": "Creating an AI-powered test generation command involves analyzing tasks, constructing effective prompts, and generating well-formatted test files. The complexity is moderate to high, focusing on leveraging AI to produce useful tests based on task descriptions and subtasks." - } - ] -} \ No newline at end of file + "meta": { + "generatedAt": "2025-03-24T20:01:35.986Z", + "tasksAnalyzed": 24, + "thresholdScore": 5, + "projectName": "Your Project Name", + "usedResearch": false + }, + "complexityAnalysis": [ + { + "taskId": 1, + "taskTitle": "Implement Task Data Structure", + "complexityScore": 7, + "recommendedSubtasks": 5, + "expansionPrompt": "Break down the implementation of the core tasks.json data structure into subtasks that cover schema design, model implementation, validation, file operations, and error handling. For each subtask, include specific technical requirements and acceptance criteria.", + "reasoning": "This task requires designing a foundational data structure that will be used throughout the system. It involves schema design, validation logic, and file system operations, which together represent moderate to high complexity. The task is critical as many other tasks depend on it." + }, + { + "taskId": 2, + "taskTitle": "Develop Command Line Interface Foundation", + "complexityScore": 6, + "recommendedSubtasks": 4, + "expansionPrompt": "Divide the CLI foundation implementation into subtasks covering Commander.js setup, help documentation creation, console output formatting, and global options handling. Each subtask should specify implementation details and how it integrates with the overall CLI structure.", + "reasoning": "Setting up the CLI foundation requires integrating Commander.js, implementing various command-line options, and establishing the output formatting system. The complexity is moderate as it involves creating the interface layer that users will interact with." + }, + { + "taskId": 3, + "taskTitle": "Implement Basic Task Operations", + "complexityScore": 8, + "recommendedSubtasks": 5, + "expansionPrompt": "Break down the implementation of basic task operations into subtasks covering CRUD operations, status management, dependency handling, and priority management. Each subtask should detail the specific operations, validation requirements, and error cases to handle.", + "reasoning": "This task encompasses multiple operations (create, read, update, delete) along with status changes, dependency management, and priority handling. It represents high complexity due to the breadth of functionality and the need to ensure data integrity across operations." + }, + { + "taskId": 4, + "taskTitle": "Create Task File Generation System", + "complexityScore": 7, + "recommendedSubtasks": 4, + "expansionPrompt": "Divide the task file generation system into subtasks covering template creation, file generation logic, bi-directional synchronization, and file organization. Each subtask should specify the technical approach, edge cases to handle, and integration points with the task data structure.", + "reasoning": "Implementing file generation with bi-directional synchronization presents significant complexity due to the need to maintain consistency between individual files and the central tasks.json. The system must handle updates in either direction and resolve potential conflicts." + }, + { + "taskId": 5, + "taskTitle": "Integrate Anthropic Claude API", + "complexityScore": 6, + "recommendedSubtasks": 4, + "expansionPrompt": "Break down the Claude API integration into subtasks covering authentication setup, prompt template creation, response handling, and error management with retries. Each subtask should detail the specific implementation approach, including security considerations and performance optimizations.", + "reasoning": "Integrating with the Claude API involves setting up authentication, creating effective prompts, and handling responses and errors. The complexity is moderate, focusing on establishing a reliable connection to the external service with proper error handling and retry logic." + }, + { + "taskId": 6, + "taskTitle": "Build PRD Parsing System", + "complexityScore": 8, + "recommendedSubtasks": 5, + "expansionPrompt": "Divide the PRD parsing system into subtasks covering file reading, prompt engineering, content-to-task conversion, dependency inference, priority assignment, and handling large documents. Each subtask should specify the AI interaction approach, data transformation steps, and validation requirements.", + "reasoning": "Parsing PRDs into structured tasks requires sophisticated prompt engineering and intelligent processing of unstructured text. The complexity is high due to the need to accurately extract tasks, infer dependencies, and handle potentially large documents with varying formats." + }, + { + "taskId": 7, + "taskTitle": "Implement Task Expansion with Claude", + "complexityScore": 7, + "recommendedSubtasks": 4, + "expansionPrompt": "Break down the task expansion functionality into subtasks covering prompt creation for subtask generation, expansion workflow implementation, parent-child relationship management, and regeneration mechanisms. Each subtask should detail the AI interaction patterns, data structures, and user experience considerations.", + "reasoning": "Task expansion involves complex AI interactions to generate meaningful subtasks and manage their relationships with parent tasks. The complexity comes from creating effective prompts that produce useful subtasks and implementing a smooth workflow for users to generate and refine these subtasks." + }, + { + "taskId": 8, + "taskTitle": "Develop Implementation Drift Handling", + "complexityScore": 9, + "recommendedSubtasks": 5, + "expansionPrompt": "Divide the implementation drift handling into subtasks covering change detection, task rewriting based on new context, dependency chain updates, work preservation, and update suggestion analysis. Each subtask should specify the algorithms, heuristics, and AI prompts needed to effectively manage implementation changes.", + "reasoning": "This task involves the complex challenge of updating future tasks based on changes in implementation. It requires sophisticated analysis of completed work, understanding how it affects pending tasks, and intelligently updating those tasks while preserving dependencies. This represents high complexity due to the need for context-aware AI reasoning." + }, + { + "taskId": 9, + "taskTitle": "Integrate Perplexity API", + "complexityScore": 5, + "recommendedSubtasks": 3, + "expansionPrompt": "Break down the Perplexity API integration into subtasks covering authentication setup, research-oriented prompt creation, response handling, and fallback mechanisms. Each subtask should detail the implementation approach, integration with existing systems, and quality comparison metrics.", + "reasoning": "Similar to the Claude integration but slightly less complex, this task focuses on connecting to the Perplexity API for research capabilities. The complexity is moderate, involving API authentication, prompt templates, and response handling with fallback mechanisms to Claude." + }, + { + "taskId": 10, + "taskTitle": "Create Research-Backed Subtask Generation", + "complexityScore": 7, + "recommendedSubtasks": 4, + "expansionPrompt": "Divide the research-backed subtask generation into subtasks covering domain-specific prompt creation, context enrichment from research, knowledge incorporation, and detailed subtask generation. Each subtask should specify the approach for leveraging research data and integrating it into the generation process.", + "reasoning": "This task builds on previous work to enhance subtask generation with research capabilities. The complexity comes from effectively incorporating research results into the generation process and creating domain-specific prompts that produce high-quality, detailed subtasks with best practices." + }, + { + "taskId": 11, + "taskTitle": "Implement Batch Operations", + "complexityScore": 6, + "recommendedSubtasks": 4, + "expansionPrompt": "Break down the batch operations functionality into subtasks covering multi-task status updates, bulk subtask generation, task filtering/querying, and batch prioritization. Each subtask should detail the command interface, implementation approach, and performance considerations for handling multiple tasks.", + "reasoning": "Implementing batch operations requires extending existing functionality to work with multiple tasks simultaneously. The complexity is moderate, focusing on efficient processing of task sets, filtering capabilities, and maintaining data consistency across bulk operations." + }, + { + "taskId": 12, + "taskTitle": "Develop Project Initialization System", + "complexityScore": 6, + "recommendedSubtasks": 4, + "expansionPrompt": "Divide the project initialization system into subtasks covering project templating, interactive setup wizard, environment configuration, directory structure creation, and example generation. Each subtask should specify the user interaction flow, template design, and integration with existing components.", + "reasoning": "Creating a project initialization system involves setting up templates, an interactive wizard, and generating initial files and directories. The complexity is moderate, focusing on providing a smooth setup experience for new projects with appropriate defaults and configuration." + }, + { + "taskId": 13, + "taskTitle": "Create Cursor Rules Implementation", + "complexityScore": 5, + "recommendedSubtasks": 3, + "expansionPrompt": "Break down the Cursor rules implementation into subtasks covering documentation creation (dev_workflow.mdc, cursor_rules.mdc, self_improve.mdc), directory structure setup, and integration documentation. Each subtask should detail the specific content to include and how it enables effective AI interaction.", + "reasoning": "This task focuses on creating documentation and rules for Cursor AI integration. The complexity is moderate, involving the creation of structured documentation files that define how AI should interact with the system and setting up the appropriate directory structure." + }, + { + "taskId": 14, + "taskTitle": "Develop Agent Workflow Guidelines", + "complexityScore": 5, + "recommendedSubtasks": 3, + "expansionPrompt": "Divide the agent workflow guidelines into subtasks covering task discovery documentation, selection guidelines, implementation guidance, verification procedures, and prioritization rules. Each subtask should specify the specific guidance to provide and how it enables effective agent workflows.", + "reasoning": "Creating comprehensive guidelines for AI agents involves documenting workflows, selection criteria, and implementation guidance. The complexity is moderate, focusing on clear documentation that helps agents interact effectively with the task system." + }, + { + "taskId": 15, + "taskTitle": "Optimize Agent Integration with Cursor and dev.js Commands", + "complexityScore": 6, + "recommendedSubtasks": 4, + "expansionPrompt": "Break down the agent integration optimization into subtasks covering existing pattern documentation, Cursor-dev.js command integration enhancement, workflow documentation improvement, and feature additions. Each subtask should specify the specific improvements to make and how they enhance agent interaction.", + "reasoning": "This task involves enhancing and documenting existing agent interaction patterns with Cursor and dev.js commands. The complexity is moderate, focusing on improving integration between different components and ensuring agents can effectively utilize the system's capabilities." + }, + { + "taskId": 16, + "taskTitle": "Create Configuration Management System", + "complexityScore": 6, + "recommendedSubtasks": 4, + "expansionPrompt": "Divide the configuration management system into subtasks covering environment variable handling, .env file support, configuration validation, defaults with overrides, and secure API key handling. Each subtask should specify the implementation approach, security considerations, and user experience for configuration.", + "reasoning": "Implementing robust configuration management involves handling environment variables, .env files, validation, and secure storage of sensitive information. The complexity is moderate, focusing on creating a flexible system that works across different environments with appropriate security measures." + }, + { + "taskId": 17, + "taskTitle": "Implement Comprehensive Logging System", + "complexityScore": 5, + "recommendedSubtasks": 3, + "expansionPrompt": "Break down the logging system implementation into subtasks covering log level configuration, output destination management, specialized logging (commands, APIs, errors), and performance metrics. Each subtask should detail the implementation approach, configuration options, and integration with existing components.", + "reasoning": "Creating a comprehensive logging system involves implementing multiple log levels, configurable destinations, and specialized logging for different components. The complexity is moderate, focusing on providing useful information for debugging and monitoring while maintaining performance." + }, + { + "taskId": 18, + "taskTitle": "Create Comprehensive User Documentation", + "complexityScore": 7, + "recommendedSubtasks": 5, + "expansionPrompt": "Divide the user documentation creation into subtasks covering README with installation instructions, command reference, configuration guide, example workflows, troubleshooting guides, and advanced usage. Each subtask should specify the content to include, format, and organization to ensure comprehensive coverage.", + "reasoning": "Creating comprehensive documentation requires covering installation, usage, configuration, examples, and troubleshooting across multiple components. The complexity is moderate to high due to the breadth of functionality to document and the need to make it accessible to different user levels." + }, + { + "taskId": 19, + "taskTitle": "Implement Error Handling and Recovery", + "complexityScore": 8, + "recommendedSubtasks": 5, + "expansionPrompt": "Break down the error handling implementation into subtasks covering consistent error formatting, helpful error messages, API error handling with retries, file system error recovery, validation errors, and system state recovery. Each subtask should detail the specific error types to handle, recovery strategies, and user communication approach.", + "reasoning": "Implementing robust error handling across the entire system represents high complexity due to the variety of error types, the need for meaningful messages, and the implementation of recovery mechanisms. This task is critical for system reliability and user experience." + }, + { + "taskId": 20, + "taskTitle": "Create Token Usage Tracking and Cost Management", + "complexityScore": 7, + "recommendedSubtasks": 4, + "expansionPrompt": "Divide the token tracking and cost management into subtasks covering usage tracking implementation, configurable limits, reporting features, cost estimation, caching for optimization, and usage alerts. Each subtask should specify the implementation approach, data storage, and user interface for monitoring and managing usage.", + "reasoning": "Implementing token usage tracking involves monitoring API calls, calculating costs, implementing limits, and optimizing usage through caching. The complexity is moderate to high, focusing on providing users with visibility into their API consumption and tools to manage costs." + }, + { + "taskId": 21, + "taskTitle": "Refactor dev.js into Modular Components", + "complexityScore": 8, + "recommendedSubtasks": 5, + "expansionPrompt": "Break down the refactoring of dev.js into subtasks covering module design (commands.js, ai-services.js, task-manager.js, ui.js, utils.js), entry point restructuring, dependency management, error handling standardization, and documentation. Each subtask should detail the specific code to extract, interfaces to define, and integration points between modules.", + "reasoning": "Refactoring a monolithic file into modular components represents high complexity due to the need to identify appropriate boundaries, manage dependencies between modules, and ensure all functionality is preserved. This requires deep understanding of the existing codebase and careful restructuring." + }, + { + "taskId": 22, + "taskTitle": "Create Comprehensive Test Suite for Task Master CLI", + "complexityScore": 9, + "recommendedSubtasks": 5, + "expansionPrompt": "Divide the test suite creation into subtasks covering unit test implementation, integration test development, end-to-end test creation, mocking setup, and CI integration. Each subtask should specify the testing approach, coverage goals, test data preparation, and specific functionality to test.", + "reasoning": "Developing a comprehensive test suite represents high complexity due to the need to cover unit, integration, and end-to-end tests across all functionality, implement appropriate mocking, and ensure good test coverage. This requires significant test engineering and understanding of the entire system." + }, + { + "taskId": 23, + "taskTitle": "Implement MCP (Model Context Protocol) Server Functionality for Task Master", + "complexityScore": 9, + "recommendedSubtasks": 5, + "expansionPrompt": "Break down the MCP server implementation into subtasks covering core server module creation, endpoint implementation (/context, /models, /execute), context management system, authentication mechanisms, and performance optimization. Each subtask should detail the API design, data structures, and integration with existing Task Master functionality.", + "reasoning": "Implementing an MCP server represents high complexity due to the need to create a RESTful API with multiple endpoints, manage context data efficiently, handle authentication, and ensure compatibility with the MCP specification. This requires significant API design and server-side development work." + }, + { + "taskId": 24, + "taskTitle": "Implement AI-Powered Test Generation Command", + "complexityScore": 7, + "recommendedSubtasks": 4, + "expansionPrompt": "Divide the test generation command implementation into subtasks covering command structure and parameter handling, task analysis logic, AI prompt construction, and test file generation. Each subtask should specify the implementation approach, AI interaction pattern, and output formatting requirements.", + "reasoning": "Creating an AI-powered test generation command involves analyzing tasks, constructing effective prompts, and generating well-formatted test files. The complexity is moderate to high, focusing on leveraging AI to produce useful tests based on task descriptions and subtasks." + } + ] +} diff --git a/scripts/test-claude-errors.js b/scripts/test-claude-errors.js index f224eb44..6db16629 100755 --- a/scripts/test-claude-errors.js +++ b/scripts/test-claude-errors.js @@ -2,7 +2,7 @@ /** * test-claude-errors.js - * + * * A test script to verify the error handling and retry logic in the callClaude function. * This script creates a modified version of dev.js that simulates different error scenarios. */ @@ -22,7 +22,7 @@ dotenv.config(); // Create a simple PRD for testing const createTestPRD = () => { - return `# Test PRD for Error Handling + return `# Test PRD for Error Handling ## Overview This is a simple test PRD to verify the error handling in the callClaude function. @@ -36,21 +36,22 @@ This is a simple test PRD to verify the error handling in the callClaude functio // Create a modified version of dev.js that simulates errors function createErrorSimulationScript(errorType, failureCount = 2) { - // Read the original dev.js file - const devJsPath = path.join(__dirname, 'dev.js'); - const devJsContent = fs.readFileSync(devJsPath, 'utf8'); - - // Create a modified version that simulates errors - let modifiedContent = devJsContent; - - // Find the anthropic.messages.create call and replace it with our mock - const anthropicCallRegex = /const response = await anthropic\.messages\.create\(/; - - let mockCode = ''; - - switch (errorType) { - case 'network': - mockCode = ` + // Read the original dev.js file + const devJsPath = path.join(__dirname, 'dev.js'); + const devJsContent = fs.readFileSync(devJsPath, 'utf8'); + + // Create a modified version that simulates errors + let modifiedContent = devJsContent; + + // Find the anthropic.messages.create call and replace it with our mock + const anthropicCallRegex = + /const response = await anthropic\.messages\.create\(/; + + let mockCode = ''; + + switch (errorType) { + case 'network': + mockCode = ` // Mock for network error simulation let currentAttempt = 0; const failureCount = ${failureCount}; @@ -65,10 +66,10 @@ function createErrorSimulationScript(errorType, failureCount = 2) { } const response = await anthropic.messages.create(`; - break; - - case 'timeout': - mockCode = ` + break; + + case 'timeout': + mockCode = ` // Mock for timeout error simulation let currentAttempt = 0; const failureCount = ${failureCount}; @@ -83,10 +84,10 @@ function createErrorSimulationScript(errorType, failureCount = 2) { } const response = await anthropic.messages.create(`; - break; - - case 'invalid-json': - mockCode = ` + break; + + case 'invalid-json': + mockCode = ` // Mock for invalid JSON response let currentAttempt = 0; const failureCount = ${failureCount}; @@ -107,10 +108,10 @@ function createErrorSimulationScript(errorType, failureCount = 2) { } const response = await anthropic.messages.create(`; - break; - - case 'empty-tasks': - mockCode = ` + break; + + case 'empty-tasks': + mockCode = ` // Mock for empty tasks array let currentAttempt = 0; const failureCount = ${failureCount}; @@ -131,82 +132,87 @@ function createErrorSimulationScript(errorType, failureCount = 2) { } const response = await anthropic.messages.create(`; - break; - - default: - // No modification - mockCode = `const response = await anthropic.messages.create(`; - } - - // Replace the anthropic call with our mock - modifiedContent = modifiedContent.replace(anthropicCallRegex, mockCode); - - // Write the modified script to a temporary file - const tempScriptPath = path.join(__dirname, `temp-dev-${errorType}.js`); - fs.writeFileSync(tempScriptPath, modifiedContent, 'utf8'); - - return tempScriptPath; + break; + + default: + // No modification + mockCode = `const response = await anthropic.messages.create(`; + } + + // Replace the anthropic call with our mock + modifiedContent = modifiedContent.replace(anthropicCallRegex, mockCode); + + // Write the modified script to a temporary file + const tempScriptPath = path.join(__dirname, `temp-dev-${errorType}.js`); + fs.writeFileSync(tempScriptPath, modifiedContent, 'utf8'); + + return tempScriptPath; } // Function to run a test with a specific error type async function runErrorTest(errorType, numTasks = 5, failureCount = 2) { - console.log(`\n=== Test: ${errorType.toUpperCase()} Error Simulation ===`); - - // Create a test PRD - const testPRD = createTestPRD(); - const testPRDPath = path.join(__dirname, `test-prd-${errorType}.txt`); - fs.writeFileSync(testPRDPath, testPRD, 'utf8'); - - // Create a modified dev.js that simulates the specified error - const tempScriptPath = createErrorSimulationScript(errorType, failureCount); - - console.log(`Created test PRD at ${testPRDPath}`); - console.log(`Created error simulation script at ${tempScriptPath}`); - console.log(`Running with error type: ${errorType}, failure count: ${failureCount}, tasks: ${numTasks}`); - - try { - // Run the modified script - execSync(`node ${tempScriptPath} parse-prd --input=${testPRDPath} --tasks=${numTasks}`, { - stdio: 'inherit' - }); - console.log(`${errorType} error test completed successfully`); - } catch (error) { - console.error(`${errorType} error test failed:`, error.message); - } finally { - // Clean up temporary files - if (fs.existsSync(tempScriptPath)) { - fs.unlinkSync(tempScriptPath); - } - if (fs.existsSync(testPRDPath)) { - fs.unlinkSync(testPRDPath); - } - } + console.log(`\n=== Test: ${errorType.toUpperCase()} Error Simulation ===`); + + // Create a test PRD + const testPRD = createTestPRD(); + const testPRDPath = path.join(__dirname, `test-prd-${errorType}.txt`); + fs.writeFileSync(testPRDPath, testPRD, 'utf8'); + + // Create a modified dev.js that simulates the specified error + const tempScriptPath = createErrorSimulationScript(errorType, failureCount); + + console.log(`Created test PRD at ${testPRDPath}`); + console.log(`Created error simulation script at ${tempScriptPath}`); + console.log( + `Running with error type: ${errorType}, failure count: ${failureCount}, tasks: ${numTasks}` + ); + + try { + // Run the modified script + execSync( + `node ${tempScriptPath} parse-prd --input=${testPRDPath} --tasks=${numTasks}`, + { + stdio: 'inherit' + } + ); + console.log(`${errorType} error test completed successfully`); + } catch (error) { + console.error(`${errorType} error test failed:`, error.message); + } finally { + // Clean up temporary files + if (fs.existsSync(tempScriptPath)) { + fs.unlinkSync(tempScriptPath); + } + if (fs.existsSync(testPRDPath)) { + fs.unlinkSync(testPRDPath); + } + } } // Function to run all error tests async function runAllErrorTests() { - console.log('Starting error handling tests for callClaude function...'); - - // Test 1: Network error with automatic retry - await runErrorTest('network', 5, 2); - - // Test 2: Timeout error with automatic retry - await runErrorTest('timeout', 5, 2); - - // Test 3: Invalid JSON response with task reduction - await runErrorTest('invalid-json', 10, 2); - - // Test 4: Empty tasks array with task reduction - await runErrorTest('empty-tasks', 15, 2); - - // Test 5: Exhausted retries (more failures than MAX_RETRIES) - await runErrorTest('network', 5, 4); - - console.log('\nAll error tests completed!'); + console.log('Starting error handling tests for callClaude function...'); + + // Test 1: Network error with automatic retry + await runErrorTest('network', 5, 2); + + // Test 2: Timeout error with automatic retry + await runErrorTest('timeout', 5, 2); + + // Test 3: Invalid JSON response with task reduction + await runErrorTest('invalid-json', 10, 2); + + // Test 4: Empty tasks array with task reduction + await runErrorTest('empty-tasks', 15, 2); + + // Test 5: Exhausted retries (more failures than MAX_RETRIES) + await runErrorTest('network', 5, 4); + + console.log('\nAll error tests completed!'); } // Run the tests -runAllErrorTests().catch(error => { - console.error('Error running tests:', error); - process.exit(1); -}); \ No newline at end of file +runAllErrorTests().catch((error) => { + console.error('Error running tests:', error); + process.exit(1); +}); diff --git a/scripts/test-claude.js b/scripts/test-claude.js index f3599ac4..7d92a890 100755 --- a/scripts/test-claude.js +++ b/scripts/test-claude.js @@ -2,7 +2,7 @@ /** * test-claude.js - * + * * A simple test script to verify the improvements to the callClaude function. * This script tests different scenarios: * 1. Normal operation with a small PRD @@ -24,11 +24,11 @@ dotenv.config(); // Create a simple PRD for testing const createTestPRD = (size = 'small', taskComplexity = 'simple') => { - let content = `# Test PRD - ${size.toUpperCase()} SIZE, ${taskComplexity.toUpperCase()} COMPLEXITY\n\n`; - - // Add more content based on size - if (size === 'small') { - content += ` + let content = `# Test PRD - ${size.toUpperCase()} SIZE, ${taskComplexity.toUpperCase()} COMPLEXITY\n\n`; + + // Add more content based on size + if (size === 'small') { + content += ` ## Overview This is a small test PRD to verify the callClaude function improvements. @@ -44,9 +44,9 @@ This is a small test PRD to verify the callClaude function improvements. - Backend: Node.js - Database: MongoDB `; - } else if (size === 'medium') { - // Medium-sized PRD with more requirements - content += ` + } else if (size === 'medium') { + // Medium-sized PRD with more requirements + content += ` ## Overview This is a medium-sized test PRD to verify the callClaude function improvements. @@ -76,20 +76,20 @@ This is a medium-sized test PRD to verify the callClaude function improvements. - CI/CD: GitHub Actions - Monitoring: Prometheus and Grafana `; - } else if (size === 'large') { - // Large PRD with many requirements - content += ` + } else if (size === 'large') { + // Large PRD with many requirements + content += ` ## Overview This is a large test PRD to verify the callClaude function improvements. ## Requirements `; - // Generate 30 requirements - for (let i = 1; i <= 30; i++) { - content += `${i}. Requirement ${i} - This is a detailed description of requirement ${i}.\n`; - } - - content += ` + // Generate 30 requirements + for (let i = 1; i <= 30; i++) { + content += `${i}. Requirement ${i} - This is a detailed description of requirement ${i}.\n`; + } + + content += ` ## Technical Stack - Frontend: React with TypeScript - Backend: Node.js with Express @@ -101,12 +101,12 @@ This is a large test PRD to verify the callClaude function improvements. ## User Stories `; - // Generate 20 user stories - for (let i = 1; i <= 20; i++) { - content += `- As a user, I want to be able to ${i} so that I can achieve benefit ${i}.\n`; - } - - content += ` + // Generate 20 user stories + for (let i = 1; i <= 20; i++) { + content += `- As a user, I want to be able to ${i} so that I can achieve benefit ${i}.\n`; + } + + content += ` ## Non-Functional Requirements - Performance: The system should respond within 200ms - Scalability: The system should handle 10,000 concurrent users @@ -114,11 +114,11 @@ This is a large test PRD to verify the callClaude function improvements. - Security: The system should comply with OWASP top 10 - Accessibility: The system should comply with WCAG 2.1 AA `; - } - - // Add complexity if needed - if (taskComplexity === 'complex') { - content += ` + } + + // Add complexity if needed + if (taskComplexity === 'complex') { + content += ` ## Complex Requirements - Implement a real-time collaboration system - Add a machine learning-based recommendation engine @@ -131,101 +131,110 @@ This is a large test PRD to verify the callClaude function improvements. - Implement a custom reporting system - Add a custom dashboard builder `; - } - - return content; + } + + return content; }; // Function to run the tests async function runTests() { - console.log('Starting tests for callClaude function improvements...'); - - try { - // Instead of importing the callClaude function directly, we'll use the dev.js script - // with our test PRDs by running it as a child process - - // Test 1: Small PRD, 5 tasks - console.log('\n=== Test 1: Small PRD, 5 tasks ==='); - const smallPRD = createTestPRD('small', 'simple'); - const smallPRDPath = path.join(__dirname, 'test-small-prd.txt'); - fs.writeFileSync(smallPRDPath, smallPRD, 'utf8'); - - console.log(`Created test PRD at ${smallPRDPath}`); - console.log('Running dev.js with small PRD...'); - - // Use the child_process module to run the dev.js script - const { execSync } = await import('child_process'); - - try { - const smallResult = execSync(`node ${path.join(__dirname, 'dev.js')} parse-prd --input=${smallPRDPath} --tasks=5`, { - stdio: 'inherit' - }); - console.log('Small PRD test completed successfully'); - } catch (error) { - console.error('Small PRD test failed:', error.message); - } - - // Test 2: Medium PRD, 15 tasks - console.log('\n=== Test 2: Medium PRD, 15 tasks ==='); - const mediumPRD = createTestPRD('medium', 'simple'); - const mediumPRDPath = path.join(__dirname, 'test-medium-prd.txt'); - fs.writeFileSync(mediumPRDPath, mediumPRD, 'utf8'); - - console.log(`Created test PRD at ${mediumPRDPath}`); - console.log('Running dev.js with medium PRD...'); - - try { - const mediumResult = execSync(`node ${path.join(__dirname, 'dev.js')} parse-prd --input=${mediumPRDPath} --tasks=15`, { - stdio: 'inherit' - }); - console.log('Medium PRD test completed successfully'); - } catch (error) { - console.error('Medium PRD test failed:', error.message); - } - - // Test 3: Large PRD, 25 tasks - console.log('\n=== Test 3: Large PRD, 25 tasks ==='); - const largePRD = createTestPRD('large', 'complex'); - const largePRDPath = path.join(__dirname, 'test-large-prd.txt'); - fs.writeFileSync(largePRDPath, largePRD, 'utf8'); - - console.log(`Created test PRD at ${largePRDPath}`); - console.log('Running dev.js with large PRD...'); - - try { - const largeResult = execSync(`node ${path.join(__dirname, 'dev.js')} parse-prd --input=${largePRDPath} --tasks=25`, { - stdio: 'inherit' - }); - console.log('Large PRD test completed successfully'); - } catch (error) { - console.error('Large PRD test failed:', error.message); - } - - console.log('\nAll tests completed!'); - } catch (error) { - console.error('Test failed:', error); - } finally { - // Clean up test files - console.log('\nCleaning up test files...'); - const testFiles = [ - path.join(__dirname, 'test-small-prd.txt'), - path.join(__dirname, 'test-medium-prd.txt'), - path.join(__dirname, 'test-large-prd.txt') - ]; - - testFiles.forEach(file => { - if (fs.existsSync(file)) { - fs.unlinkSync(file); - console.log(`Deleted ${file}`); - } - }); - - console.log('Cleanup complete.'); - } + console.log('Starting tests for callClaude function improvements...'); + + try { + // Instead of importing the callClaude function directly, we'll use the dev.js script + // with our test PRDs by running it as a child process + + // Test 1: Small PRD, 5 tasks + console.log('\n=== Test 1: Small PRD, 5 tasks ==='); + const smallPRD = createTestPRD('small', 'simple'); + const smallPRDPath = path.join(__dirname, 'test-small-prd.txt'); + fs.writeFileSync(smallPRDPath, smallPRD, 'utf8'); + + console.log(`Created test PRD at ${smallPRDPath}`); + console.log('Running dev.js with small PRD...'); + + // Use the child_process module to run the dev.js script + const { execSync } = await import('child_process'); + + try { + const smallResult = execSync( + `node ${path.join(__dirname, 'dev.js')} parse-prd --input=${smallPRDPath} --tasks=5`, + { + stdio: 'inherit' + } + ); + console.log('Small PRD test completed successfully'); + } catch (error) { + console.error('Small PRD test failed:', error.message); + } + + // Test 2: Medium PRD, 15 tasks + console.log('\n=== Test 2: Medium PRD, 15 tasks ==='); + const mediumPRD = createTestPRD('medium', 'simple'); + const mediumPRDPath = path.join(__dirname, 'test-medium-prd.txt'); + fs.writeFileSync(mediumPRDPath, mediumPRD, 'utf8'); + + console.log(`Created test PRD at ${mediumPRDPath}`); + console.log('Running dev.js with medium PRD...'); + + try { + const mediumResult = execSync( + `node ${path.join(__dirname, 'dev.js')} parse-prd --input=${mediumPRDPath} --tasks=15`, + { + stdio: 'inherit' + } + ); + console.log('Medium PRD test completed successfully'); + } catch (error) { + console.error('Medium PRD test failed:', error.message); + } + + // Test 3: Large PRD, 25 tasks + console.log('\n=== Test 3: Large PRD, 25 tasks ==='); + const largePRD = createTestPRD('large', 'complex'); + const largePRDPath = path.join(__dirname, 'test-large-prd.txt'); + fs.writeFileSync(largePRDPath, largePRD, 'utf8'); + + console.log(`Created test PRD at ${largePRDPath}`); + console.log('Running dev.js with large PRD...'); + + try { + const largeResult = execSync( + `node ${path.join(__dirname, 'dev.js')} parse-prd --input=${largePRDPath} --tasks=25`, + { + stdio: 'inherit' + } + ); + console.log('Large PRD test completed successfully'); + } catch (error) { + console.error('Large PRD test failed:', error.message); + } + + console.log('\nAll tests completed!'); + } catch (error) { + console.error('Test failed:', error); + } finally { + // Clean up test files + console.log('\nCleaning up test files...'); + const testFiles = [ + path.join(__dirname, 'test-small-prd.txt'), + path.join(__dirname, 'test-medium-prd.txt'), + path.join(__dirname, 'test-large-prd.txt') + ]; + + testFiles.forEach((file) => { + if (fs.existsSync(file)) { + fs.unlinkSync(file); + console.log(`Deleted ${file}`); + } + }); + + console.log('Cleanup complete.'); + } } // Run the tests -runTests().catch(error => { - console.error('Error running tests:', error); - process.exit(1); -}); \ No newline at end of file +runTests().catch((error) => { + console.error('Error running tests:', error); + process.exit(1); +}); diff --git a/test-version-check-full.js b/test-version-check-full.js index da467790..c8fb9150 100644 --- a/test-version-check-full.js +++ b/test-version-check-full.js @@ -1,4 +1,8 @@ -import { checkForUpdate, displayUpgradeNotification, compareVersions } from './scripts/modules/commands.js'; +import { + checkForUpdate, + displayUpgradeNotification, + compareVersions +} from './scripts/modules/commands.js'; import fs from 'fs'; import path from 'path'; @@ -7,63 +11,73 @@ process.env.FORCE_VERSION = '0.9.30'; // Create a mock package.json in memory for testing const mockPackageJson = { - name: 'task-master-ai', - version: '0.9.30' + name: 'task-master-ai', + version: '0.9.30' }; // Modified version of checkForUpdate that doesn't use HTTP for testing async function testCheckForUpdate(simulatedLatestVersion) { - // Get current version - use our forced version - const currentVersion = process.env.FORCE_VERSION || '0.9.30'; - - console.log(`Using simulated current version: ${currentVersion}`); - console.log(`Using simulated latest version: ${simulatedLatestVersion}`); - - // Compare versions - const needsUpdate = compareVersions(currentVersion, simulatedLatestVersion) < 0; - - return { - currentVersion, - latestVersion: simulatedLatestVersion, - needsUpdate - }; + // Get current version - use our forced version + const currentVersion = process.env.FORCE_VERSION || '0.9.30'; + + console.log(`Using simulated current version: ${currentVersion}`); + console.log(`Using simulated latest version: ${simulatedLatestVersion}`); + + // Compare versions + const needsUpdate = + compareVersions(currentVersion, simulatedLatestVersion) < 0; + + return { + currentVersion, + latestVersion: simulatedLatestVersion, + needsUpdate + }; } // Test with current version older than latest (should show update notice) async function runTest() { - console.log('=== Testing version check scenarios ===\n'); - - // Scenario 1: Update available - console.log('\n--- Scenario 1: Update available (Current: 0.9.30, Latest: 1.0.0) ---'); - const updateInfo1 = await testCheckForUpdate('1.0.0'); - console.log('Update check results:'); - console.log(`- Current version: ${updateInfo1.currentVersion}`); - console.log(`- Latest version: ${updateInfo1.latestVersion}`); - console.log(`- Update needed: ${updateInfo1.needsUpdate}`); - - if (updateInfo1.needsUpdate) { - console.log('\nDisplaying upgrade notification:'); - displayUpgradeNotification(updateInfo1.currentVersion, updateInfo1.latestVersion); - } - - // Scenario 2: No update needed (versions equal) - console.log('\n--- Scenario 2: No update needed (Current: 0.9.30, Latest: 0.9.30) ---'); - const updateInfo2 = await testCheckForUpdate('0.9.30'); - console.log('Update check results:'); - console.log(`- Current version: ${updateInfo2.currentVersion}`); - console.log(`- Latest version: ${updateInfo2.latestVersion}`); - console.log(`- Update needed: ${updateInfo2.needsUpdate}`); - - // Scenario 3: Development version (current newer than latest) - console.log('\n--- Scenario 3: Development version (Current: 0.9.30, Latest: 0.9.0) ---'); - const updateInfo3 = await testCheckForUpdate('0.9.0'); - console.log('Update check results:'); - console.log(`- Current version: ${updateInfo3.currentVersion}`); - console.log(`- Latest version: ${updateInfo3.latestVersion}`); - console.log(`- Update needed: ${updateInfo3.needsUpdate}`); - - console.log('\n=== Test complete ==='); + console.log('=== Testing version check scenarios ===\n'); + + // Scenario 1: Update available + console.log( + '\n--- Scenario 1: Update available (Current: 0.9.30, Latest: 1.0.0) ---' + ); + const updateInfo1 = await testCheckForUpdate('1.0.0'); + console.log('Update check results:'); + console.log(`- Current version: ${updateInfo1.currentVersion}`); + console.log(`- Latest version: ${updateInfo1.latestVersion}`); + console.log(`- Update needed: ${updateInfo1.needsUpdate}`); + + if (updateInfo1.needsUpdate) { + console.log('\nDisplaying upgrade notification:'); + displayUpgradeNotification( + updateInfo1.currentVersion, + updateInfo1.latestVersion + ); + } + + // Scenario 2: No update needed (versions equal) + console.log( + '\n--- Scenario 2: No update needed (Current: 0.9.30, Latest: 0.9.30) ---' + ); + const updateInfo2 = await testCheckForUpdate('0.9.30'); + console.log('Update check results:'); + console.log(`- Current version: ${updateInfo2.currentVersion}`); + console.log(`- Latest version: ${updateInfo2.latestVersion}`); + console.log(`- Update needed: ${updateInfo2.needsUpdate}`); + + // Scenario 3: Development version (current newer than latest) + console.log( + '\n--- Scenario 3: Development version (Current: 0.9.30, Latest: 0.9.0) ---' + ); + const updateInfo3 = await testCheckForUpdate('0.9.0'); + console.log('Update check results:'); + console.log(`- Current version: ${updateInfo3.currentVersion}`); + console.log(`- Latest version: ${updateInfo3.latestVersion}`); + console.log(`- Update needed: ${updateInfo3.needsUpdate}`); + + console.log('\n=== Test complete ==='); } // Run all tests -runTest(); \ No newline at end of file +runTest(); diff --git a/test-version-check.js b/test-version-check.js index 13dfe7a4..b1abdbfa 100644 --- a/test-version-check.js +++ b/test-version-check.js @@ -1,4 +1,7 @@ -import { displayUpgradeNotification, compareVersions } from './scripts/modules/commands.js'; +import { + displayUpgradeNotification, + compareVersions +} from './scripts/modules/commands.js'; // Simulate different version scenarios console.log('=== Simulating version check ===\n'); @@ -8,15 +11,25 @@ console.log('Scenario 1: Current version older than latest'); displayUpgradeNotification('0.9.30', '1.0.0'); // 2. Current version same as latest (no update needed) -console.log('\nScenario 2: Current version same as latest (this would not normally show a notice)'); +console.log( + '\nScenario 2: Current version same as latest (this would not normally show a notice)' +); console.log('Current: 1.0.0, Latest: 1.0.0'); console.log('compareVersions result:', compareVersions('1.0.0', '1.0.0')); -console.log('Update needed:', compareVersions('1.0.0', '1.0.0') < 0 ? 'Yes' : 'No'); +console.log( + 'Update needed:', + compareVersions('1.0.0', '1.0.0') < 0 ? 'Yes' : 'No' +); // 3. Current version newer than latest (e.g., development version, would not show notice) -console.log('\nScenario 3: Current version newer than latest (this would not normally show a notice)'); +console.log( + '\nScenario 3: Current version newer than latest (this would not normally show a notice)' +); console.log('Current: 1.1.0, Latest: 1.0.0'); console.log('compareVersions result:', compareVersions('1.1.0', '1.0.0')); -console.log('Update needed:', compareVersions('1.1.0', '1.0.0') < 0 ? 'Yes' : 'No'); +console.log( + 'Update needed:', + compareVersions('1.1.0', '1.0.0') < 0 ? 'Yes' : 'No' +); -console.log('\n=== Test complete ==='); \ No newline at end of file +console.log('\n=== Test complete ==='); diff --git a/tests/README.md b/tests/README.md index e5076eb1..2b3531aa 100644 --- a/tests/README.md +++ b/tests/README.md @@ -60,4 +60,4 @@ We aim for at least 80% test coverage for all code paths. Coverage reports can b ```bash npm run test:coverage -``` \ No newline at end of file +``` diff --git a/tests/fixtures/sample-claude-response.js b/tests/fixtures/sample-claude-response.js index 69dd6196..a5722a6a 100644 --- a/tests/fixtures/sample-claude-response.js +++ b/tests/fixtures/sample-claude-response.js @@ -3,42 +3,50 @@ */ export const sampleClaudeResponse = { - tasks: [ - { - id: 1, - title: "Setup Task Data Structure", - description: "Implement the core task data structure and file operations", - status: "pending", - dependencies: [], - priority: "high", - details: "Create the tasks.json file structure with support for task properties including ID, title, description, status, dependencies, priority, details, and test strategy. Implement file system operations for reading and writing task data.", - testStrategy: "Verify tasks.json is created with the correct structure and that task data can be read from and written to the file." - }, - { - id: 2, - title: "Implement CLI Foundation", - description: "Create the command-line interface foundation with basic commands", - status: "pending", - dependencies: [1], - priority: "high", - details: "Set up Commander.js for handling CLI commands. Implement the basic command structure including help documentation. Create the foundational command parsing logic.", - testStrategy: "Test each command to ensure it properly parses arguments and options. Verify help documentation is displayed correctly." - }, - { - id: 3, - title: "Develop Task Management Operations", - description: "Implement core operations for creating, reading, updating, and deleting tasks", - status: "pending", - dependencies: [1], - priority: "medium", - details: "Implement functions for listing tasks, adding new tasks, updating task status, and removing tasks. Include support for filtering tasks by status and other properties.", - testStrategy: "Create unit tests for each CRUD operation to verify they correctly modify the task data." - } - ], - metadata: { - projectName: "Task Management CLI", - totalTasks: 3, - sourceFile: "tests/fixtures/sample-prd.txt", - generatedAt: "2023-12-15" - } -}; \ No newline at end of file + tasks: [ + { + id: 1, + title: 'Setup Task Data Structure', + description: 'Implement the core task data structure and file operations', + status: 'pending', + dependencies: [], + priority: 'high', + details: + 'Create the tasks.json file structure with support for task properties including ID, title, description, status, dependencies, priority, details, and test strategy. Implement file system operations for reading and writing task data.', + testStrategy: + 'Verify tasks.json is created with the correct structure and that task data can be read from and written to the file.' + }, + { + id: 2, + title: 'Implement CLI Foundation', + description: + 'Create the command-line interface foundation with basic commands', + status: 'pending', + dependencies: [1], + priority: 'high', + details: + 'Set up Commander.js for handling CLI commands. Implement the basic command structure including help documentation. Create the foundational command parsing logic.', + testStrategy: + 'Test each command to ensure it properly parses arguments and options. Verify help documentation is displayed correctly.' + }, + { + id: 3, + title: 'Develop Task Management Operations', + description: + 'Implement core operations for creating, reading, updating, and deleting tasks', + status: 'pending', + dependencies: [1], + priority: 'medium', + details: + 'Implement functions for listing tasks, adding new tasks, updating task status, and removing tasks. Include support for filtering tasks by status and other properties.', + testStrategy: + 'Create unit tests for each CRUD operation to verify they correctly modify the task data.' + } + ], + metadata: { + projectName: 'Task Management CLI', + totalTasks: 3, + sourceFile: 'tests/fixtures/sample-prd.txt', + generatedAt: '2023-12-15' + } +}; diff --git a/tests/fixtures/sample-tasks.js b/tests/fixtures/sample-tasks.js index 396afe19..d75ec4ca 100644 --- a/tests/fixtures/sample-tasks.js +++ b/tests/fixtures/sample-tasks.js @@ -3,70 +3,72 @@ */ export const sampleTasks = { - meta: { - projectName: "Test Project", - projectVersion: "1.0.0", - createdAt: "2023-01-01T00:00:00.000Z", - updatedAt: "2023-01-01T00:00:00.000Z" - }, - tasks: [ - { - id: 1, - title: "Initialize Project", - description: "Set up the project structure and dependencies", - status: "done", - dependencies: [], - priority: "high", - details: "Create directory structure, initialize package.json, and install dependencies", - testStrategy: "Verify all directories and files are created correctly" - }, - { - id: 2, - title: "Create Core Functionality", - description: "Implement the main features of the application", - status: "in-progress", - dependencies: [1], - priority: "high", - details: "Implement user authentication, data processing, and API endpoints", - testStrategy: "Write unit tests for all core functions" - }, - { - id: 3, - title: "Implement UI Components", - description: "Create the user interface components", - status: "pending", - dependencies: [2], - priority: "medium", - details: "Design and implement React components for the user interface", - testStrategy: "Test components with React Testing Library", - subtasks: [ - { - id: 1, - title: "Create Header Component", - description: "Implement the header component", - status: "pending", - dependencies: [], - details: "Create a responsive header with navigation links" - }, - { - id: 2, - title: "Create Footer Component", - description: "Implement the footer component", - status: "pending", - dependencies: [], - details: "Create a footer with copyright information and links" - } - ] - } - ] + meta: { + projectName: 'Test Project', + projectVersion: '1.0.0', + createdAt: '2023-01-01T00:00:00.000Z', + updatedAt: '2023-01-01T00:00:00.000Z' + }, + tasks: [ + { + id: 1, + title: 'Initialize Project', + description: 'Set up the project structure and dependencies', + status: 'done', + dependencies: [], + priority: 'high', + details: + 'Create directory structure, initialize package.json, and install dependencies', + testStrategy: 'Verify all directories and files are created correctly' + }, + { + id: 2, + title: 'Create Core Functionality', + description: 'Implement the main features of the application', + status: 'in-progress', + dependencies: [1], + priority: 'high', + details: + 'Implement user authentication, data processing, and API endpoints', + testStrategy: 'Write unit tests for all core functions' + }, + { + id: 3, + title: 'Implement UI Components', + description: 'Create the user interface components', + status: 'pending', + dependencies: [2], + priority: 'medium', + details: 'Design and implement React components for the user interface', + testStrategy: 'Test components with React Testing Library', + subtasks: [ + { + id: 1, + title: 'Create Header Component', + description: 'Implement the header component', + status: 'pending', + dependencies: [], + details: 'Create a responsive header with navigation links' + }, + { + id: 2, + title: 'Create Footer Component', + description: 'Implement the footer component', + status: 'pending', + dependencies: [], + details: 'Create a footer with copyright information and links' + } + ] + } + ] }; export const emptySampleTasks = { - meta: { - projectName: "Empty Project", - projectVersion: "1.0.0", - createdAt: "2023-01-01T00:00:00.000Z", - updatedAt: "2023-01-01T00:00:00.000Z" - }, - tasks: [] -}; \ No newline at end of file + meta: { + projectName: 'Empty Project', + projectVersion: '1.0.0', + createdAt: '2023-01-01T00:00:00.000Z', + updatedAt: '2023-01-01T00:00:00.000Z' + }, + tasks: [] +}; diff --git a/tests/integration/mcp-server/direct-functions.test.js b/tests/integration/mcp-server/direct-functions.test.js index e8c8c427..129bc03f 100644 --- a/tests/integration/mcp-server/direct-functions.test.js +++ b/tests/integration/mcp-server/direct-functions.test.js @@ -17,10 +17,10 @@ import { listTasksDirect } from '../../../mcp-server/src/core/task-master-core.j // Mock logger const mockLogger = { - info: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - warn: jest.fn() + info: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn() }; // Test file paths @@ -28,164 +28,164 @@ const testProjectRoot = path.join(__dirname, '../../fixture'); const testTasksPath = path.join(testProjectRoot, 'test-tasks.json'); describe('MCP Server Direct Functions', () => { - // Create test data before tests - beforeAll(() => { - // Create test directory if it doesn't exist - if (!fs.existsSync(testProjectRoot)) { - fs.mkdirSync(testProjectRoot, { recursive: true }); - } - - // Create a sample tasks.json file for testing - const sampleTasks = { - meta: { - projectName: 'Test Project', - version: '1.0.0' - }, - tasks: [ - { - id: 1, - title: 'Task 1', - description: 'First task', - status: 'done', - dependencies: [], - priority: 'high' - }, - { - id: 2, - title: 'Task 2', - description: 'Second task', - status: 'in-progress', - dependencies: [1], - priority: 'medium', - subtasks: [ - { - id: 1, - title: 'Subtask 2.1', - description: 'First subtask', - status: 'done' - }, - { - id: 2, - title: 'Subtask 2.2', - description: 'Second subtask', - status: 'pending' - } - ] - }, - { - id: 3, - title: 'Task 3', - description: 'Third task', - status: 'pending', - dependencies: [1, 2], - priority: 'low' - } - ] - }; - - fs.writeFileSync(testTasksPath, JSON.stringify(sampleTasks, null, 2)); - }); - - // Clean up after tests - afterAll(() => { - // Remove test tasks file - if (fs.existsSync(testTasksPath)) { - fs.unlinkSync(testTasksPath); - } - - // Try to remove the directory (will only work if empty) - try { - fs.rmdirSync(testProjectRoot); - } catch (error) { - // Ignore errors if the directory isn't empty - } - }); - - // Reset mocks before each test - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('listTasksDirect', () => { - test('should return all tasks when no filter is provided', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: testTasksPath - }; - - // Act - const result = await listTasksDirect(args, mockLogger); - - // Assert - expect(result.success).toBe(true); - expect(result.data.tasks.length).toBe(3); - expect(result.data.stats.total).toBe(3); - expect(result.data.stats.completed).toBe(1); - expect(result.data.stats.inProgress).toBe(1); - expect(result.data.stats.pending).toBe(1); - expect(mockLogger.info).toHaveBeenCalled(); - }); - - test('should filter tasks by status', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: testTasksPath, - status: 'pending' - }; - - // Act - const result = await listTasksDirect(args, mockLogger); - - // Assert - expect(result.success).toBe(true); - expect(result.data.tasks.length).toBe(1); - expect(result.data.tasks[0].id).toBe(3); - expect(result.data.filter).toBe('pending'); - }); - - test('should include subtasks when requested', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: testTasksPath, - withSubtasks: true - }; - - // Act - const result = await listTasksDirect(args, mockLogger); - - // Assert - expect(result.success).toBe(true); - - // Verify subtasks are included - const taskWithSubtasks = result.data.tasks.find(t => t.id === 2); - expect(taskWithSubtasks.subtasks).toBeDefined(); - expect(taskWithSubtasks.subtasks.length).toBe(2); - - // Verify subtask details - expect(taskWithSubtasks.subtasks[0].id).toBe(1); - expect(taskWithSubtasks.subtasks[0].title).toBe('Subtask 2.1'); - expect(taskWithSubtasks.subtasks[0].status).toBe('done'); - }); - - test('should handle errors gracefully', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: 'non-existent-file.json' - }; - - // Act - const result = await listTasksDirect(args, mockLogger); - - // Assert - expect(result.success).toBe(false); - expect(result.error).toBeDefined(); - expect(result.error.code).toBeDefined(); - expect(result.error.message).toBeDefined(); - expect(mockLogger.error).toHaveBeenCalled(); - }); - }); -}); \ No newline at end of file + // Create test data before tests + beforeAll(() => { + // Create test directory if it doesn't exist + if (!fs.existsSync(testProjectRoot)) { + fs.mkdirSync(testProjectRoot, { recursive: true }); + } + + // Create a sample tasks.json file for testing + const sampleTasks = { + meta: { + projectName: 'Test Project', + version: '1.0.0' + }, + tasks: [ + { + id: 1, + title: 'Task 1', + description: 'First task', + status: 'done', + dependencies: [], + priority: 'high' + }, + { + id: 2, + title: 'Task 2', + description: 'Second task', + status: 'in-progress', + dependencies: [1], + priority: 'medium', + subtasks: [ + { + id: 1, + title: 'Subtask 2.1', + description: 'First subtask', + status: 'done' + }, + { + id: 2, + title: 'Subtask 2.2', + description: 'Second subtask', + status: 'pending' + } + ] + }, + { + id: 3, + title: 'Task 3', + description: 'Third task', + status: 'pending', + dependencies: [1, 2], + priority: 'low' + } + ] + }; + + fs.writeFileSync(testTasksPath, JSON.stringify(sampleTasks, null, 2)); + }); + + // Clean up after tests + afterAll(() => { + // Remove test tasks file + if (fs.existsSync(testTasksPath)) { + fs.unlinkSync(testTasksPath); + } + + // Try to remove the directory (will only work if empty) + try { + fs.rmdirSync(testProjectRoot); + } catch (error) { + // Ignore errors if the directory isn't empty + } + }); + + // Reset mocks before each test + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('listTasksDirect', () => { + test('should return all tasks when no filter is provided', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: testTasksPath + }; + + // Act + const result = await listTasksDirect(args, mockLogger); + + // Assert + expect(result.success).toBe(true); + expect(result.data.tasks.length).toBe(3); + expect(result.data.stats.total).toBe(3); + expect(result.data.stats.completed).toBe(1); + expect(result.data.stats.inProgress).toBe(1); + expect(result.data.stats.pending).toBe(1); + expect(mockLogger.info).toHaveBeenCalled(); + }); + + test('should filter tasks by status', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: testTasksPath, + status: 'pending' + }; + + // Act + const result = await listTasksDirect(args, mockLogger); + + // Assert + expect(result.success).toBe(true); + expect(result.data.tasks.length).toBe(1); + expect(result.data.tasks[0].id).toBe(3); + expect(result.data.filter).toBe('pending'); + }); + + test('should include subtasks when requested', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: testTasksPath, + withSubtasks: true + }; + + // Act + const result = await listTasksDirect(args, mockLogger); + + // Assert + expect(result.success).toBe(true); + + // Verify subtasks are included + const taskWithSubtasks = result.data.tasks.find((t) => t.id === 2); + expect(taskWithSubtasks.subtasks).toBeDefined(); + expect(taskWithSubtasks.subtasks.length).toBe(2); + + // Verify subtask details + expect(taskWithSubtasks.subtasks[0].id).toBe(1); + expect(taskWithSubtasks.subtasks[0].title).toBe('Subtask 2.1'); + expect(taskWithSubtasks.subtasks[0].status).toBe('done'); + }); + + test('should handle errors gracefully', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: 'non-existent-file.json' + }; + + // Act + const result = await listTasksDirect(args, mockLogger); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error.code).toBeDefined(); + expect(result.error.message).toBeDefined(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/setup.js b/tests/setup.js index 511b3554..40ebd479 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -1,6 +1,6 @@ /** * Jest setup file - * + * * This file is run before each test suite to set up the test environment. */ @@ -16,15 +16,15 @@ process.env.PROJECT_NAME = 'Test Project'; process.env.PROJECT_VERSION = '1.0.0'; // Add global test helpers if needed -global.wait = (ms) => new Promise(resolve => setTimeout(resolve, ms)); +global.wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // If needed, silence console during tests if (process.env.SILENCE_CONSOLE === 'true') { - global.console = { - ...console, - log: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }; -} \ No newline at end of file + global.console = { + ...console, + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() + }; +} diff --git a/tests/unit/ai-services.test.js b/tests/unit/ai-services.test.js index 232b93bc..e129c151 100644 --- a/tests/unit/ai-services.test.js +++ b/tests/unit/ai-services.test.js @@ -10,62 +10,68 @@ const mockLog = jest.fn(); // Mock dependencies jest.mock('@anthropic-ai/sdk', () => { - const mockCreate = jest.fn().mockResolvedValue({ - content: [{ text: 'AI response' }], - }); - const mockAnthropicInstance = { - messages: { - create: mockCreate - } - }; - const mockAnthropicConstructor = jest.fn().mockImplementation(() => mockAnthropicInstance); - return { - Anthropic: mockAnthropicConstructor - }; + const mockCreate = jest.fn().mockResolvedValue({ + content: [{ text: 'AI response' }] + }); + const mockAnthropicInstance = { + messages: { + create: mockCreate + } + }; + const mockAnthropicConstructor = jest + .fn() + .mockImplementation(() => mockAnthropicInstance); + return { + Anthropic: mockAnthropicConstructor + }; }); // Use jest.fn() directly for OpenAI mock const mockOpenAIInstance = { - chat: { - completions: { - create: jest.fn().mockResolvedValue({ - choices: [{ message: { content: 'Perplexity response' } }], - }), - }, - }, + chat: { + completions: { + create: jest.fn().mockResolvedValue({ + choices: [{ message: { content: 'Perplexity response' } }] + }) + } + } }; const mockOpenAI = jest.fn().mockImplementation(() => mockOpenAIInstance); jest.mock('openai', () => { - return { default: mockOpenAI }; + return { default: mockOpenAI }; }); jest.mock('dotenv', () => ({ - config: jest.fn(), + config: jest.fn() })); jest.mock('../../scripts/modules/utils.js', () => ({ - CONFIG: { - model: 'claude-3-sonnet-20240229', - temperature: 0.7, - maxTokens: 4000, - }, - log: mockLog, - sanitizePrompt: jest.fn(text => text), + CONFIG: { + model: 'claude-3-sonnet-20240229', + temperature: 0.7, + maxTokens: 4000 + }, + log: mockLog, + sanitizePrompt: jest.fn((text) => text) })); jest.mock('../../scripts/modules/ui.js', () => ({ - startLoadingIndicator: jest.fn().mockReturnValue('mockLoader'), - stopLoadingIndicator: jest.fn(), + startLoadingIndicator: jest.fn().mockReturnValue('mockLoader'), + stopLoadingIndicator: jest.fn() })); // Mock anthropic global object global.anthropic = { - messages: { - create: jest.fn().mockResolvedValue({ - content: [{ text: '[{"id": 1, "title": "Test", "description": "Test", "dependencies": [], "details": "Test"}]' }], - }), - }, + messages: { + create: jest.fn().mockResolvedValue({ + content: [ + { + text: '[{"id": 1, "title": "Test", "description": "Test", "dependencies": [], "details": "Test"}]' + } + ] + }) + } }; // Mock process.env @@ -75,20 +81,20 @@ const originalEnv = process.env; import { Anthropic } from '@anthropic-ai/sdk'; describe('AI Services Module', () => { - beforeEach(() => { - jest.clearAllMocks(); - process.env = { ...originalEnv }; - process.env.ANTHROPIC_API_KEY = 'test-anthropic-key'; - process.env.PERPLEXITY_API_KEY = 'test-perplexity-key'; - }); + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + process.env.ANTHROPIC_API_KEY = 'test-anthropic-key'; + process.env.PERPLEXITY_API_KEY = 'test-perplexity-key'; + }); - afterEach(() => { - process.env = originalEnv; - }); + afterEach(() => { + process.env = originalEnv; + }); - describe('parseSubtasksFromText function', () => { - test('should parse subtasks from JSON text', () => { - const text = `Here's your list of subtasks: + describe('parseSubtasksFromText function', () => { + test('should parse subtasks from JSON text', () => { + const text = `Here's your list of subtasks: [ { @@ -109,31 +115,31 @@ describe('AI Services Module', () => { These subtasks will help you implement the parent task efficiently.`; - const result = parseSubtasksFromText(text, 1, 2, 5); - - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ - id: 1, - title: 'Implement database schema', - description: 'Design and implement the database schema for user data', - status: 'pending', - dependencies: [], - details: 'Create tables for users, preferences, and settings', - parentTaskId: 5 - }); - expect(result[1]).toEqual({ - id: 2, - title: 'Create API endpoints', - description: 'Develop RESTful API endpoints for user operations', - status: 'pending', - dependencies: [], - details: 'Implement CRUD operations for user management', - parentTaskId: 5 - }); - }); + const result = parseSubtasksFromText(text, 1, 2, 5); - test('should handle subtasks with dependencies', () => { - const text = ` + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: 1, + title: 'Implement database schema', + description: 'Design and implement the database schema for user data', + status: 'pending', + dependencies: [], + details: 'Create tables for users, preferences, and settings', + parentTaskId: 5 + }); + expect(result[1]).toEqual({ + id: 2, + title: 'Create API endpoints', + description: 'Develop RESTful API endpoints for user operations', + status: 'pending', + dependencies: [], + details: 'Implement CRUD operations for user management', + parentTaskId: 5 + }); + }); + + test('should handle subtasks with dependencies', () => { + const text = ` [ { "id": 1, @@ -151,15 +157,15 @@ These subtasks will help you implement the parent task efficiently.`; } ]`; - const result = parseSubtasksFromText(text, 1, 2, 5); - - expect(result).toHaveLength(2); - expect(result[0].dependencies).toEqual([]); - expect(result[1].dependencies).toEqual([1]); - }); + const result = parseSubtasksFromText(text, 1, 2, 5); - test('should handle complex dependency lists', () => { - const text = ` + expect(result).toHaveLength(2); + expect(result[0].dependencies).toEqual([]); + expect(result[1].dependencies).toEqual([1]); + }); + + test('should handle complex dependency lists', () => { + const text = ` [ { "id": 1, @@ -184,39 +190,39 @@ These subtasks will help you implement the parent task efficiently.`; } ]`; - const result = parseSubtasksFromText(text, 1, 3, 5); - - expect(result).toHaveLength(3); - expect(result[2].dependencies).toEqual([1, 2]); - }); + const result = parseSubtasksFromText(text, 1, 3, 5); - test('should create fallback subtasks for empty text', () => { - const emptyText = ''; - - const result = parseSubtasksFromText(emptyText, 1, 2, 5); - - // Verify fallback subtasks structure - expect(result).toHaveLength(2); - expect(result[0]).toMatchObject({ - id: 1, - title: 'Subtask 1', - description: 'Auto-generated fallback subtask', - status: 'pending', - dependencies: [], - parentTaskId: 5 - }); - expect(result[1]).toMatchObject({ - id: 2, - title: 'Subtask 2', - description: 'Auto-generated fallback subtask', - status: 'pending', - dependencies: [], - parentTaskId: 5 - }); - }); + expect(result).toHaveLength(3); + expect(result[2].dependencies).toEqual([1, 2]); + }); - test('should normalize subtask IDs', () => { - const text = ` + test('should create fallback subtasks for empty text', () => { + const emptyText = ''; + + const result = parseSubtasksFromText(emptyText, 1, 2, 5); + + // Verify fallback subtasks structure + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + id: 1, + title: 'Subtask 1', + description: 'Auto-generated fallback subtask', + status: 'pending', + dependencies: [], + parentTaskId: 5 + }); + expect(result[1]).toMatchObject({ + id: 2, + title: 'Subtask 2', + description: 'Auto-generated fallback subtask', + status: 'pending', + dependencies: [], + parentTaskId: 5 + }); + }); + + test('should normalize subtask IDs', () => { + const text = ` [ { "id": 10, @@ -234,15 +240,15 @@ These subtasks will help you implement the parent task efficiently.`; } ]`; - const result = parseSubtasksFromText(text, 1, 2, 5); - - expect(result).toHaveLength(2); - expect(result[0].id).toBe(1); // Should normalize to starting ID - expect(result[1].id).toBe(2); // Should normalize to starting ID + 1 - }); + const result = parseSubtasksFromText(text, 1, 2, 5); - test('should convert string dependencies to numbers', () => { - const text = ` + expect(result).toHaveLength(2); + expect(result[0].id).toBe(1); // Should normalize to starting ID + expect(result[1].id).toBe(2); // Should normalize to starting ID + 1 + }); + + test('should convert string dependencies to numbers', () => { + const text = ` [ { "id": 1, @@ -260,140 +266,142 @@ These subtasks will help you implement the parent task efficiently.`; } ]`; - const result = parseSubtasksFromText(text, 1, 2, 5); - - expect(result[1].dependencies).toEqual([1]); - expect(typeof result[1].dependencies[0]).toBe('number'); - }); + const result = parseSubtasksFromText(text, 1, 2, 5); - test('should create fallback subtasks for invalid JSON', () => { - const text = `This is not valid JSON and cannot be parsed`; + expect(result[1].dependencies).toEqual([1]); + expect(typeof result[1].dependencies[0]).toBe('number'); + }); - const result = parseSubtasksFromText(text, 1, 2, 5); - - // Verify fallback subtasks structure - expect(result).toHaveLength(2); - expect(result[0]).toMatchObject({ - id: 1, - title: 'Subtask 1', - description: 'Auto-generated fallback subtask', - status: 'pending', - dependencies: [], - parentTaskId: 5 - }); - expect(result[1]).toMatchObject({ - id: 2, - title: 'Subtask 2', - description: 'Auto-generated fallback subtask', - status: 'pending', - dependencies: [], - parentTaskId: 5 - }); - }); - }); + test('should create fallback subtasks for invalid JSON', () => { + const text = `This is not valid JSON and cannot be parsed`; - describe('handleClaudeError function', () => { - // Import the function directly for testing - let handleClaudeError; - - beforeAll(async () => { - // Dynamic import to get the actual function - const module = await import('../../scripts/modules/ai-services.js'); - handleClaudeError = module.handleClaudeError; - }); + const result = parseSubtasksFromText(text, 1, 2, 5); - test('should handle overloaded_error type', () => { - const error = { - type: 'error', - error: { - type: 'overloaded_error', - message: 'Claude is experiencing high volume' - } - }; - - // Mock process.env to include PERPLEXITY_API_KEY - const originalEnv = process.env; - process.env = { ...originalEnv, PERPLEXITY_API_KEY: 'test-key' }; - - const result = handleClaudeError(error); - - // Restore original env - process.env = originalEnv; - - expect(result).toContain('Claude is currently overloaded'); - expect(result).toContain('fall back to Perplexity AI'); - }); + // Verify fallback subtasks structure + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + id: 1, + title: 'Subtask 1', + description: 'Auto-generated fallback subtask', + status: 'pending', + dependencies: [], + parentTaskId: 5 + }); + expect(result[1]).toMatchObject({ + id: 2, + title: 'Subtask 2', + description: 'Auto-generated fallback subtask', + status: 'pending', + dependencies: [], + parentTaskId: 5 + }); + }); + }); - test('should handle rate_limit_error type', () => { - const error = { - type: 'error', - error: { - type: 'rate_limit_error', - message: 'Rate limit exceeded' - } - }; - - const result = handleClaudeError(error); - - expect(result).toContain('exceeded the rate limit'); - }); + describe('handleClaudeError function', () => { + // Import the function directly for testing + let handleClaudeError; - test('should handle invalid_request_error type', () => { - const error = { - type: 'error', - error: { - type: 'invalid_request_error', - message: 'Invalid request parameters' - } - }; - - const result = handleClaudeError(error); - - expect(result).toContain('issue with the request format'); - }); + beforeAll(async () => { + // Dynamic import to get the actual function + const module = await import('../../scripts/modules/ai-services.js'); + handleClaudeError = module.handleClaudeError; + }); - test('should handle timeout errors', () => { - const error = { - message: 'Request timed out after 60000ms' - }; - - const result = handleClaudeError(error); - - expect(result).toContain('timed out'); - }); + test('should handle overloaded_error type', () => { + const error = { + type: 'error', + error: { + type: 'overloaded_error', + message: 'Claude is experiencing high volume' + } + }; - test('should handle network errors', () => { - const error = { - message: 'Network error occurred' - }; - - const result = handleClaudeError(error); - - expect(result).toContain('network error'); - }); + // Mock process.env to include PERPLEXITY_API_KEY + const originalEnv = process.env; + process.env = { ...originalEnv, PERPLEXITY_API_KEY: 'test-key' }; - test('should handle generic errors', () => { - const error = { - message: 'Something unexpected happened' - }; - - const result = handleClaudeError(error); - - expect(result).toContain('Error communicating with Claude'); - expect(result).toContain('Something unexpected happened'); - }); - }); + const result = handleClaudeError(error); - describe('Anthropic client configuration', () => { - test('should include output-128k beta header in client configuration', async () => { - // Read the file content to verify the change is present - const fs = await import('fs'); - const path = await import('path'); - const filePath = path.resolve('./scripts/modules/ai-services.js'); - const fileContent = fs.readFileSync(filePath, 'utf8'); - - // Check if the beta header is in the file - expect(fileContent).toContain("'anthropic-beta': 'output-128k-2025-02-19'"); - }); - }); -}); \ No newline at end of file + // Restore original env + process.env = originalEnv; + + expect(result).toContain('Claude is currently overloaded'); + expect(result).toContain('fall back to Perplexity AI'); + }); + + test('should handle rate_limit_error type', () => { + const error = { + type: 'error', + error: { + type: 'rate_limit_error', + message: 'Rate limit exceeded' + } + }; + + const result = handleClaudeError(error); + + expect(result).toContain('exceeded the rate limit'); + }); + + test('should handle invalid_request_error type', () => { + const error = { + type: 'error', + error: { + type: 'invalid_request_error', + message: 'Invalid request parameters' + } + }; + + const result = handleClaudeError(error); + + expect(result).toContain('issue with the request format'); + }); + + test('should handle timeout errors', () => { + const error = { + message: 'Request timed out after 60000ms' + }; + + const result = handleClaudeError(error); + + expect(result).toContain('timed out'); + }); + + test('should handle network errors', () => { + const error = { + message: 'Network error occurred' + }; + + const result = handleClaudeError(error); + + expect(result).toContain('network error'); + }); + + test('should handle generic errors', () => { + const error = { + message: 'Something unexpected happened' + }; + + const result = handleClaudeError(error); + + expect(result).toContain('Error communicating with Claude'); + expect(result).toContain('Something unexpected happened'); + }); + }); + + describe('Anthropic client configuration', () => { + test('should include output-128k beta header in client configuration', async () => { + // Read the file content to verify the change is present + const fs = await import('fs'); + const path = await import('path'); + const filePath = path.resolve('./scripts/modules/ai-services.js'); + const fileContent = fs.readFileSync(filePath, 'utf8'); + + // Check if the beta header is in the file + expect(fileContent).toContain( + "'anthropic-beta': 'output-128k-2025-02-19'" + ); + }); + }); +}); diff --git a/tests/unit/commands.test.js b/tests/unit/commands.test.js index 80d10f1d..f2d5d2a1 100644 --- a/tests/unit/commands.test.js +++ b/tests/unit/commands.test.js @@ -7,9 +7,9 @@ import { jest } from '@jest/globals'; // Mock functions that need jest.fn methods const mockParsePRD = jest.fn().mockResolvedValue(undefined); const mockUpdateTaskById = jest.fn().mockResolvedValue({ - id: 2, - title: 'Updated Task', - description: 'Updated description' + id: 2, + title: 'Updated Task', + description: 'Updated description' }); const mockDisplayBanner = jest.fn(); const mockDisplayHelp = jest.fn(); @@ -17,33 +17,33 @@ const mockLog = jest.fn(); // Mock modules first jest.mock('fs', () => ({ - existsSync: jest.fn(), - readFileSync: jest.fn() + existsSync: jest.fn(), + readFileSync: jest.fn() })); jest.mock('path', () => ({ - join: jest.fn((dir, file) => `${dir}/${file}`) + join: jest.fn((dir, file) => `${dir}/${file}`) })); jest.mock('chalk', () => ({ - red: jest.fn(text => text), - blue: jest.fn(text => text), - green: jest.fn(text => text), - yellow: jest.fn(text => text), - white: jest.fn(text => ({ - bold: jest.fn(text => text) - })), - reset: jest.fn(text => text) + red: jest.fn((text) => text), + blue: jest.fn((text) => text), + green: jest.fn((text) => text), + yellow: jest.fn((text) => text), + white: jest.fn((text) => ({ + bold: jest.fn((text) => text) + })), + reset: jest.fn((text) => text) })); jest.mock('../../scripts/modules/ui.js', () => ({ - displayBanner: mockDisplayBanner, - displayHelp: mockDisplayHelp + displayBanner: mockDisplayBanner, + displayHelp: mockDisplayHelp })); jest.mock('../../scripts/modules/task-manager.js', () => ({ - parsePRD: mockParsePRD, - updateTaskById: mockUpdateTaskById + parsePRD: mockParsePRD, + updateTaskById: mockUpdateTaskById })); // Add this function before the mock of utils.js @@ -53,10 +53,10 @@ jest.mock('../../scripts/modules/task-manager.js', () => ({ * @returns {string} kebab-case version of the input */ const toKebabCase = (str) => { - return str - .replace(/([a-z0-9])([A-Z])/g, '$1-$2') - .toLowerCase() - .replace(/^-/, ''); // Remove leading hyphen if present + return str + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .toLowerCase() + .replace(/^-/, ''); // Remove leading hyphen if present }; /** @@ -65,37 +65,37 @@ const toKebabCase = (str) => { * @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 if it's a single word (no hyphens) or already in kebab-case - if (!flagName.includes('-')) { - // 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; + const camelCaseFlags = []; + for (const arg of args) { + if (arg.startsWith('--')) { + const flagName = arg.split('=')[0].slice(2); // Remove -- and anything after = + + // Skip if it's a single word (no hyphens) or already in kebab-case + if (!flagName.includes('-')) { + // 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; } // Then update the utils.js mock to include these functions jest.mock('../../scripts/modules/utils.js', () => ({ - CONFIG: { - projectVersion: '1.5.0' - }, - log: mockLog, - toKebabCase: toKebabCase, - detectCamelCaseFlags: detectCamelCaseFlags + CONFIG: { + projectVersion: '1.5.0' + }, + log: mockLog, + toKebabCase: toKebabCase, + detectCamelCaseFlags: detectCamelCaseFlags })); // Import all modules after mocking @@ -106,479 +106,592 @@ import { setupCLI } from '../../scripts/modules/commands.js'; // We'll use a simplified, direct test approach instead of Commander mocking describe('Commands Module', () => { - // Set up spies on the mocked modules - const mockExistsSync = jest.spyOn(fs, 'existsSync'); - const mockReadFileSync = jest.spyOn(fs, 'readFileSync'); - const mockJoin = jest.spyOn(path, 'join'); - const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); - const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); - const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}); + // Set up spies on the mocked modules + const mockExistsSync = jest.spyOn(fs, 'existsSync'); + const mockReadFileSync = jest.spyOn(fs, 'readFileSync'); + const mockJoin = jest.spyOn(path, 'join'); + const mockConsoleLog = jest + .spyOn(console, 'log') + .mockImplementation(() => {}); + const mockConsoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}); - beforeEach(() => { - jest.clearAllMocks(); - mockExistsSync.mockReturnValue(true); - }); + beforeEach(() => { + jest.clearAllMocks(); + mockExistsSync.mockReturnValue(true); + }); - afterAll(() => { - jest.restoreAllMocks(); - }); + afterAll(() => { + jest.restoreAllMocks(); + }); - describe('setupCLI function', () => { - test('should return Commander program instance', () => { - const program = setupCLI(); - expect(program).toBeDefined(); - expect(program.name()).toBe('dev'); - }); + describe('setupCLI function', () => { + test('should return Commander program instance', () => { + const program = setupCLI(); + expect(program).toBeDefined(); + expect(program.name()).toBe('dev'); + }); - test('should read version from package.json when available', () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue('{"version": "1.0.0"}'); - mockJoin.mockReturnValue('package.json'); - - const program = setupCLI(); - const version = program._version(); - expect(mockReadFileSync).toHaveBeenCalledWith('package.json', 'utf8'); - expect(version).toBe('1.0.0'); - }); + test('should read version from package.json when available', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('{"version": "1.0.0"}'); + mockJoin.mockReturnValue('package.json'); - test('should use default version when package.json is not available', () => { - mockExistsSync.mockReturnValue(false); - - const program = setupCLI(); - const version = program._version(); - expect(mockReadFileSync).not.toHaveBeenCalled(); - expect(version).toBe('1.5.0'); - }); + const program = setupCLI(); + const version = program._version(); + expect(mockReadFileSync).toHaveBeenCalledWith('package.json', 'utf8'); + expect(version).toBe('1.0.0'); + }); - test('should use default version when package.json reading throws an error', () => { - mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockImplementation(() => { - throw new Error('Invalid JSON'); - }); - - const program = setupCLI(); - const version = program._version(); - expect(mockReadFileSync).toHaveBeenCalled(); - expect(version).toBe('1.5.0'); - }); - }); + test('should use default version when package.json is not available', () => { + mockExistsSync.mockReturnValue(false); - describe('Kebab Case Validation', () => { - test('should detect camelCase flags correctly', () => { - const args = ['node', 'task-master', '--camelCase', '--kebab-case']; - const camelCaseFlags = args.filter(arg => - arg.startsWith('--') && - /[A-Z]/.test(arg) && - !arg.includes('-[A-Z]') - ); - expect(camelCaseFlags).toContain('--camelCase'); - expect(camelCaseFlags).not.toContain('--kebab-case'); - }); + const program = setupCLI(); + const version = program._version(); + expect(mockReadFileSync).not.toHaveBeenCalled(); + expect(version).toBe('1.5.0'); + }); - test('should accept kebab-case flags correctly', () => { - const args = ['node', 'task-master', '--kebab-case']; - const camelCaseFlags = args.filter(arg => - arg.startsWith('--') && - /[A-Z]/.test(arg) && - !arg.includes('-[A-Z]') - ); - expect(camelCaseFlags).toHaveLength(0); - }); - }); + test('should use default version when package.json reading throws an error', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation(() => { + throw new Error('Invalid JSON'); + }); - describe('parse-prd command', () => { - // Since mocking Commander is complex, we'll test the action handler directly - // Recreate the action handler logic based on commands.js - async function parsePrdAction(file, options) { - // Use input option if file argument not provided - const inputFile = file || options.input; - const defaultPrdPath = 'scripts/prd.txt'; - - // 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}`)); - const numTasks = parseInt(options.numTasks, 10); - const outputPath = options.output; - - console.log(chalk.blue(`Generating ${numTasks} tasks...`)); - await mockParsePRD(defaultPrdPath, outputPath, numTasks); - return; - } - - console.log(chalk.yellow('No PRD file specified and default PRD file not found at scripts/prd.txt.')); - return; - } - - const numTasks = parseInt(options.numTasks, 10); - const outputPath = options.output; - - console.log(chalk.blue(`Parsing PRD file: ${inputFile}`)); - console.log(chalk.blue(`Generating ${numTasks} tasks...`)); - - await mockParsePRD(inputFile, outputPath, numTasks); - } + const program = setupCLI(); + const version = program._version(); + expect(mockReadFileSync).toHaveBeenCalled(); + expect(version).toBe('1.5.0'); + }); + }); - beforeEach(() => { - // Reset the parsePRD mock - mockParsePRD.mockClear(); - }); + describe('Kebab Case Validation', () => { + test('should detect camelCase flags correctly', () => { + const args = ['node', 'task-master', '--camelCase', '--kebab-case']; + const camelCaseFlags = args.filter( + (arg) => + arg.startsWith('--') && /[A-Z]/.test(arg) && !arg.includes('-[A-Z]') + ); + expect(camelCaseFlags).toContain('--camelCase'); + expect(camelCaseFlags).not.toContain('--kebab-case'); + }); - test('should use default PRD path when no arguments provided', async () => { - // Arrange - mockExistsSync.mockReturnValue(true); - - // Act - call the handler directly with the right params - await parsePrdAction(undefined, { numTasks: '10', output: 'tasks/tasks.json' }); - - // Assert - expect(mockExistsSync).toHaveBeenCalledWith('scripts/prd.txt'); - expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Using default PRD file')); - expect(mockParsePRD).toHaveBeenCalledWith( - 'scripts/prd.txt', - 'tasks/tasks.json', - 10 // Default value from command definition - ); - }); + test('should accept kebab-case flags correctly', () => { + const args = ['node', 'task-master', '--kebab-case']; + const camelCaseFlags = args.filter( + (arg) => + arg.startsWith('--') && /[A-Z]/.test(arg) && !arg.includes('-[A-Z]') + ); + expect(camelCaseFlags).toHaveLength(0); + }); + }); - test('should display help when no arguments and no default PRD exists', async () => { - // Arrange - mockExistsSync.mockReturnValue(false); - - // Act - call the handler directly with the right params - await parsePrdAction(undefined, { numTasks: '10', output: 'tasks/tasks.json' }); - - // Assert - expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('No PRD file specified')); - expect(mockParsePRD).not.toHaveBeenCalled(); - }); + describe('parse-prd command', () => { + // Since mocking Commander is complex, we'll test the action handler directly + // Recreate the action handler logic based on commands.js + async function parsePrdAction(file, options) { + // Use input option if file argument not provided + const inputFile = file || options.input; + const defaultPrdPath = 'scripts/prd.txt'; - test('should use explicitly provided file path', async () => { - // Arrange - const testFile = 'test/prd.txt'; - - // Act - call the handler directly with the right params - await parsePrdAction(testFile, { numTasks: '10', output: 'tasks/tasks.json' }); - - // Assert - expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining(`Parsing PRD file: ${testFile}`)); - expect(mockParsePRD).toHaveBeenCalledWith(testFile, 'tasks/tasks.json', 10); - expect(mockExistsSync).not.toHaveBeenCalledWith('scripts/prd.txt'); - }); + // 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}`)); + const numTasks = parseInt(options.numTasks, 10); + const outputPath = options.output; - test('should use file path from input option when provided', async () => { - // Arrange - const testFile = 'test/prd.txt'; - - // Act - call the handler directly with the right params - await parsePrdAction(undefined, { input: testFile, numTasks: '10', output: 'tasks/tasks.json' }); - - // Assert - expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining(`Parsing PRD file: ${testFile}`)); - expect(mockParsePRD).toHaveBeenCalledWith(testFile, 'tasks/tasks.json', 10); - expect(mockExistsSync).not.toHaveBeenCalledWith('scripts/prd.txt'); - }); + console.log(chalk.blue(`Generating ${numTasks} tasks...`)); + await mockParsePRD(defaultPrdPath, outputPath, numTasks); + return; + } - test('should respect numTasks and output options', async () => { - // Arrange - const testFile = 'test/prd.txt'; - const outputFile = 'custom/output.json'; - const numTasks = 15; - - // Act - call the handler directly with the right params - await parsePrdAction(testFile, { numTasks: numTasks.toString(), output: outputFile }); - - // Assert - expect(mockParsePRD).toHaveBeenCalledWith(testFile, outputFile, numTasks); - }); - }); + console.log( + chalk.yellow( + 'No PRD file specified and default PRD file not found at scripts/prd.txt.' + ) + ); + return; + } - describe('updateTask command', () => { - // Since mocking Commander is complex, we'll test the action handler directly - // Recreate the action handler logic based on commands.js - async function updateTaskAction(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); - return; // Add early return to prevent calling updateTaskById - } - - // 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); - return; // Add early return to prevent calling updateTaskById - } - - 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); - return; // Add early return to prevent calling updateTaskById - } - - 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); - return; // Add early return to prevent calling updateTaskById - } - - 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 (!process.env.PERPLEXITY_API_KEY) { - 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 mockUpdateTaskById(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.')); - } - - if (true) { // CONFIG.debug - console.error(error); - } - - process.exit(1); - } - } - - beforeEach(() => { - // Reset all mocks - jest.clearAllMocks(); - - // Set up spy for existsSync (already mocked in the outer scope) - mockExistsSync.mockReturnValue(true); - }); - - test('should validate required parameters - missing ID', async () => { - // Set up the command options without ID - const options = { - file: 'test-tasks.json', - prompt: 'Update the task' - }; - - // Call the action directly - await updateTaskAction(options); - - // Verify validation error - expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('--id parameter is required')); - expect(mockExit).toHaveBeenCalledWith(1); - expect(mockUpdateTaskById).not.toHaveBeenCalled(); - }); - - test('should validate required parameters - invalid ID', async () => { - // Set up the command options with invalid ID - const options = { - file: 'test-tasks.json', - id: 'not-a-number', - prompt: 'Update the task' - }; - - // Call the action directly - await updateTaskAction(options); - - // Verify validation error - expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Invalid task ID')); - expect(mockExit).toHaveBeenCalledWith(1); - expect(mockUpdateTaskById).not.toHaveBeenCalled(); - }); - - test('should validate required parameters - missing prompt', async () => { - // Set up the command options without prompt - const options = { - file: 'test-tasks.json', - id: '2' - }; - - // Call the action directly - await updateTaskAction(options); - - // Verify validation error - expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('--prompt parameter is required')); - expect(mockExit).toHaveBeenCalledWith(1); - expect(mockUpdateTaskById).not.toHaveBeenCalled(); - }); - - test('should validate tasks file exists', async () => { - // Mock file not existing - mockExistsSync.mockReturnValue(false); - - // Set up the command options - const options = { - file: 'missing-tasks.json', - id: '2', - prompt: 'Update the task' - }; - - // Call the action directly - await updateTaskAction(options); - - // Verify validation error - expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Tasks file not found')); - expect(mockExit).toHaveBeenCalledWith(1); - expect(mockUpdateTaskById).not.toHaveBeenCalled(); - }); - - test('should call updateTaskById with correct parameters', async () => { - // Set up the command options - const options = { - file: 'test-tasks.json', - id: '2', - prompt: 'Update the task', - research: true - }; - - // Mock perplexity API key - process.env.PERPLEXITY_API_KEY = 'dummy-key'; - - // Call the action directly - await updateTaskAction(options); - - // Verify updateTaskById was called with correct parameters - expect(mockUpdateTaskById).toHaveBeenCalledWith( - 'test-tasks.json', - 2, - 'Update the task', - true - ); - - // Verify console output - expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Updating task 2')); - expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Using Perplexity AI')); - - // Clean up - delete process.env.PERPLEXITY_API_KEY; - }); - - test('should handle null result from updateTaskById', async () => { - // Mock updateTaskById returning null (e.g., task already completed) - mockUpdateTaskById.mockResolvedValueOnce(null); - - // Set up the command options - const options = { - file: 'test-tasks.json', - id: '2', - prompt: 'Update the task' - }; - - // Call the action directly - await updateTaskAction(options); - - // Verify updateTaskById was called - expect(mockUpdateTaskById).toHaveBeenCalled(); - - // Verify console output for null result - expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Task update was not completed')); - }); - - test('should handle errors from updateTaskById', async () => { - // Mock updateTaskById throwing an error - mockUpdateTaskById.mockRejectedValueOnce(new Error('Task update failed')); - - // Set up the command options - const options = { - file: 'test-tasks.json', - id: '2', - prompt: 'Update the task' - }; - - // Call the action directly - await updateTaskAction(options); - - // Verify error handling - expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Error: Task update failed')); - expect(mockExit).toHaveBeenCalledWith(1); - }); - }); + const numTasks = parseInt(options.numTasks, 10); + const outputPath = options.output; + + console.log(chalk.blue(`Parsing PRD file: ${inputFile}`)); + console.log(chalk.blue(`Generating ${numTasks} tasks...`)); + + await mockParsePRD(inputFile, outputPath, numTasks); + } + + beforeEach(() => { + // Reset the parsePRD mock + mockParsePRD.mockClear(); + }); + + test('should use default PRD path when no arguments provided', async () => { + // Arrange + mockExistsSync.mockReturnValue(true); + + // Act - call the handler directly with the right params + await parsePrdAction(undefined, { + numTasks: '10', + output: 'tasks/tasks.json' + }); + + // Assert + expect(mockExistsSync).toHaveBeenCalledWith('scripts/prd.txt'); + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('Using default PRD file') + ); + expect(mockParsePRD).toHaveBeenCalledWith( + 'scripts/prd.txt', + 'tasks/tasks.json', + 10 // Default value from command definition + ); + }); + + test('should display help when no arguments and no default PRD exists', async () => { + // Arrange + mockExistsSync.mockReturnValue(false); + + // Act - call the handler directly with the right params + await parsePrdAction(undefined, { + numTasks: '10', + output: 'tasks/tasks.json' + }); + + // Assert + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('No PRD file specified') + ); + expect(mockParsePRD).not.toHaveBeenCalled(); + }); + + test('should use explicitly provided file path', async () => { + // Arrange + const testFile = 'test/prd.txt'; + + // Act - call the handler directly with the right params + await parsePrdAction(testFile, { + numTasks: '10', + output: 'tasks/tasks.json' + }); + + // Assert + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining(`Parsing PRD file: ${testFile}`) + ); + expect(mockParsePRD).toHaveBeenCalledWith( + testFile, + 'tasks/tasks.json', + 10 + ); + expect(mockExistsSync).not.toHaveBeenCalledWith('scripts/prd.txt'); + }); + + test('should use file path from input option when provided', async () => { + // Arrange + const testFile = 'test/prd.txt'; + + // Act - call the handler directly with the right params + await parsePrdAction(undefined, { + input: testFile, + numTasks: '10', + output: 'tasks/tasks.json' + }); + + // Assert + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining(`Parsing PRD file: ${testFile}`) + ); + expect(mockParsePRD).toHaveBeenCalledWith( + testFile, + 'tasks/tasks.json', + 10 + ); + expect(mockExistsSync).not.toHaveBeenCalledWith('scripts/prd.txt'); + }); + + test('should respect numTasks and output options', async () => { + // Arrange + const testFile = 'test/prd.txt'; + const outputFile = 'custom/output.json'; + const numTasks = 15; + + // Act - call the handler directly with the right params + await parsePrdAction(testFile, { + numTasks: numTasks.toString(), + output: outputFile + }); + + // Assert + expect(mockParsePRD).toHaveBeenCalledWith(testFile, outputFile, numTasks); + }); + }); + + describe('updateTask command', () => { + // Since mocking Commander is complex, we'll test the action handler directly + // Recreate the action handler logic based on commands.js + async function updateTaskAction(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); + return; // Add early return to prevent calling updateTaskById + } + + // 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); + return; // Add early return to prevent calling updateTaskById + } + + 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); + return; // Add early return to prevent calling updateTaskById + } + + 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); + return; // Add early return to prevent calling updateTaskById + } + + 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 (!process.env.PERPLEXITY_API_KEY) { + 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 mockUpdateTaskById( + 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.' + ) + ); + } + + if (true) { + // CONFIG.debug + console.error(error); + } + + process.exit(1); + } + } + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Set up spy for existsSync (already mocked in the outer scope) + mockExistsSync.mockReturnValue(true); + }); + + test('should validate required parameters - missing ID', async () => { + // Set up the command options without ID + const options = { + file: 'test-tasks.json', + prompt: 'Update the task' + }; + + // Call the action directly + await updateTaskAction(options); + + // Verify validation error + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('--id parameter is required') + ); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockUpdateTaskById).not.toHaveBeenCalled(); + }); + + test('should validate required parameters - invalid ID', async () => { + // Set up the command options with invalid ID + const options = { + file: 'test-tasks.json', + id: 'not-a-number', + prompt: 'Update the task' + }; + + // Call the action directly + await updateTaskAction(options); + + // Verify validation error + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Invalid task ID') + ); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockUpdateTaskById).not.toHaveBeenCalled(); + }); + + test('should validate required parameters - missing prompt', async () => { + // Set up the command options without prompt + const options = { + file: 'test-tasks.json', + id: '2' + }; + + // Call the action directly + await updateTaskAction(options); + + // Verify validation error + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('--prompt parameter is required') + ); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockUpdateTaskById).not.toHaveBeenCalled(); + }); + + test('should validate tasks file exists', async () => { + // Mock file not existing + mockExistsSync.mockReturnValue(false); + + // Set up the command options + const options = { + file: 'missing-tasks.json', + id: '2', + prompt: 'Update the task' + }; + + // Call the action directly + await updateTaskAction(options); + + // Verify validation error + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Tasks file not found') + ); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockUpdateTaskById).not.toHaveBeenCalled(); + }); + + test('should call updateTaskById with correct parameters', async () => { + // Set up the command options + const options = { + file: 'test-tasks.json', + id: '2', + prompt: 'Update the task', + research: true + }; + + // Mock perplexity API key + process.env.PERPLEXITY_API_KEY = 'dummy-key'; + + // Call the action directly + await updateTaskAction(options); + + // Verify updateTaskById was called with correct parameters + expect(mockUpdateTaskById).toHaveBeenCalledWith( + 'test-tasks.json', + 2, + 'Update the task', + true + ); + + // Verify console output + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('Updating task 2') + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('Using Perplexity AI') + ); + + // Clean up + delete process.env.PERPLEXITY_API_KEY; + }); + + test('should handle null result from updateTaskById', async () => { + // Mock updateTaskById returning null (e.g., task already completed) + mockUpdateTaskById.mockResolvedValueOnce(null); + + // Set up the command options + const options = { + file: 'test-tasks.json', + id: '2', + prompt: 'Update the task' + }; + + // Call the action directly + await updateTaskAction(options); + + // Verify updateTaskById was called + expect(mockUpdateTaskById).toHaveBeenCalled(); + + // Verify console output for null result + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('Task update was not completed') + ); + }); + + test('should handle errors from updateTaskById', async () => { + // Mock updateTaskById throwing an error + mockUpdateTaskById.mockRejectedValueOnce(new Error('Task update failed')); + + // Set up the command options + const options = { + file: 'test-tasks.json', + id: '2', + prompt: 'Update the task' + }; + + // Call the action directly + await updateTaskAction(options); + + // Verify error handling + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Error: Task update failed') + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + }); }); // Test the version comparison utility describe('Version comparison', () => { - // Use a dynamic import for the commands module - let compareVersions; - - beforeAll(async () => { - // Import the function we want to test dynamically - const commandsModule = await import('../../scripts/modules/commands.js'); - compareVersions = commandsModule.compareVersions; - }); + // Use a dynamic import for the commands module + let compareVersions; - test('compareVersions correctly compares semantic versions', () => { - expect(compareVersions('1.0.0', '1.0.0')).toBe(0); - expect(compareVersions('1.0.0', '1.0.1')).toBe(-1); - expect(compareVersions('1.0.1', '1.0.0')).toBe(1); - expect(compareVersions('1.0.0', '1.1.0')).toBe(-1); - expect(compareVersions('1.1.0', '1.0.0')).toBe(1); - expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); - expect(compareVersions('2.0.0', '1.0.0')).toBe(1); - expect(compareVersions('1.0', '1.0.0')).toBe(0); - expect(compareVersions('1.0.0.0', '1.0.0')).toBe(0); - expect(compareVersions('1.0.0', '1.0.0.1')).toBe(-1); - }); + beforeAll(async () => { + // Import the function we want to test dynamically + const commandsModule = await import('../../scripts/modules/commands.js'); + compareVersions = commandsModule.compareVersions; + }); + + test('compareVersions correctly compares semantic versions', () => { + expect(compareVersions('1.0.0', '1.0.0')).toBe(0); + expect(compareVersions('1.0.0', '1.0.1')).toBe(-1); + expect(compareVersions('1.0.1', '1.0.0')).toBe(1); + expect(compareVersions('1.0.0', '1.1.0')).toBe(-1); + expect(compareVersions('1.1.0', '1.0.0')).toBe(1); + expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); + expect(compareVersions('2.0.0', '1.0.0')).toBe(1); + expect(compareVersions('1.0', '1.0.0')).toBe(0); + expect(compareVersions('1.0.0.0', '1.0.0')).toBe(0); + expect(compareVersions('1.0.0', '1.0.0.1')).toBe(-1); + }); }); // Test the update check functionality describe('Update check', () => { - let displayUpgradeNotification; - let consoleLogSpy; - - beforeAll(async () => { - // Import the function we want to test dynamically - const commandsModule = await import('../../scripts/modules/commands.js'); - displayUpgradeNotification = commandsModule.displayUpgradeNotification; - }); - - beforeEach(() => { - // Spy on console.log - consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); - }); + let displayUpgradeNotification; + let consoleLogSpy; - afterEach(() => { - consoleLogSpy.mockRestore(); - }); + beforeAll(async () => { + // Import the function we want to test dynamically + const commandsModule = await import('../../scripts/modules/commands.js'); + displayUpgradeNotification = commandsModule.displayUpgradeNotification; + }); - test('displays upgrade notification when newer version is available', () => { - // Test displayUpgradeNotification function - displayUpgradeNotification('1.0.0', '1.1.0'); - expect(consoleLogSpy).toHaveBeenCalled(); - expect(consoleLogSpy.mock.calls[0][0]).toContain('Update Available!'); - expect(consoleLogSpy.mock.calls[0][0]).toContain('1.0.0'); - expect(consoleLogSpy.mock.calls[0][0]).toContain('1.1.0'); - }); -}); \ No newline at end of file + beforeEach(() => { + // Spy on console.log + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + }); + + test('displays upgrade notification when newer version is available', () => { + // Test displayUpgradeNotification function + displayUpgradeNotification('1.0.0', '1.1.0'); + expect(consoleLogSpy).toHaveBeenCalled(); + expect(consoleLogSpy.mock.calls[0][0]).toContain('Update Available!'); + expect(consoleLogSpy.mock.calls[0][0]).toContain('1.0.0'); + expect(consoleLogSpy.mock.calls[0][0]).toContain('1.1.0'); + }); +}); diff --git a/tests/unit/dependency-manager.test.js b/tests/unit/dependency-manager.test.js index cb38f592..db6633e4 100644 --- a/tests/unit/dependency-manager.test.js +++ b/tests/unit/dependency-manager.test.js @@ -3,13 +3,13 @@ */ import { jest } from '@jest/globals'; -import { - validateTaskDependencies, - isCircularDependency, - removeDuplicateDependencies, - cleanupSubtaskDependencies, - ensureAtLeastOneIndependentSubtask, - validateAndFixDependencies +import { + validateTaskDependencies, + isCircularDependency, + removeDuplicateDependencies, + cleanupSubtaskDependencies, + ensureAtLeastOneIndependentSubtask, + validateAndFixDependencies } from '../../scripts/modules/dependency-manager.js'; import * as utils from '../../scripts/modules/utils.js'; import { sampleTasks } from '../fixtures/sample-tasks.js'; @@ -17,17 +17,17 @@ import { sampleTasks } from '../fixtures/sample-tasks.js'; // Mock dependencies jest.mock('path'); jest.mock('chalk', () => ({ - green: jest.fn(text => `<green>${text}</green>`), - yellow: jest.fn(text => `<yellow>${text}</yellow>`), - red: jest.fn(text => `<red>${text}</red>`), - cyan: jest.fn(text => `<cyan>${text}</cyan>`), - bold: jest.fn(text => `<bold>${text}</bold>`), + green: jest.fn((text) => `<green>${text}</green>`), + yellow: jest.fn((text) => `<yellow>${text}</yellow>`), + red: jest.fn((text) => `<red>${text}</red>`), + cyan: jest.fn((text) => `<cyan>${text}</cyan>`), + bold: jest.fn((text) => `<bold>${text}</bold>`) })); -jest.mock('boxen', () => jest.fn(text => `[boxed: ${text}]`)); +jest.mock('boxen', () => jest.fn((text) => `[boxed: ${text}]`)); jest.mock('@anthropic-ai/sdk', () => ({ - Anthropic: jest.fn().mockImplementation(() => ({})), + Anthropic: jest.fn().mockImplementation(() => ({})) })); // Mock utils module @@ -39,744 +39,775 @@ const mockReadJSON = jest.fn(); const mockWriteJSON = jest.fn(); jest.mock('../../scripts/modules/utils.js', () => ({ - log: mockLog, - readJSON: mockReadJSON, - writeJSON: mockWriteJSON, - taskExists: mockTaskExists, - formatTaskId: mockFormatTaskId, - findCycles: mockFindCycles + log: mockLog, + readJSON: mockReadJSON, + writeJSON: mockWriteJSON, + taskExists: mockTaskExists, + formatTaskId: mockFormatTaskId, + findCycles: mockFindCycles })); jest.mock('../../scripts/modules/ui.js', () => ({ - displayBanner: jest.fn(), + displayBanner: jest.fn() })); jest.mock('../../scripts/modules/task-manager.js', () => ({ - generateTaskFiles: jest.fn(), + generateTaskFiles: jest.fn() })); // Create a path for test files const TEST_TASKS_PATH = 'tests/fixture/test-tasks.json'; describe('Dependency Manager Module', () => { - beforeEach(() => { - jest.clearAllMocks(); - - // Set default implementations - mockTaskExists.mockImplementation((tasks, id) => { - if (Array.isArray(tasks)) { - if (typeof id === 'string' && id.includes('.')) { - const [taskId, subtaskId] = id.split('.').map(Number); - const task = tasks.find(t => t.id === taskId); - return task && task.subtasks && task.subtasks.some(st => st.id === subtaskId); - } - return tasks.some(task => task.id === (typeof id === 'string' ? parseInt(id, 10) : id)); - } - return false; - }); - - mockFormatTaskId.mockImplementation(id => { - if (typeof id === 'string' && id.includes('.')) { - return id; - } - return parseInt(id, 10); - }); - - mockFindCycles.mockImplementation((tasks) => { - // Simplified cycle detection for testing - const dependencyMap = new Map(); - - // Build dependency map - tasks.forEach(task => { - if (task.dependencies) { - dependencyMap.set(task.id, task.dependencies); - } - }); - - const visited = new Set(); - const recursionStack = new Set(); - - function dfs(taskId) { - visited.add(taskId); - recursionStack.add(taskId); - - const dependencies = dependencyMap.get(taskId) || []; - for (const depId of dependencies) { - if (!visited.has(depId)) { - if (dfs(depId)) return true; - } else if (recursionStack.has(depId)) { - return true; - } - } - - recursionStack.delete(taskId); - return false; - } - - // Check for cycles starting from each unvisited node - for (const taskId of dependencyMap.keys()) { - if (!visited.has(taskId)) { - if (dfs(taskId)) return true; - } - } - - return false; - }); - }); + beforeEach(() => { + jest.clearAllMocks(); - describe('isCircularDependency function', () => { - test('should detect a direct circular dependency', () => { - const tasks = [ - { id: 1, dependencies: [2] }, - { id: 2, dependencies: [1] } - ]; - - const result = isCircularDependency(tasks, 1); - expect(result).toBe(true); - }); + // Set default implementations + mockTaskExists.mockImplementation((tasks, id) => { + if (Array.isArray(tasks)) { + if (typeof id === 'string' && id.includes('.')) { + const [taskId, subtaskId] = id.split('.').map(Number); + const task = tasks.find((t) => t.id === taskId); + return ( + task && + task.subtasks && + task.subtasks.some((st) => st.id === subtaskId) + ); + } + return tasks.some( + (task) => task.id === (typeof id === 'string' ? parseInt(id, 10) : id) + ); + } + return false; + }); - test('should detect an indirect circular dependency', () => { - const tasks = [ - { id: 1, dependencies: [2] }, - { id: 2, dependencies: [3] }, - { id: 3, dependencies: [1] } - ]; - - const result = isCircularDependency(tasks, 1); - expect(result).toBe(true); - }); + mockFormatTaskId.mockImplementation((id) => { + if (typeof id === 'string' && id.includes('.')) { + return id; + } + return parseInt(id, 10); + }); - test('should return false for non-circular dependencies', () => { - const tasks = [ - { id: 1, dependencies: [2] }, - { id: 2, dependencies: [3] }, - { id: 3, dependencies: [] } - ]; - - const result = isCircularDependency(tasks, 1); - expect(result).toBe(false); - }); + mockFindCycles.mockImplementation((tasks) => { + // Simplified cycle detection for testing + const dependencyMap = new Map(); - test('should handle a task with no dependencies', () => { - const tasks = [ - { id: 1, dependencies: [] }, - { id: 2, dependencies: [1] } - ]; - - const result = isCircularDependency(tasks, 1); - expect(result).toBe(false); - }); + // Build dependency map + tasks.forEach((task) => { + if (task.dependencies) { + dependencyMap.set(task.id, task.dependencies); + } + }); - test('should handle a task depending on itself', () => { - const tasks = [ - { id: 1, dependencies: [1] } - ]; - - const result = isCircularDependency(tasks, 1); - expect(result).toBe(true); - }); + const visited = new Set(); + const recursionStack = new Set(); - test('should handle subtask dependencies correctly', () => { - const tasks = [ - { - id: 1, - dependencies: [], - subtasks: [ - { id: 1, dependencies: ["1.2"] }, - { id: 2, dependencies: ["1.3"] }, - { id: 3, dependencies: ["1.1"] } - ] - } - ]; - - // This creates a circular dependency: 1.1 -> 1.2 -> 1.3 -> 1.1 - const result = isCircularDependency(tasks, "1.1", ["1.3", "1.2"]); - expect(result).toBe(true); - }); + function dfs(taskId) { + visited.add(taskId); + recursionStack.add(taskId); - test('should allow non-circular subtask dependencies within same parent', () => { - const tasks = [ - { - id: 1, - dependencies: [], - subtasks: [ - { id: 1, dependencies: [] }, - { id: 2, dependencies: ["1.1"] }, - { id: 3, dependencies: ["1.2"] } - ] - } - ]; - - // This is a valid dependency chain: 1.3 -> 1.2 -> 1.1 - const result = isCircularDependency(tasks, "1.1", []); - expect(result).toBe(false); - }); + const dependencies = dependencyMap.get(taskId) || []; + for (const depId of dependencies) { + if (!visited.has(depId)) { + if (dfs(depId)) return true; + } else if (recursionStack.has(depId)) { + return true; + } + } - test('should properly handle dependencies between subtasks of the same parent', () => { - const tasks = [ - { - id: 1, - dependencies: [], - subtasks: [ - { id: 1, dependencies: [] }, - { id: 2, dependencies: ["1.1"] }, - { id: 3, dependencies: [] } - ] - } - ]; - - // Check if adding a dependency from subtask 1.3 to 1.2 creates a circular dependency - // This should be false as 1.3 -> 1.2 -> 1.1 is a valid chain - mockTaskExists.mockImplementation(() => true); - const result = isCircularDependency(tasks, "1.3", ["1.2"]); - expect(result).toBe(false); - }); + recursionStack.delete(taskId); + return false; + } - test('should correctly detect circular dependencies in subtasks of the same parent', () => { - const tasks = [ - { - id: 1, - dependencies: [], - subtasks: [ - { id: 1, dependencies: ["1.3"] }, - { id: 2, dependencies: ["1.1"] }, - { id: 3, dependencies: ["1.2"] } - ] - } - ]; - - // This creates a circular dependency: 1.1 -> 1.3 -> 1.2 -> 1.1 - mockTaskExists.mockImplementation(() => true); - const result = isCircularDependency(tasks, "1.2", ["1.1"]); - expect(result).toBe(true); - }); - }); + // Check for cycles starting from each unvisited node + for (const taskId of dependencyMap.keys()) { + if (!visited.has(taskId)) { + if (dfs(taskId)) return true; + } + } - describe('validateTaskDependencies function', () => { - test('should detect missing dependencies', () => { - const tasks = [ - { id: 1, dependencies: [99] }, // 99 doesn't exist - { id: 2, dependencies: [1] } - ]; - - const result = validateTaskDependencies(tasks); - - expect(result.valid).toBe(false); - expect(result.issues.length).toBeGreaterThan(0); - expect(result.issues[0].type).toBe('missing'); - expect(result.issues[0].taskId).toBe(1); - expect(result.issues[0].dependencyId).toBe(99); - }); + return false; + }); + }); - test('should detect circular dependencies', () => { - const tasks = [ - { id: 1, dependencies: [2] }, - { id: 2, dependencies: [1] } - ]; - - const result = validateTaskDependencies(tasks); - - expect(result.valid).toBe(false); - expect(result.issues.some(issue => issue.type === 'circular')).toBe(true); - }); + describe('isCircularDependency function', () => { + test('should detect a direct circular dependency', () => { + const tasks = [ + { id: 1, dependencies: [2] }, + { id: 2, dependencies: [1] } + ]; - test('should detect self-dependencies', () => { - const tasks = [ - { id: 1, dependencies: [1] } - ]; - - const result = validateTaskDependencies(tasks); - - expect(result.valid).toBe(false); - expect(result.issues.some(issue => - issue.type === 'self' && issue.taskId === 1 - )).toBe(true); - }); + const result = isCircularDependency(tasks, 1); + expect(result).toBe(true); + }); - test('should return valid for correct dependencies', () => { - const tasks = [ - { id: 1, dependencies: [] }, - { id: 2, dependencies: [1] }, - { id: 3, dependencies: [1, 2] } - ]; - - const result = validateTaskDependencies(tasks); - - expect(result.valid).toBe(true); - expect(result.issues.length).toBe(0); - }); + test('should detect an indirect circular dependency', () => { + const tasks = [ + { id: 1, dependencies: [2] }, + { id: 2, dependencies: [3] }, + { id: 3, dependencies: [1] } + ]; - test('should handle tasks with no dependencies property', () => { - const tasks = [ - { id: 1 }, // Missing dependencies property - { id: 2, dependencies: [1] } - ]; - - const result = validateTaskDependencies(tasks); - - // Should be valid since a missing dependencies property is interpreted as an empty array - expect(result.valid).toBe(true); - }); + const result = isCircularDependency(tasks, 1); + expect(result).toBe(true); + }); - test('should handle subtask dependencies correctly', () => { - const tasks = [ - { - id: 1, - dependencies: [], - subtasks: [ - { id: 1, dependencies: [] }, - { id: 2, dependencies: ["1.1"] }, // Valid - depends on another subtask - { id: 3, dependencies: ["1.2"] } // Valid - depends on another subtask - ] - }, - { - id: 2, - dependencies: ["1.3"], // Valid - depends on a subtask from task 1 - subtasks: [] - } - ]; - - // Set up mock to handle subtask validation - mockTaskExists.mockImplementation((tasks, id) => { - if (typeof id === 'string' && id.includes('.')) { - const [taskId, subtaskId] = id.split('.').map(Number); - const task = tasks.find(t => t.id === taskId); - return task && task.subtasks && task.subtasks.some(st => st.id === subtaskId); - } - return tasks.some(task => task.id === parseInt(id, 10)); - }); + test('should return false for non-circular dependencies', () => { + const tasks = [ + { id: 1, dependencies: [2] }, + { id: 2, dependencies: [3] }, + { id: 3, dependencies: [] } + ]; - const result = validateTaskDependencies(tasks); - - expect(result.valid).toBe(true); - expect(result.issues.length).toBe(0); - }); + const result = isCircularDependency(tasks, 1); + expect(result).toBe(false); + }); - test('should detect missing subtask dependencies', () => { - const tasks = [ - { - id: 1, - dependencies: [], - subtasks: [ - { id: 1, dependencies: ["1.4"] }, // Invalid - subtask 4 doesn't exist - { id: 2, dependencies: ["2.1"] } // Invalid - task 2 has no subtasks - ] - }, - { - id: 2, - dependencies: [], - subtasks: [] - } - ]; + test('should handle a task with no dependencies', () => { + const tasks = [ + { id: 1, dependencies: [] }, + { id: 2, dependencies: [1] } + ]; - // Mock taskExists to correctly identify missing subtasks - mockTaskExists.mockImplementation((taskArray, depId) => { - if (typeof depId === 'string' && depId === "1.4") { - return false; // Subtask 1.4 doesn't exist - } - if (typeof depId === 'string' && depId === "2.1") { - return false; // Subtask 2.1 doesn't exist - } - return true; // All other dependencies exist - }); + const result = isCircularDependency(tasks, 1); + expect(result).toBe(false); + }); - const result = validateTaskDependencies(tasks); - - expect(result.valid).toBe(false); - expect(result.issues.length).toBeGreaterThan(0); - // Should detect missing subtask dependencies - expect(result.issues.some(issue => - issue.type === 'missing' && String(issue.taskId) === "1.1" && String(issue.dependencyId) === "1.4" - )).toBe(true); - }); + test('should handle a task depending on itself', () => { + const tasks = [{ id: 1, dependencies: [1] }]; - test('should detect circular dependencies between subtasks', () => { - const tasks = [ - { - id: 1, - dependencies: [], - subtasks: [ - { id: 1, dependencies: ["1.2"] }, - { id: 2, dependencies: ["1.1"] } // Creates a circular dependency with 1.1 - ] - } - ]; + const result = isCircularDependency(tasks, 1); + expect(result).toBe(true); + }); - // Mock isCircularDependency for subtasks - mockFindCycles.mockReturnValue(true); + test('should handle subtask dependencies correctly', () => { + const tasks = [ + { + id: 1, + dependencies: [], + subtasks: [ + { id: 1, dependencies: ['1.2'] }, + { id: 2, dependencies: ['1.3'] }, + { id: 3, dependencies: ['1.1'] } + ] + } + ]; - const result = validateTaskDependencies(tasks); - - expect(result.valid).toBe(false); - expect(result.issues.some(issue => issue.type === 'circular')).toBe(true); - }); + // This creates a circular dependency: 1.1 -> 1.2 -> 1.3 -> 1.1 + const result = isCircularDependency(tasks, '1.1', ['1.3', '1.2']); + expect(result).toBe(true); + }); - test('should properly validate dependencies between subtasks of the same parent', () => { - const tasks = [ - { - id: 23, - dependencies: [], - subtasks: [ - { id: 8, dependencies: ["23.13"] }, - { id: 10, dependencies: ["23.8"] }, - { id: 13, dependencies: [] } - ] - } - ]; - - // Mock taskExists to validate the subtask dependencies - mockTaskExists.mockImplementation((taskArray, id) => { - if (typeof id === 'string') { - if (id === "23.8" || id === "23.10" || id === "23.13") { - return true; - } - } - return false; - }); - - const result = validateTaskDependencies(tasks); - - expect(result.valid).toBe(true); - expect(result.issues.length).toBe(0); - }); - }); + test('should allow non-circular subtask dependencies within same parent', () => { + const tasks = [ + { + id: 1, + dependencies: [], + subtasks: [ + { id: 1, dependencies: [] }, + { id: 2, dependencies: ['1.1'] }, + { id: 3, dependencies: ['1.2'] } + ] + } + ]; - describe('removeDuplicateDependencies function', () => { - test('should remove duplicate dependencies from tasks', () => { - const tasksData = { - tasks: [ - { id: 1, dependencies: [2, 2, 3, 3, 3] }, - { id: 2, dependencies: [3] }, - { id: 3, dependencies: [] } - ] - }; - - const result = removeDuplicateDependencies(tasksData); - - expect(result.tasks[0].dependencies).toEqual([2, 3]); - expect(result.tasks[1].dependencies).toEqual([3]); - expect(result.tasks[2].dependencies).toEqual([]); - }); + // This is a valid dependency chain: 1.3 -> 1.2 -> 1.1 + const result = isCircularDependency(tasks, '1.1', []); + expect(result).toBe(false); + }); - test('should handle empty dependencies array', () => { - const tasksData = { - tasks: [ - { id: 1, dependencies: [] }, - { id: 2, dependencies: [1] } - ] - }; - - const result = removeDuplicateDependencies(tasksData); - - expect(result.tasks[0].dependencies).toEqual([]); - expect(result.tasks[1].dependencies).toEqual([1]); - }); + test('should properly handle dependencies between subtasks of the same parent', () => { + const tasks = [ + { + id: 1, + dependencies: [], + subtasks: [ + { id: 1, dependencies: [] }, + { id: 2, dependencies: ['1.1'] }, + { id: 3, dependencies: [] } + ] + } + ]; - test('should handle tasks with no dependencies property', () => { - const tasksData = { - tasks: [ - { id: 1 }, // No dependencies property - { id: 2, dependencies: [1] } - ] - }; - - const result = removeDuplicateDependencies(tasksData); - - expect(result.tasks[0]).not.toHaveProperty('dependencies'); - expect(result.tasks[1].dependencies).toEqual([1]); - }); - }); + // Check if adding a dependency from subtask 1.3 to 1.2 creates a circular dependency + // This should be false as 1.3 -> 1.2 -> 1.1 is a valid chain + mockTaskExists.mockImplementation(() => true); + const result = isCircularDependency(tasks, '1.3', ['1.2']); + expect(result).toBe(false); + }); - describe('cleanupSubtaskDependencies function', () => { - test('should remove dependencies to non-existent subtasks', () => { - const tasksData = { - tasks: [ - { - id: 1, - dependencies: [], - subtasks: [ - { id: 1, dependencies: [] }, - { id: 2, dependencies: [3] } // Dependency 3 doesn't exist - ] - }, - { - id: 2, - dependencies: ['1.2'], // Valid subtask dependency - subtasks: [ - { id: 1, dependencies: ['1.1'] } // Valid subtask dependency - ] - } - ] - }; - - const result = cleanupSubtaskDependencies(tasksData); - - // Should remove the invalid dependency to subtask 3 - expect(result.tasks[0].subtasks[1].dependencies).toEqual([]); - // Should keep valid dependencies - expect(result.tasks[1].dependencies).toEqual(['1.2']); - expect(result.tasks[1].subtasks[0].dependencies).toEqual(['1.1']); - }); + test('should correctly detect circular dependencies in subtasks of the same parent', () => { + const tasks = [ + { + id: 1, + dependencies: [], + subtasks: [ + { id: 1, dependencies: ['1.3'] }, + { id: 2, dependencies: ['1.1'] }, + { id: 3, dependencies: ['1.2'] } + ] + } + ]; - test('should handle tasks without subtasks', () => { - const tasksData = { - tasks: [ - { id: 1, dependencies: [] }, - { id: 2, dependencies: [1] } - ] - }; - - const result = cleanupSubtaskDependencies(tasksData); - - // Should return the original data unchanged - expect(result).toEqual(tasksData); - }); - }); + // This creates a circular dependency: 1.1 -> 1.3 -> 1.2 -> 1.1 + mockTaskExists.mockImplementation(() => true); + const result = isCircularDependency(tasks, '1.2', ['1.1']); + expect(result).toBe(true); + }); + }); - describe('ensureAtLeastOneIndependentSubtask function', () => { - test('should clear dependencies of first subtask if none are independent', () => { - const tasksData = { - tasks: [ - { - id: 1, - subtasks: [ - { id: 1, dependencies: [2] }, - { id: 2, dependencies: [1] } - ] - } - ] - }; + describe('validateTaskDependencies function', () => { + test('should detect missing dependencies', () => { + const tasks = [ + { id: 1, dependencies: [99] }, // 99 doesn't exist + { id: 2, dependencies: [1] } + ]; - const result = ensureAtLeastOneIndependentSubtask(tasksData); + const result = validateTaskDependencies(tasks); - expect(result).toBe(true); - expect(tasksData.tasks[0].subtasks[0].dependencies).toEqual([]); - expect(tasksData.tasks[0].subtasks[1].dependencies).toEqual([1]); - }); + expect(result.valid).toBe(false); + expect(result.issues.length).toBeGreaterThan(0); + expect(result.issues[0].type).toBe('missing'); + expect(result.issues[0].taskId).toBe(1); + expect(result.issues[0].dependencyId).toBe(99); + }); - test('should not modify tasks if at least one subtask is independent', () => { - const tasksData = { - tasks: [ - { - id: 1, - subtasks: [ - { id: 1, dependencies: [] }, - { id: 2, dependencies: [1] } - ] - } - ] - }; + test('should detect circular dependencies', () => { + const tasks = [ + { id: 1, dependencies: [2] }, + { id: 2, dependencies: [1] } + ]; - const result = ensureAtLeastOneIndependentSubtask(tasksData); + const result = validateTaskDependencies(tasks); - expect(result).toBe(false); - expect(tasksData.tasks[0].subtasks[0].dependencies).toEqual([]); - expect(tasksData.tasks[0].subtasks[1].dependencies).toEqual([1]); - }); + expect(result.valid).toBe(false); + expect(result.issues.some((issue) => issue.type === 'circular')).toBe( + true + ); + }); - test('should handle tasks without subtasks', () => { - const tasksData = { - tasks: [ - { id: 1 }, - { id: 2, dependencies: [1] } - ] - }; + test('should detect self-dependencies', () => { + const tasks = [{ id: 1, dependencies: [1] }]; - const result = ensureAtLeastOneIndependentSubtask(tasksData); + const result = validateTaskDependencies(tasks); - expect(result).toBe(false); - expect(tasksData).toEqual({ - tasks: [ - { id: 1 }, - { id: 2, dependencies: [1] } - ] - }); - }); + expect(result.valid).toBe(false); + expect( + result.issues.some( + (issue) => issue.type === 'self' && issue.taskId === 1 + ) + ).toBe(true); + }); - test('should handle empty subtasks array', () => { - const tasksData = { - tasks: [ - { id: 1, subtasks: [] } - ] - }; + test('should return valid for correct dependencies', () => { + const tasks = [ + { id: 1, dependencies: [] }, + { id: 2, dependencies: [1] }, + { id: 3, dependencies: [1, 2] } + ]; - const result = ensureAtLeastOneIndependentSubtask(tasksData); + const result = validateTaskDependencies(tasks); - expect(result).toBe(false); - expect(tasksData).toEqual({ - tasks: [ - { id: 1, subtasks: [] } - ] - }); - }); - }); + expect(result.valid).toBe(true); + expect(result.issues.length).toBe(0); + }); - describe('validateAndFixDependencies function', () => { - test('should fix multiple dependency issues and return true if changes made', () => { - const tasksData = { - tasks: [ - { - id: 1, - dependencies: [1, 1, 99], // Self-dependency and duplicate and invalid dependency - subtasks: [ - { id: 1, dependencies: [2, 2] }, // Duplicate dependencies - { id: 2, dependencies: [1] } - ] - }, - { - id: 2, - dependencies: [1], - subtasks: [ - { id: 1, dependencies: [99] } // Invalid dependency - ] - } - ] - }; + test('should handle tasks with no dependencies property', () => { + const tasks = [ + { id: 1 }, // Missing dependencies property + { id: 2, dependencies: [1] } + ]; - // Mock taskExists for validating dependencies - mockTaskExists.mockImplementation((tasks, id) => { - // Convert id to string for comparison - const idStr = String(id); - - // Handle subtask references (e.g., "1.2") - if (idStr.includes('.')) { - const [parentId, subtaskId] = idStr.split('.').map(Number); - const task = tasks.find(t => t.id === parentId); - return task && task.subtasks && task.subtasks.some(st => st.id === subtaskId); - } - - // Handle regular task references - const taskId = parseInt(idStr, 10); - return taskId === 1 || taskId === 2; // Only tasks 1 and 2 exist - }); + const result = validateTaskDependencies(tasks); - // Make a copy for verification that original is modified - const originalData = JSON.parse(JSON.stringify(tasksData)); + // Should be valid since a missing dependencies property is interpreted as an empty array + expect(result.valid).toBe(true); + }); - const result = validateAndFixDependencies(tasksData); + test('should handle subtask dependencies correctly', () => { + const tasks = [ + { + id: 1, + dependencies: [], + subtasks: [ + { id: 1, dependencies: [] }, + { id: 2, dependencies: ['1.1'] }, // Valid - depends on another subtask + { id: 3, dependencies: ['1.2'] } // Valid - depends on another subtask + ] + }, + { + id: 2, + dependencies: ['1.3'], // Valid - depends on a subtask from task 1 + subtasks: [] + } + ]; - expect(result).toBe(true); - // Check that data has been modified - expect(tasksData).not.toEqual(originalData); - - // Check specific changes - // 1. Self-dependency removed - expect(tasksData.tasks[0].dependencies).not.toContain(1); - // 2. Invalid dependency removed - expect(tasksData.tasks[0].dependencies).not.toContain(99); - // 3. Dependencies have been deduplicated - if (tasksData.tasks[0].subtasks[0].dependencies.length > 0) { - expect(tasksData.tasks[0].subtasks[0].dependencies).toEqual( - expect.arrayContaining([]) - ); - } - // 4. Invalid subtask dependency removed - expect(tasksData.tasks[1].subtasks[0].dependencies).toEqual([]); + // Set up mock to handle subtask validation + mockTaskExists.mockImplementation((tasks, id) => { + if (typeof id === 'string' && id.includes('.')) { + const [taskId, subtaskId] = id.split('.').map(Number); + const task = tasks.find((t) => t.id === taskId); + return ( + task && + task.subtasks && + task.subtasks.some((st) => st.id === subtaskId) + ); + } + return tasks.some((task) => task.id === parseInt(id, 10)); + }); - // IMPORTANT: Verify no calls to writeJSON with actual tasks.json - expect(mockWriteJSON).not.toHaveBeenCalledWith('tasks/tasks.json', expect.anything()); - }); + const result = validateTaskDependencies(tasks); - test('should return false if no changes needed', () => { - const tasksData = { - tasks: [ - { - id: 1, - dependencies: [], - subtasks: [ - { id: 1, dependencies: [] }, // Already has an independent subtask - { id: 2, dependencies: ['1.1'] } - ] - }, - { - id: 2, - dependencies: [1] - } - ] - }; + expect(result.valid).toBe(true); + expect(result.issues.length).toBe(0); + }); - // Mock taskExists to validate all dependencies as valid - mockTaskExists.mockImplementation((tasks, id) => { - // Convert id to string for comparison - const idStr = String(id); - - // Handle subtask references - if (idStr.includes('.')) { - const [parentId, subtaskId] = idStr.split('.').map(Number); - const task = tasks.find(t => t.id === parentId); - return task && task.subtasks && task.subtasks.some(st => st.id === subtaskId); - } - - // Handle regular task references - const taskId = parseInt(idStr, 10); - return taskId === 1 || taskId === 2; - }); + test('should detect missing subtask dependencies', () => { + const tasks = [ + { + id: 1, + dependencies: [], + subtasks: [ + { id: 1, dependencies: ['1.4'] }, // Invalid - subtask 4 doesn't exist + { id: 2, dependencies: ['2.1'] } // Invalid - task 2 has no subtasks + ] + }, + { + id: 2, + dependencies: [], + subtasks: [] + } + ]; - const originalData = JSON.parse(JSON.stringify(tasksData)); - const result = validateAndFixDependencies(tasksData); + // Mock taskExists to correctly identify missing subtasks + mockTaskExists.mockImplementation((taskArray, depId) => { + if (typeof depId === 'string' && depId === '1.4') { + return false; // Subtask 1.4 doesn't exist + } + if (typeof depId === 'string' && depId === '2.1') { + return false; // Subtask 2.1 doesn't exist + } + return true; // All other dependencies exist + }); - expect(result).toBe(false); - // Verify data is unchanged - expect(tasksData).toEqual(originalData); - - // IMPORTANT: Verify no calls to writeJSON with actual tasks.json - expect(mockWriteJSON).not.toHaveBeenCalledWith('tasks/tasks.json', expect.anything()); - }); + const result = validateTaskDependencies(tasks); - test('should handle invalid input', () => { - expect(validateAndFixDependencies(null)).toBe(false); - expect(validateAndFixDependencies({})).toBe(false); - expect(validateAndFixDependencies({ tasks: null })).toBe(false); - expect(validateAndFixDependencies({ tasks: 'not an array' })).toBe(false); - - // IMPORTANT: Verify no calls to writeJSON with actual tasks.json - expect(mockWriteJSON).not.toHaveBeenCalledWith('tasks/tasks.json', expect.anything()); - }); + expect(result.valid).toBe(false); + expect(result.issues.length).toBeGreaterThan(0); + // Should detect missing subtask dependencies + expect( + result.issues.some( + (issue) => + issue.type === 'missing' && + String(issue.taskId) === '1.1' && + String(issue.dependencyId) === '1.4' + ) + ).toBe(true); + }); - test('should save changes when tasksPath is provided', () => { - const tasksData = { - tasks: [ - { - id: 1, - dependencies: [1, 1], // Self-dependency and duplicate - subtasks: [ - { id: 1, dependencies: [99] } // Invalid dependency - ] - } - ] - }; + test('should detect circular dependencies between subtasks', () => { + const tasks = [ + { + id: 1, + dependencies: [], + subtasks: [ + { id: 1, dependencies: ['1.2'] }, + { id: 2, dependencies: ['1.1'] } // Creates a circular dependency with 1.1 + ] + } + ]; - // Mock taskExists for this specific test - mockTaskExists.mockImplementation((tasks, id) => { - // Convert id to string for comparison - const idStr = String(id); - - // Handle subtask references - if (idStr.includes('.')) { - const [parentId, subtaskId] = idStr.split('.').map(Number); - const task = tasks.find(t => t.id === parentId); - return task && task.subtasks && task.subtasks.some(st => st.id === subtaskId); - } - - // Handle regular task references - const taskId = parseInt(idStr, 10); - return taskId === 1; // Only task 1 exists - }); + // Mock isCircularDependency for subtasks + mockFindCycles.mockReturnValue(true); - // Copy the original data to verify changes - const originalData = JSON.parse(JSON.stringify(tasksData)); + const result = validateTaskDependencies(tasks); - // Call the function with our test path instead of the actual tasks.json - const result = validateAndFixDependencies(tasksData, TEST_TASKS_PATH); + expect(result.valid).toBe(false); + expect(result.issues.some((issue) => issue.type === 'circular')).toBe( + true + ); + }); - // First verify that the result is true (changes were made) - expect(result).toBe(true); + test('should properly validate dependencies between subtasks of the same parent', () => { + const tasks = [ + { + id: 23, + dependencies: [], + subtasks: [ + { id: 8, dependencies: ['23.13'] }, + { id: 10, dependencies: ['23.8'] }, + { id: 13, dependencies: [] } + ] + } + ]; - // Verify the data was modified - expect(tasksData).not.toEqual(originalData); + // Mock taskExists to validate the subtask dependencies + mockTaskExists.mockImplementation((taskArray, id) => { + if (typeof id === 'string') { + if (id === '23.8' || id === '23.10' || id === '23.13') { + return true; + } + } + return false; + }); - // IMPORTANT: Verify no calls to writeJSON with actual tasks.json - expect(mockWriteJSON).not.toHaveBeenCalledWith('tasks/tasks.json', expect.anything()); - }); - }); -}); \ No newline at end of file + const result = validateTaskDependencies(tasks); + + expect(result.valid).toBe(true); + expect(result.issues.length).toBe(0); + }); + }); + + describe('removeDuplicateDependencies function', () => { + test('should remove duplicate dependencies from tasks', () => { + const tasksData = { + tasks: [ + { id: 1, dependencies: [2, 2, 3, 3, 3] }, + { id: 2, dependencies: [3] }, + { id: 3, dependencies: [] } + ] + }; + + const result = removeDuplicateDependencies(tasksData); + + expect(result.tasks[0].dependencies).toEqual([2, 3]); + expect(result.tasks[1].dependencies).toEqual([3]); + expect(result.tasks[2].dependencies).toEqual([]); + }); + + test('should handle empty dependencies array', () => { + const tasksData = { + tasks: [ + { id: 1, dependencies: [] }, + { id: 2, dependencies: [1] } + ] + }; + + const result = removeDuplicateDependencies(tasksData); + + expect(result.tasks[0].dependencies).toEqual([]); + expect(result.tasks[1].dependencies).toEqual([1]); + }); + + test('should handle tasks with no dependencies property', () => { + const tasksData = { + tasks: [ + { id: 1 }, // No dependencies property + { id: 2, dependencies: [1] } + ] + }; + + const result = removeDuplicateDependencies(tasksData); + + expect(result.tasks[0]).not.toHaveProperty('dependencies'); + expect(result.tasks[1].dependencies).toEqual([1]); + }); + }); + + describe('cleanupSubtaskDependencies function', () => { + test('should remove dependencies to non-existent subtasks', () => { + const tasksData = { + tasks: [ + { + id: 1, + dependencies: [], + subtasks: [ + { id: 1, dependencies: [] }, + { id: 2, dependencies: [3] } // Dependency 3 doesn't exist + ] + }, + { + id: 2, + dependencies: ['1.2'], // Valid subtask dependency + subtasks: [ + { id: 1, dependencies: ['1.1'] } // Valid subtask dependency + ] + } + ] + }; + + const result = cleanupSubtaskDependencies(tasksData); + + // Should remove the invalid dependency to subtask 3 + expect(result.tasks[0].subtasks[1].dependencies).toEqual([]); + // Should keep valid dependencies + expect(result.tasks[1].dependencies).toEqual(['1.2']); + expect(result.tasks[1].subtasks[0].dependencies).toEqual(['1.1']); + }); + + test('should handle tasks without subtasks', () => { + const tasksData = { + tasks: [ + { id: 1, dependencies: [] }, + { id: 2, dependencies: [1] } + ] + }; + + const result = cleanupSubtaskDependencies(tasksData); + + // Should return the original data unchanged + expect(result).toEqual(tasksData); + }); + }); + + describe('ensureAtLeastOneIndependentSubtask function', () => { + test('should clear dependencies of first subtask if none are independent', () => { + const tasksData = { + tasks: [ + { + id: 1, + subtasks: [ + { id: 1, dependencies: [2] }, + { id: 2, dependencies: [1] } + ] + } + ] + }; + + const result = ensureAtLeastOneIndependentSubtask(tasksData); + + expect(result).toBe(true); + expect(tasksData.tasks[0].subtasks[0].dependencies).toEqual([]); + expect(tasksData.tasks[0].subtasks[1].dependencies).toEqual([1]); + }); + + test('should not modify tasks if at least one subtask is independent', () => { + const tasksData = { + tasks: [ + { + id: 1, + subtasks: [ + { id: 1, dependencies: [] }, + { id: 2, dependencies: [1] } + ] + } + ] + }; + + const result = ensureAtLeastOneIndependentSubtask(tasksData); + + expect(result).toBe(false); + expect(tasksData.tasks[0].subtasks[0].dependencies).toEqual([]); + expect(tasksData.tasks[0].subtasks[1].dependencies).toEqual([1]); + }); + + test('should handle tasks without subtasks', () => { + const tasksData = { + tasks: [{ id: 1 }, { id: 2, dependencies: [1] }] + }; + + const result = ensureAtLeastOneIndependentSubtask(tasksData); + + expect(result).toBe(false); + expect(tasksData).toEqual({ + tasks: [{ id: 1 }, { id: 2, dependencies: [1] }] + }); + }); + + test('should handle empty subtasks array', () => { + const tasksData = { + tasks: [{ id: 1, subtasks: [] }] + }; + + const result = ensureAtLeastOneIndependentSubtask(tasksData); + + expect(result).toBe(false); + expect(tasksData).toEqual({ + tasks: [{ id: 1, subtasks: [] }] + }); + }); + }); + + describe('validateAndFixDependencies function', () => { + test('should fix multiple dependency issues and return true if changes made', () => { + const tasksData = { + tasks: [ + { + id: 1, + dependencies: [1, 1, 99], // Self-dependency and duplicate and invalid dependency + subtasks: [ + { id: 1, dependencies: [2, 2] }, // Duplicate dependencies + { id: 2, dependencies: [1] } + ] + }, + { + id: 2, + dependencies: [1], + subtasks: [ + { id: 1, dependencies: [99] } // Invalid dependency + ] + } + ] + }; + + // Mock taskExists for validating dependencies + mockTaskExists.mockImplementation((tasks, id) => { + // Convert id to string for comparison + const idStr = String(id); + + // Handle subtask references (e.g., "1.2") + if (idStr.includes('.')) { + const [parentId, subtaskId] = idStr.split('.').map(Number); + const task = tasks.find((t) => t.id === parentId); + return ( + task && + task.subtasks && + task.subtasks.some((st) => st.id === subtaskId) + ); + } + + // Handle regular task references + const taskId = parseInt(idStr, 10); + return taskId === 1 || taskId === 2; // Only tasks 1 and 2 exist + }); + + // Make a copy for verification that original is modified + const originalData = JSON.parse(JSON.stringify(tasksData)); + + const result = validateAndFixDependencies(tasksData); + + expect(result).toBe(true); + // Check that data has been modified + expect(tasksData).not.toEqual(originalData); + + // Check specific changes + // 1. Self-dependency removed + expect(tasksData.tasks[0].dependencies).not.toContain(1); + // 2. Invalid dependency removed + expect(tasksData.tasks[0].dependencies).not.toContain(99); + // 3. Dependencies have been deduplicated + if (tasksData.tasks[0].subtasks[0].dependencies.length > 0) { + expect(tasksData.tasks[0].subtasks[0].dependencies).toEqual( + expect.arrayContaining([]) + ); + } + // 4. Invalid subtask dependency removed + expect(tasksData.tasks[1].subtasks[0].dependencies).toEqual([]); + + // IMPORTANT: Verify no calls to writeJSON with actual tasks.json + expect(mockWriteJSON).not.toHaveBeenCalledWith( + 'tasks/tasks.json', + expect.anything() + ); + }); + + test('should return false if no changes needed', () => { + const tasksData = { + tasks: [ + { + id: 1, + dependencies: [], + subtasks: [ + { id: 1, dependencies: [] }, // Already has an independent subtask + { id: 2, dependencies: ['1.1'] } + ] + }, + { + id: 2, + dependencies: [1] + } + ] + }; + + // Mock taskExists to validate all dependencies as valid + mockTaskExists.mockImplementation((tasks, id) => { + // Convert id to string for comparison + const idStr = String(id); + + // Handle subtask references + if (idStr.includes('.')) { + const [parentId, subtaskId] = idStr.split('.').map(Number); + const task = tasks.find((t) => t.id === parentId); + return ( + task && + task.subtasks && + task.subtasks.some((st) => st.id === subtaskId) + ); + } + + // Handle regular task references + const taskId = parseInt(idStr, 10); + return taskId === 1 || taskId === 2; + }); + + const originalData = JSON.parse(JSON.stringify(tasksData)); + const result = validateAndFixDependencies(tasksData); + + expect(result).toBe(false); + // Verify data is unchanged + expect(tasksData).toEqual(originalData); + + // IMPORTANT: Verify no calls to writeJSON with actual tasks.json + expect(mockWriteJSON).not.toHaveBeenCalledWith( + 'tasks/tasks.json', + expect.anything() + ); + }); + + test('should handle invalid input', () => { + expect(validateAndFixDependencies(null)).toBe(false); + expect(validateAndFixDependencies({})).toBe(false); + expect(validateAndFixDependencies({ tasks: null })).toBe(false); + expect(validateAndFixDependencies({ tasks: 'not an array' })).toBe(false); + + // IMPORTANT: Verify no calls to writeJSON with actual tasks.json + expect(mockWriteJSON).not.toHaveBeenCalledWith( + 'tasks/tasks.json', + expect.anything() + ); + }); + + test('should save changes when tasksPath is provided', () => { + const tasksData = { + tasks: [ + { + id: 1, + dependencies: [1, 1], // Self-dependency and duplicate + subtasks: [ + { id: 1, dependencies: [99] } // Invalid dependency + ] + } + ] + }; + + // Mock taskExists for this specific test + mockTaskExists.mockImplementation((tasks, id) => { + // Convert id to string for comparison + const idStr = String(id); + + // Handle subtask references + if (idStr.includes('.')) { + const [parentId, subtaskId] = idStr.split('.').map(Number); + const task = tasks.find((t) => t.id === parentId); + return ( + task && + task.subtasks && + task.subtasks.some((st) => st.id === subtaskId) + ); + } + + // Handle regular task references + const taskId = parseInt(idStr, 10); + return taskId === 1; // Only task 1 exists + }); + + // Copy the original data to verify changes + const originalData = JSON.parse(JSON.stringify(tasksData)); + + // Call the function with our test path instead of the actual tasks.json + const result = validateAndFixDependencies(tasksData, TEST_TASKS_PATH); + + // First verify that the result is true (changes were made) + expect(result).toBe(true); + + // Verify the data was modified + expect(tasksData).not.toEqual(originalData); + + // IMPORTANT: Verify no calls to writeJSON with actual tasks.json + expect(mockWriteJSON).not.toHaveBeenCalledWith( + 'tasks/tasks.json', + expect.anything() + ); + }); + }); +}); diff --git a/tests/unit/init.test.js b/tests/unit/init.test.js index 77497932..0705ebd0 100644 --- a/tests/unit/init.test.js +++ b/tests/unit/init.test.js @@ -5,393 +5,396 @@ import os from 'os'; // Mock external modules jest.mock('child_process', () => ({ - execSync: jest.fn() + execSync: jest.fn() })); jest.mock('readline', () => ({ - createInterface: jest.fn(() => ({ - question: jest.fn(), - close: jest.fn() - })) + createInterface: jest.fn(() => ({ + question: jest.fn(), + close: jest.fn() + })) })); // Mock figlet for banner display jest.mock('figlet', () => ({ - default: { - textSync: jest.fn(() => 'Task Master') - } + default: { + textSync: jest.fn(() => 'Task Master') + } })); // Mock console methods jest.mock('console', () => ({ - log: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - clear: jest.fn() + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + clear: jest.fn() })); describe('Windsurf Rules File Handling', () => { - let tempDir; - - beforeEach(() => { - jest.clearAllMocks(); - - // Create a temporary directory for testing - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-master-test-')); - - // Spy on fs methods - jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); - jest.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - if (filePath.toString().includes('.windsurfrules')) { - return 'Existing windsurf rules content'; - } - return '{}'; - }); - jest.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - // Mock specific file existence checks - if (filePath.toString().includes('package.json')) { - return true; - } - return false; - }); - jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {}); - jest.spyOn(fs, 'copyFileSync').mockImplementation(() => {}); - }); + let tempDir; - afterEach(() => { - // Clean up the temporary directory - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - } catch (err) { - console.error(`Error cleaning up: ${err.message}`); - } - }); + beforeEach(() => { + jest.clearAllMocks(); - // Test function that simulates the behavior of .windsurfrules handling - function mockCopyTemplateFile(templateName, targetPath) { - if (templateName === 'windsurfrules') { - const filename = path.basename(targetPath); - - if (filename === '.windsurfrules') { - if (fs.existsSync(targetPath)) { - // Should append content when file exists - const existingContent = fs.readFileSync(targetPath, 'utf8'); - const updatedContent = existingContent.trim() + - '\n\n# Added by Claude Task Master - Development Workflow Rules\n\n' + - 'New content'; - fs.writeFileSync(targetPath, updatedContent); - return; - } - } - - // If file doesn't exist, create it normally - fs.writeFileSync(targetPath, 'New content'); - } - } + // Create a temporary directory for testing + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-master-test-')); - test('creates .windsurfrules when it does not exist', () => { - // Arrange - const targetPath = path.join(tempDir, '.windsurfrules'); - - // Act - mockCopyTemplateFile('windsurfrules', targetPath); - - // Assert - expect(fs.writeFileSync).toHaveBeenCalledWith(targetPath, 'New content'); - }); - - test('appends content to existing .windsurfrules', () => { - // Arrange - const targetPath = path.join(tempDir, '.windsurfrules'); - const existingContent = 'Existing windsurf rules content'; - - // Override the existsSync mock just for this test - fs.existsSync.mockReturnValueOnce(true); // Target file exists - fs.readFileSync.mockReturnValueOnce(existingContent); - - // Act - mockCopyTemplateFile('windsurfrules', targetPath); - - // Assert - expect(fs.writeFileSync).toHaveBeenCalledWith( - targetPath, - expect.stringContaining(existingContent) - ); - expect(fs.writeFileSync).toHaveBeenCalledWith( - targetPath, - expect.stringContaining('Added by Claude Task Master') - ); - }); - - test('includes .windsurfrules in project structure creation', () => { - // This test verifies the expected behavior by using a mock implementation - // that represents how createProjectStructure should work - - // Mock implementation of createProjectStructure - function mockCreateProjectStructure(projectName) { - // Copy template files including .windsurfrules - mockCopyTemplateFile('windsurfrules', path.join(tempDir, '.windsurfrules')); - } - - // Act - call our mock implementation - mockCreateProjectStructure('test-project'); - - // Assert - verify that .windsurfrules was created - expect(fs.writeFileSync).toHaveBeenCalledWith( - path.join(tempDir, '.windsurfrules'), - expect.any(String) - ); - }); + // Spy on fs methods + jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); + jest.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { + if (filePath.toString().includes('.windsurfrules')) { + return 'Existing windsurf rules content'; + } + return '{}'; + }); + jest.spyOn(fs, 'existsSync').mockImplementation((filePath) => { + // Mock specific file existence checks + if (filePath.toString().includes('package.json')) { + return true; + } + return false; + }); + jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {}); + jest.spyOn(fs, 'copyFileSync').mockImplementation(() => {}); + }); + + afterEach(() => { + // Clean up the temporary directory + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch (err) { + console.error(`Error cleaning up: ${err.message}`); + } + }); + + // Test function that simulates the behavior of .windsurfrules handling + function mockCopyTemplateFile(templateName, targetPath) { + if (templateName === 'windsurfrules') { + const filename = path.basename(targetPath); + + if (filename === '.windsurfrules') { + if (fs.existsSync(targetPath)) { + // Should append content when file exists + const existingContent = fs.readFileSync(targetPath, 'utf8'); + const updatedContent = + existingContent.trim() + + '\n\n# Added by Claude Task Master - Development Workflow Rules\n\n' + + 'New content'; + fs.writeFileSync(targetPath, updatedContent); + return; + } + } + + // If file doesn't exist, create it normally + fs.writeFileSync(targetPath, 'New content'); + } + } + + test('creates .windsurfrules when it does not exist', () => { + // Arrange + const targetPath = path.join(tempDir, '.windsurfrules'); + + // Act + mockCopyTemplateFile('windsurfrules', targetPath); + + // Assert + expect(fs.writeFileSync).toHaveBeenCalledWith(targetPath, 'New content'); + }); + + test('appends content to existing .windsurfrules', () => { + // Arrange + const targetPath = path.join(tempDir, '.windsurfrules'); + const existingContent = 'Existing windsurf rules content'; + + // Override the existsSync mock just for this test + fs.existsSync.mockReturnValueOnce(true); // Target file exists + fs.readFileSync.mockReturnValueOnce(existingContent); + + // Act + mockCopyTemplateFile('windsurfrules', targetPath); + + // Assert + expect(fs.writeFileSync).toHaveBeenCalledWith( + targetPath, + expect.stringContaining(existingContent) + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + targetPath, + expect.stringContaining('Added by Claude Task Master') + ); + }); + + test('includes .windsurfrules in project structure creation', () => { + // This test verifies the expected behavior by using a mock implementation + // that represents how createProjectStructure should work + + // Mock implementation of createProjectStructure + function mockCreateProjectStructure(projectName) { + // Copy template files including .windsurfrules + mockCopyTemplateFile( + 'windsurfrules', + path.join(tempDir, '.windsurfrules') + ); + } + + // Act - call our mock implementation + mockCreateProjectStructure('test-project'); + + // Assert - verify that .windsurfrules was created + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tempDir, '.windsurfrules'), + expect.any(String) + ); + }); }); // New test suite for MCP Configuration Handling describe('MCP Configuration Handling', () => { - let tempDir; - - beforeEach(() => { - jest.clearAllMocks(); - - // Create a temporary directory for testing - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-master-test-')); - - // Spy on fs methods - jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); - jest.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { - if (filePath.toString().includes('mcp.json')) { - return JSON.stringify({ - "mcpServers": { - "existing-server": { - "command": "node", - "args": ["server.js"] - } - } - }); - } - return '{}'; - }); - jest.spyOn(fs, 'existsSync').mockImplementation((filePath) => { - // Return true for specific paths to test different scenarios - if (filePath.toString().includes('package.json')) { - return true; - } - // Default to false for other paths - return false; - }); - jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {}); - jest.spyOn(fs, 'copyFileSync').mockImplementation(() => {}); - }); + let tempDir; - afterEach(() => { - // Clean up the temporary directory - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - } catch (err) { - console.error(`Error cleaning up: ${err.message}`); - } - }); + beforeEach(() => { + jest.clearAllMocks(); - // Test function that simulates the behavior of setupMCPConfiguration - function mockSetupMCPConfiguration(targetDir, projectName) { - const mcpDirPath = path.join(targetDir, '.cursor'); - const mcpJsonPath = path.join(mcpDirPath, 'mcp.json'); - - // Create .cursor directory if it doesn't exist - if (!fs.existsSync(mcpDirPath)) { - fs.mkdirSync(mcpDirPath, { recursive: true }); - } - - // New MCP config to be added - references the installed package - const newMCPServer = { - "task-master-ai": { - "command": "npx", - "args": [ - "task-master-ai", - "mcp-server" - ] - } - }; - - // Check if mcp.json already exists - if (fs.existsSync(mcpJsonPath)) { - try { - // Read existing config - const mcpConfig = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf8')); - - // Initialize mcpServers if it doesn't exist - if (!mcpConfig.mcpServers) { - mcpConfig.mcpServers = {}; - } - - // Add the taskmaster-ai server if it doesn't exist - if (!mcpConfig.mcpServers["task-master-ai"]) { - mcpConfig.mcpServers["task-master-ai"] = newMCPServer["task-master-ai"]; - } - - // Write the updated configuration - fs.writeFileSync( - mcpJsonPath, - JSON.stringify(mcpConfig, null, 4) - ); - } catch (error) { - // Create new configuration on error - const newMCPConfig = { - "mcpServers": newMCPServer - }; - - fs.writeFileSync(mcpJsonPath, JSON.stringify(newMCPConfig, null, 4)); - } - } else { - // If mcp.json doesn't exist, create it - const newMCPConfig = { - "mcpServers": newMCPServer - }; - - fs.writeFileSync(mcpJsonPath, JSON.stringify(newMCPConfig, null, 4)); - } - } + // Create a temporary directory for testing + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-master-test-')); - test('creates mcp.json when it does not exist', () => { - // Arrange - const mcpJsonPath = path.join(tempDir, '.cursor', 'mcp.json'); - - // Act - mockSetupMCPConfiguration(tempDir, 'test-project'); - - // Assert - expect(fs.writeFileSync).toHaveBeenCalledWith( - mcpJsonPath, - expect.stringContaining('task-master-ai') - ); - - // Should create a proper structure with mcpServers key - expect(fs.writeFileSync).toHaveBeenCalledWith( - mcpJsonPath, - expect.stringContaining('mcpServers') - ); - - // Should reference npx command - expect(fs.writeFileSync).toHaveBeenCalledWith( - mcpJsonPath, - expect.stringContaining('npx') - ); - }); - - test('updates existing mcp.json by adding new server', () => { - // Arrange - const mcpJsonPath = path.join(tempDir, '.cursor', 'mcp.json'); - - // Override the existsSync mock to simulate mcp.json exists - fs.existsSync.mockImplementation((filePath) => { - if (filePath.toString().includes('mcp.json')) { - return true; - } - return false; - }); - - // Act - mockSetupMCPConfiguration(tempDir, 'test-project'); - - // Assert - // Should preserve existing server - expect(fs.writeFileSync).toHaveBeenCalledWith( - mcpJsonPath, - expect.stringContaining('existing-server') - ); - - // Should add our new server - expect(fs.writeFileSync).toHaveBeenCalledWith( - mcpJsonPath, - expect.stringContaining('task-master-ai') - ); - }); - - test('handles JSON parsing errors by creating new mcp.json', () => { - // Arrange - const mcpJsonPath = path.join(tempDir, '.cursor', 'mcp.json'); - - // Override existsSync to say mcp.json exists - fs.existsSync.mockImplementation((filePath) => { - if (filePath.toString().includes('mcp.json')) { - return true; - } - return false; - }); - - // But make readFileSync return invalid JSON - fs.readFileSync.mockImplementation((filePath) => { - if (filePath.toString().includes('mcp.json')) { - return '{invalid json'; - } - return '{}'; - }); - - // Act - mockSetupMCPConfiguration(tempDir, 'test-project'); - - // Assert - // Should create a new valid JSON file with our server - expect(fs.writeFileSync).toHaveBeenCalledWith( - mcpJsonPath, - expect.stringContaining('task-master-ai') - ); - }); - - test('does not modify existing server configuration if it already exists', () => { - // Arrange - const mcpJsonPath = path.join(tempDir, '.cursor', 'mcp.json'); - - // Override existsSync to say mcp.json exists - fs.existsSync.mockImplementation((filePath) => { - if (filePath.toString().includes('mcp.json')) { - return true; - } - return false; - }); - - // Return JSON that already has task-master-ai - fs.readFileSync.mockImplementation((filePath) => { - if (filePath.toString().includes('mcp.json')) { - return JSON.stringify({ - "mcpServers": { - "existing-server": { - "command": "node", - "args": ["server.js"] - }, - "task-master-ai": { - "command": "custom", - "args": ["custom-args"] - } - } - }); - } - return '{}'; - }); - - // Spy to check what's written - const writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync'); - - // Act - mockSetupMCPConfiguration(tempDir, 'test-project'); - - // Assert - // Verify the written data contains the original taskmaster configuration - const dataWritten = JSON.parse(writeFileSyncSpy.mock.calls[0][1]); - expect(dataWritten.mcpServers["task-master-ai"].command).toBe("custom"); - expect(dataWritten.mcpServers["task-master-ai"].args).toContain("custom-args"); - }); - - test('creates the .cursor directory if it doesnt exist', () => { - // Arrange - const cursorDirPath = path.join(tempDir, '.cursor'); - - // Make sure it looks like the directory doesn't exist - fs.existsSync.mockReturnValue(false); - - // Act - mockSetupMCPConfiguration(tempDir, 'test-project'); - - // Assert - expect(fs.mkdirSync).toHaveBeenCalledWith(cursorDirPath, { recursive: true }); - }); -}); \ No newline at end of file + // Spy on fs methods + jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); + jest.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { + if (filePath.toString().includes('mcp.json')) { + return JSON.stringify({ + mcpServers: { + 'existing-server': { + command: 'node', + args: ['server.js'] + } + } + }); + } + return '{}'; + }); + jest.spyOn(fs, 'existsSync').mockImplementation((filePath) => { + // Return true for specific paths to test different scenarios + if (filePath.toString().includes('package.json')) { + return true; + } + // Default to false for other paths + return false; + }); + jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {}); + jest.spyOn(fs, 'copyFileSync').mockImplementation(() => {}); + }); + + afterEach(() => { + // Clean up the temporary directory + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch (err) { + console.error(`Error cleaning up: ${err.message}`); + } + }); + + // Test function that simulates the behavior of setupMCPConfiguration + function mockSetupMCPConfiguration(targetDir, projectName) { + const mcpDirPath = path.join(targetDir, '.cursor'); + const mcpJsonPath = path.join(mcpDirPath, 'mcp.json'); + + // Create .cursor directory if it doesn't exist + if (!fs.existsSync(mcpDirPath)) { + fs.mkdirSync(mcpDirPath, { recursive: true }); + } + + // New MCP config to be added - references the installed package + const newMCPServer = { + 'task-master-ai': { + command: 'npx', + args: ['task-master-ai', 'mcp-server'] + } + }; + + // Check if mcp.json already exists + if (fs.existsSync(mcpJsonPath)) { + try { + // Read existing config + const mcpConfig = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf8')); + + // Initialize mcpServers if it doesn't exist + if (!mcpConfig.mcpServers) { + mcpConfig.mcpServers = {}; + } + + // Add the taskmaster-ai server if it doesn't exist + if (!mcpConfig.mcpServers['task-master-ai']) { + mcpConfig.mcpServers['task-master-ai'] = + newMCPServer['task-master-ai']; + } + + // Write the updated configuration + fs.writeFileSync(mcpJsonPath, JSON.stringify(mcpConfig, null, 4)); + } catch (error) { + // Create new configuration on error + const newMCPConfig = { + mcpServers: newMCPServer + }; + + fs.writeFileSync(mcpJsonPath, JSON.stringify(newMCPConfig, null, 4)); + } + } else { + // If mcp.json doesn't exist, create it + const newMCPConfig = { + mcpServers: newMCPServer + }; + + fs.writeFileSync(mcpJsonPath, JSON.stringify(newMCPConfig, null, 4)); + } + } + + test('creates mcp.json when it does not exist', () => { + // Arrange + const mcpJsonPath = path.join(tempDir, '.cursor', 'mcp.json'); + + // Act + mockSetupMCPConfiguration(tempDir, 'test-project'); + + // Assert + expect(fs.writeFileSync).toHaveBeenCalledWith( + mcpJsonPath, + expect.stringContaining('task-master-ai') + ); + + // Should create a proper structure with mcpServers key + expect(fs.writeFileSync).toHaveBeenCalledWith( + mcpJsonPath, + expect.stringContaining('mcpServers') + ); + + // Should reference npx command + expect(fs.writeFileSync).toHaveBeenCalledWith( + mcpJsonPath, + expect.stringContaining('npx') + ); + }); + + test('updates existing mcp.json by adding new server', () => { + // Arrange + const mcpJsonPath = path.join(tempDir, '.cursor', 'mcp.json'); + + // Override the existsSync mock to simulate mcp.json exists + fs.existsSync.mockImplementation((filePath) => { + if (filePath.toString().includes('mcp.json')) { + return true; + } + return false; + }); + + // Act + mockSetupMCPConfiguration(tempDir, 'test-project'); + + // Assert + // Should preserve existing server + expect(fs.writeFileSync).toHaveBeenCalledWith( + mcpJsonPath, + expect.stringContaining('existing-server') + ); + + // Should add our new server + expect(fs.writeFileSync).toHaveBeenCalledWith( + mcpJsonPath, + expect.stringContaining('task-master-ai') + ); + }); + + test('handles JSON parsing errors by creating new mcp.json', () => { + // Arrange + const mcpJsonPath = path.join(tempDir, '.cursor', 'mcp.json'); + + // Override existsSync to say mcp.json exists + fs.existsSync.mockImplementation((filePath) => { + if (filePath.toString().includes('mcp.json')) { + return true; + } + return false; + }); + + // But make readFileSync return invalid JSON + fs.readFileSync.mockImplementation((filePath) => { + if (filePath.toString().includes('mcp.json')) { + return '{invalid json'; + } + return '{}'; + }); + + // Act + mockSetupMCPConfiguration(tempDir, 'test-project'); + + // Assert + // Should create a new valid JSON file with our server + expect(fs.writeFileSync).toHaveBeenCalledWith( + mcpJsonPath, + expect.stringContaining('task-master-ai') + ); + }); + + test('does not modify existing server configuration if it already exists', () => { + // Arrange + const mcpJsonPath = path.join(tempDir, '.cursor', 'mcp.json'); + + // Override existsSync to say mcp.json exists + fs.existsSync.mockImplementation((filePath) => { + if (filePath.toString().includes('mcp.json')) { + return true; + } + return false; + }); + + // Return JSON that already has task-master-ai + fs.readFileSync.mockImplementation((filePath) => { + if (filePath.toString().includes('mcp.json')) { + return JSON.stringify({ + mcpServers: { + 'existing-server': { + command: 'node', + args: ['server.js'] + }, + 'task-master-ai': { + command: 'custom', + args: ['custom-args'] + } + } + }); + } + return '{}'; + }); + + // Spy to check what's written + const writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync'); + + // Act + mockSetupMCPConfiguration(tempDir, 'test-project'); + + // Assert + // Verify the written data contains the original taskmaster configuration + const dataWritten = JSON.parse(writeFileSyncSpy.mock.calls[0][1]); + expect(dataWritten.mcpServers['task-master-ai'].command).toBe('custom'); + expect(dataWritten.mcpServers['task-master-ai'].args).toContain( + 'custom-args' + ); + }); + + test('creates the .cursor directory if it doesnt exist', () => { + // Arrange + const cursorDirPath = path.join(tempDir, '.cursor'); + + // Make sure it looks like the directory doesn't exist + fs.existsSync.mockReturnValue(false); + + // Act + mockSetupMCPConfiguration(tempDir, 'test-project'); + + // Assert + expect(fs.mkdirSync).toHaveBeenCalledWith(cursorDirPath, { + recursive: true + }); + }); +}); diff --git a/tests/unit/kebab-case-validation.test.js b/tests/unit/kebab-case-validation.test.js index df1b913e..7899aeba 100644 --- a/tests/unit/kebab-case-validation.test.js +++ b/tests/unit/kebab-case-validation.test.js @@ -7,114 +7,126 @@ import { toKebabCase } from '../../scripts/modules/utils.js'; // Create a test implementation of detectCamelCaseFlags function testDetectCamelCaseFlags(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; + 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; } describe('Kebab Case Validation', () => { - describe('toKebabCase', () => { - test('should convert camelCase to kebab-case', () => { - expect(toKebabCase('promptText')).toBe('prompt-text'); - expect(toKebabCase('userID')).toBe('user-id'); - expect(toKebabCase('numTasks')).toBe('num-tasks'); - }); - - test('should handle already kebab-case strings', () => { - expect(toKebabCase('already-kebab-case')).toBe('already-kebab-case'); - expect(toKebabCase('kebab-case')).toBe('kebab-case'); - }); - - test('should handle single words', () => { - expect(toKebabCase('single')).toBe('single'); - expect(toKebabCase('file')).toBe('file'); - }); - }); + describe('toKebabCase', () => { + test('should convert camelCase to kebab-case', () => { + expect(toKebabCase('promptText')).toBe('prompt-text'); + expect(toKebabCase('userID')).toBe('user-id'); + expect(toKebabCase('numTasks')).toBe('num-tasks'); + }); - describe('detectCamelCaseFlags', () => { - test('should properly detect camelCase flags', () => { - const args = ['node', 'task-master', 'add-task', '--promptText=test', '--userID=123']; - const flags = testDetectCamelCaseFlags(args); - - expect(flags).toHaveLength(2); - expect(flags).toContainEqual({ - original: 'promptText', - kebabCase: 'prompt-text' - }); - expect(flags).toContainEqual({ - original: 'userID', - kebabCase: 'user-id' - }); - }); - - test('should not flag kebab-case or lowercase flags', () => { - const args = ['node', 'task-master', 'add-task', '--prompt=test', '--user-id=123']; - const flags = testDetectCamelCaseFlags(args); - - expect(flags).toHaveLength(0); - }); - - test('should not flag any single-word flags regardless of case', () => { - const args = [ - 'node', - 'task-master', - 'add-task', - '--prompt=test', // lowercase - '--PROMPT=test', // uppercase - '--Prompt=test', // mixed case - '--file=test', // lowercase - '--FILE=test', // uppercase - '--File=test' // mixed case - ]; - const flags = testDetectCamelCaseFlags(args); - - expect(flags).toHaveLength(0); - }); + test('should handle already kebab-case strings', () => { + expect(toKebabCase('already-kebab-case')).toBe('already-kebab-case'); + expect(toKebabCase('kebab-case')).toBe('kebab-case'); + }); - test('should handle mixed case flags correctly', () => { - const args = [ - 'node', - 'task-master', - 'add-task', - '--prompt=test', // single word, should pass - '--promptText=test', // camelCase, should flag - '--prompt-text=test', // kebab-case, should pass - '--ID=123', // single word, should pass - '--userId=123', // camelCase, should flag - '--user-id=123' // kebab-case, should pass - ]; - - const flags = testDetectCamelCaseFlags(args); - - expect(flags).toHaveLength(2); - expect(flags).toContainEqual({ - original: 'promptText', - kebabCase: 'prompt-text' - }); - expect(flags).toContainEqual({ - original: 'userId', - kebabCase: 'user-id' - }); - }); - }); -}); \ No newline at end of file + test('should handle single words', () => { + expect(toKebabCase('single')).toBe('single'); + expect(toKebabCase('file')).toBe('file'); + }); + }); + + describe('detectCamelCaseFlags', () => { + test('should properly detect camelCase flags', () => { + const args = [ + 'node', + 'task-master', + 'add-task', + '--promptText=test', + '--userID=123' + ]; + const flags = testDetectCamelCaseFlags(args); + + expect(flags).toHaveLength(2); + expect(flags).toContainEqual({ + original: 'promptText', + kebabCase: 'prompt-text' + }); + expect(flags).toContainEqual({ + original: 'userID', + kebabCase: 'user-id' + }); + }); + + test('should not flag kebab-case or lowercase flags', () => { + const args = [ + 'node', + 'task-master', + 'add-task', + '--prompt=test', + '--user-id=123' + ]; + const flags = testDetectCamelCaseFlags(args); + + expect(flags).toHaveLength(0); + }); + + test('should not flag any single-word flags regardless of case', () => { + const args = [ + 'node', + 'task-master', + 'add-task', + '--prompt=test', // lowercase + '--PROMPT=test', // uppercase + '--Prompt=test', // mixed case + '--file=test', // lowercase + '--FILE=test', // uppercase + '--File=test' // mixed case + ]; + const flags = testDetectCamelCaseFlags(args); + + expect(flags).toHaveLength(0); + }); + + test('should handle mixed case flags correctly', () => { + const args = [ + 'node', + 'task-master', + 'add-task', + '--prompt=test', // single word, should pass + '--promptText=test', // camelCase, should flag + '--prompt-text=test', // kebab-case, should pass + '--ID=123', // single word, should pass + '--userId=123', // camelCase, should flag + '--user-id=123' // kebab-case, should pass + ]; + + const flags = testDetectCamelCaseFlags(args); + + expect(flags).toHaveLength(2); + expect(flags).toContainEqual({ + original: 'promptText', + kebabCase: 'prompt-text' + }); + expect(flags).toContainEqual({ + original: 'userId', + kebabCase: 'user-id' + }); + }); + }); +}); diff --git a/tests/unit/task-finder.test.js b/tests/unit/task-finder.test.js index 0bc6e74f..8edf9aaf 100644 --- a/tests/unit/task-finder.test.js +++ b/tests/unit/task-finder.test.js @@ -6,45 +6,45 @@ import { findTaskById } from '../../scripts/modules/utils.js'; import { sampleTasks, emptySampleTasks } from '../fixtures/sample-tasks.js'; describe('Task Finder', () => { - describe('findTaskById function', () => { - test('should find a task by numeric ID', () => { - const task = findTaskById(sampleTasks.tasks, 2); - expect(task).toBeDefined(); - expect(task.id).toBe(2); - expect(task.title).toBe('Create Core Functionality'); - }); + describe('findTaskById function', () => { + test('should find a task by numeric ID', () => { + const task = findTaskById(sampleTasks.tasks, 2); + expect(task).toBeDefined(); + expect(task.id).toBe(2); + expect(task.title).toBe('Create Core Functionality'); + }); - test('should find a task by string ID', () => { - const task = findTaskById(sampleTasks.tasks, '2'); - expect(task).toBeDefined(); - expect(task.id).toBe(2); - }); + test('should find a task by string ID', () => { + const task = findTaskById(sampleTasks.tasks, '2'); + expect(task).toBeDefined(); + expect(task.id).toBe(2); + }); - test('should find a subtask using dot notation', () => { - const subtask = findTaskById(sampleTasks.tasks, '3.1'); - expect(subtask).toBeDefined(); - expect(subtask.id).toBe(1); - expect(subtask.title).toBe('Create Header Component'); - }); + test('should find a subtask using dot notation', () => { + const subtask = findTaskById(sampleTasks.tasks, '3.1'); + expect(subtask).toBeDefined(); + expect(subtask.id).toBe(1); + expect(subtask.title).toBe('Create Header Component'); + }); - test('should return null for non-existent task ID', () => { - const task = findTaskById(sampleTasks.tasks, 99); - expect(task).toBeNull(); - }); + test('should return null for non-existent task ID', () => { + const task = findTaskById(sampleTasks.tasks, 99); + expect(task).toBeNull(); + }); - test('should return null for non-existent subtask ID', () => { - const subtask = findTaskById(sampleTasks.tasks, '3.99'); - expect(subtask).toBeNull(); - }); + test('should return null for non-existent subtask ID', () => { + const subtask = findTaskById(sampleTasks.tasks, '3.99'); + expect(subtask).toBeNull(); + }); - test('should return null for non-existent parent task ID in subtask notation', () => { - const subtask = findTaskById(sampleTasks.tasks, '99.1'); - expect(subtask).toBeNull(); - }); + test('should return null for non-existent parent task ID in subtask notation', () => { + const subtask = findTaskById(sampleTasks.tasks, '99.1'); + expect(subtask).toBeNull(); + }); - test('should return null when tasks array is empty', () => { - const task = findTaskById(emptySampleTasks.tasks, 1); - expect(task).toBeNull(); - }); - }); -}); \ No newline at end of file + test('should return null when tasks array is empty', () => { + const task = findTaskById(emptySampleTasks.tasks, 1); + expect(task).toBeNull(); + }); + }); +}); diff --git a/tests/unit/task-manager.test.js b/tests/unit/task-manager.test.js index 13263fb1..f1275e58 100644 --- a/tests/unit/task-manager.test.js +++ b/tests/unit/task-manager.test.js @@ -29,261 +29,285 @@ const mockPromptYesNo = jest.fn(); // Mock for confirmation prompt // Mock fs module jest.mock('fs', () => ({ - readFileSync: mockReadFileSync, - existsSync: mockExistsSync, - mkdirSync: mockMkdirSync, - writeFileSync: mockWriteFileSync + readFileSync: mockReadFileSync, + existsSync: mockExistsSync, + mkdirSync: mockMkdirSync, + writeFileSync: mockWriteFileSync })); // Mock path module jest.mock('path', () => ({ - dirname: mockDirname, - join: jest.fn((dir, file) => `${dir}/${file}`) + dirname: mockDirname, + join: jest.fn((dir, file) => `${dir}/${file}`) })); // Mock ui jest.mock('../../scripts/modules/ui.js', () => ({ - formatDependenciesWithStatus: mockFormatDependenciesWithStatus, - displayBanner: jest.fn(), - displayTaskList: mockDisplayTaskList, - startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })), // <<<<< Added mock - stopLoadingIndicator: jest.fn(), // <<<<< Added mock - createProgressBar: jest.fn(() => ' MOCK_PROGRESS_BAR '), // <<<<< Added mock (used by listTasks) - getStatusWithColor: jest.fn(status => status), // Basic mock for status - getComplexityWithColor: jest.fn(score => `Score: ${score}`), // Basic mock for complexity + formatDependenciesWithStatus: mockFormatDependenciesWithStatus, + displayBanner: jest.fn(), + displayTaskList: mockDisplayTaskList, + startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })), // <<<<< Added mock + stopLoadingIndicator: jest.fn(), // <<<<< Added mock + createProgressBar: jest.fn(() => ' MOCK_PROGRESS_BAR '), // <<<<< Added mock (used by listTasks) + getStatusWithColor: jest.fn((status) => status), // Basic mock for status + getComplexityWithColor: jest.fn((score) => `Score: ${score}`) // Basic mock for complexity })); // Mock dependency-manager jest.mock('../../scripts/modules/dependency-manager.js', () => ({ - validateAndFixDependencies: mockValidateAndFixDependencies, - validateTaskDependencies: jest.fn() + validateAndFixDependencies: mockValidateAndFixDependencies, + validateTaskDependencies: jest.fn() })); // Mock utils jest.mock('../../scripts/modules/utils.js', () => ({ - writeJSON: mockWriteJSON, - readJSON: mockReadJSON, - log: mockLog, - CONFIG: { // <<<<< Added CONFIG mock - model: 'mock-claude-model', - maxTokens: 4000, - temperature: 0.7, - debug: false, - defaultSubtasks: 3, - // Add other necessary CONFIG properties if needed - }, - sanitizePrompt: jest.fn(prompt => prompt), // <<<<< Added mock - findTaskById: jest.fn((tasks, id) => tasks.find(t => t.id === parseInt(id))), // <<<<< Added mock - readComplexityReport: jest.fn(), // <<<<< Added mock - findTaskInComplexityReport: jest.fn(), // <<<<< Added mock - truncate: jest.fn((str, len) => str.slice(0, len)), // <<<<< Added mock - promptYesNo: mockPromptYesNo, // Added mock for confirmation prompt + writeJSON: mockWriteJSON, + readJSON: mockReadJSON, + log: mockLog, + CONFIG: { + // <<<<< Added CONFIG mock + model: 'mock-claude-model', + maxTokens: 4000, + temperature: 0.7, + debug: false, + defaultSubtasks: 3 + // Add other necessary CONFIG properties if needed + }, + sanitizePrompt: jest.fn((prompt) => prompt), // <<<<< Added mock + findTaskById: jest.fn((tasks, id) => + tasks.find((t) => t.id === parseInt(id)) + ), // <<<<< Added mock + readComplexityReport: jest.fn(), // <<<<< Added mock + findTaskInComplexityReport: jest.fn(), // <<<<< Added mock + truncate: jest.fn((str, len) => str.slice(0, len)), // <<<<< Added mock + promptYesNo: mockPromptYesNo // Added mock for confirmation prompt })); // Mock AI services - Update this mock jest.mock('../../scripts/modules/ai-services.js', () => ({ - callClaude: mockCallClaude, - callPerplexity: mockCallPerplexity, - generateSubtasks: jest.fn(), // <<<<< Add other functions as needed - generateSubtasksWithPerplexity: jest.fn(), // <<<<< Add other functions as needed - generateComplexityAnalysisPrompt: jest.fn(), // <<<<< Add other functions as needed - getAvailableAIModel: mockGetAvailableAIModel, // <<<<< Use the new mock function - handleClaudeError: jest.fn(), // <<<<< Add other functions as needed + callClaude: mockCallClaude, + callPerplexity: mockCallPerplexity, + generateSubtasks: jest.fn(), // <<<<< Add other functions as needed + generateSubtasksWithPerplexity: jest.fn(), // <<<<< Add other functions as needed + generateComplexityAnalysisPrompt: jest.fn(), // <<<<< Add other functions as needed + getAvailableAIModel: mockGetAvailableAIModel, // <<<<< Use the new mock function + handleClaudeError: jest.fn() // <<<<< Add other functions as needed })); // Mock Anthropic SDK jest.mock('@anthropic-ai/sdk', () => { - return { - Anthropic: jest.fn().mockImplementation(() => ({ - messages: { - create: mockCreate - } - })) - }; + return { + Anthropic: jest.fn().mockImplementation(() => ({ + messages: { + create: mockCreate + } + })) + }; }); // Mock Perplexity using OpenAI jest.mock('openai', () => { - return { - default: jest.fn().mockImplementation(() => ({ - chat: { - completions: { - create: mockChatCompletionsCreate - } - } - })) - }; + return { + default: jest.fn().mockImplementation(() => ({ + chat: { + completions: { + create: mockChatCompletionsCreate + } + } + })) + }; }); // Mock the task-manager module itself to control what gets imported jest.mock('../../scripts/modules/task-manager.js', () => { - // Get the original module to preserve function implementations - const originalModule = jest.requireActual('../../scripts/modules/task-manager.js'); - - // Return a modified module with our custom implementation of generateTaskFiles - return { - ...originalModule, - generateTaskFiles: mockGenerateTaskFiles, - isTaskDependentOn: mockIsTaskDependentOn - }; + // Get the original module to preserve function implementations + const originalModule = jest.requireActual( + '../../scripts/modules/task-manager.js' + ); + + // Return a modified module with our custom implementation of generateTaskFiles + return { + ...originalModule, + generateTaskFiles: mockGenerateTaskFiles, + isTaskDependentOn: mockIsTaskDependentOn + }; }); // Create a simplified version of parsePRD for testing const testParsePRD = async (prdPath, outputPath, numTasks) => { - try { - // Check if the output file already exists - if (mockExistsSync(outputPath)) { - const confirmOverwrite = await mockPromptYesNo( - `Warning: ${outputPath} already exists. Overwrite?`, - false - ); - - if (!confirmOverwrite) { - console.log(`Operation cancelled. ${outputPath} was not modified.`); - return null; - } - } - - const prdContent = mockReadFileSync(prdPath, 'utf8'); - const tasks = await mockCallClaude(prdContent, prdPath, numTasks); - const dir = mockDirname(outputPath); - - if (!mockExistsSync(dir)) { - mockMkdirSync(dir, { recursive: true }); - } - - mockWriteJSON(outputPath, tasks); - await mockGenerateTaskFiles(outputPath, dir); - - return tasks; - } catch (error) { - console.error(`Error parsing PRD: ${error.message}`); - process.exit(1); - } + try { + // Check if the output file already exists + if (mockExistsSync(outputPath)) { + const confirmOverwrite = await mockPromptYesNo( + `Warning: ${outputPath} already exists. Overwrite?`, + false + ); + + if (!confirmOverwrite) { + console.log(`Operation cancelled. ${outputPath} was not modified.`); + return null; + } + } + + const prdContent = mockReadFileSync(prdPath, 'utf8'); + const tasks = await mockCallClaude(prdContent, prdPath, numTasks); + const dir = mockDirname(outputPath); + + if (!mockExistsSync(dir)) { + mockMkdirSync(dir, { recursive: true }); + } + + mockWriteJSON(outputPath, tasks); + await mockGenerateTaskFiles(outputPath, dir); + + return tasks; + } catch (error) { + console.error(`Error parsing PRD: ${error.message}`); + process.exit(1); + } }; // Create a simplified version of setTaskStatus for testing const testSetTaskStatus = (tasksData, taskIdInput, newStatus) => { - // Handle multiple task IDs (comma-separated) - const taskIds = taskIdInput.split(',').map(id => id.trim()); - const updatedTasks = []; - - // Update each task - for (const id of taskIds) { - testUpdateSingleTaskStatus(tasksData, id, newStatus); - updatedTasks.push(id); - } - - return tasksData; + // Handle multiple task IDs (comma-separated) + const taskIds = taskIdInput.split(',').map((id) => id.trim()); + const updatedTasks = []; + + // Update each task + for (const id of taskIds) { + testUpdateSingleTaskStatus(tasksData, id, newStatus); + updatedTasks.push(id); + } + + return tasksData; }; // Simplified version of updateSingleTaskStatus for testing const testUpdateSingleTaskStatus = (tasksData, taskIdInput, newStatus) => { - // Check if it's a subtask (e.g., "1.2") - if (taskIdInput.includes('.')) { - const [parentId, subtaskId] = taskIdInput.split('.').map(id => parseInt(id, 10)); - - // Find the parent task - const parentTask = tasksData.tasks.find(t => t.id === parentId); - if (!parentTask) { - throw new Error(`Parent task ${parentId} not found`); - } - - // Find the subtask - if (!parentTask.subtasks) { - throw new Error(`Parent task ${parentId} has no subtasks`); - } - - const subtask = parentTask.subtasks.find(st => st.id === subtaskId); - if (!subtask) { - throw new Error(`Subtask ${subtaskId} not found in parent task ${parentId}`); - } - - // Update the subtask status - subtask.status = newStatus; - - // Check if all subtasks are done (if setting to 'done') - if (newStatus.toLowerCase() === 'done' || newStatus.toLowerCase() === 'completed') { - const allSubtasksDone = parentTask.subtasks.every(st => - st.status === 'done' || st.status === 'completed'); - - // For testing, we don't need to output suggestions - } - } else { - // Handle regular task - const taskId = parseInt(taskIdInput, 10); - const task = tasksData.tasks.find(t => t.id === taskId); - - if (!task) { - throw new Error(`Task ${taskId} not found`); - } - - // Update the task status - task.status = newStatus; - - // If marking as done, also mark all subtasks as done - if ((newStatus.toLowerCase() === 'done' || newStatus.toLowerCase() === 'completed') && - task.subtasks && task.subtasks.length > 0) { - - task.subtasks.forEach(subtask => { - subtask.status = newStatus; - }); - } - } - - return true; + // Check if it's a subtask (e.g., "1.2") + if (taskIdInput.includes('.')) { + const [parentId, subtaskId] = taskIdInput + .split('.') + .map((id) => parseInt(id, 10)); + + // Find the parent task + const parentTask = tasksData.tasks.find((t) => t.id === parentId); + if (!parentTask) { + throw new Error(`Parent task ${parentId} not found`); + } + + // Find the subtask + if (!parentTask.subtasks) { + throw new Error(`Parent task ${parentId} has no subtasks`); + } + + const subtask = parentTask.subtasks.find((st) => st.id === subtaskId); + if (!subtask) { + throw new Error( + `Subtask ${subtaskId} not found in parent task ${parentId}` + ); + } + + // Update the subtask status + subtask.status = newStatus; + + // Check if all subtasks are done (if setting to 'done') + if ( + newStatus.toLowerCase() === 'done' || + newStatus.toLowerCase() === 'completed' + ) { + const allSubtasksDone = parentTask.subtasks.every( + (st) => st.status === 'done' || st.status === 'completed' + ); + + // For testing, we don't need to output suggestions + } + } else { + // Handle regular task + const taskId = parseInt(taskIdInput, 10); + const task = tasksData.tasks.find((t) => t.id === taskId); + + if (!task) { + throw new Error(`Task ${taskId} not found`); + } + + // Update the task status + task.status = newStatus; + + // If marking as done, also mark all subtasks as done + if ( + (newStatus.toLowerCase() === 'done' || + newStatus.toLowerCase() === 'completed') && + task.subtasks && + task.subtasks.length > 0 + ) { + task.subtasks.forEach((subtask) => { + subtask.status = newStatus; + }); + } + } + + return true; }; // Create a simplified version of listTasks for testing const testListTasks = (tasksData, statusFilter, withSubtasks = false) => { - // Filter tasks by status if specified - const filteredTasks = statusFilter - ? tasksData.tasks.filter(task => - task.status && task.status.toLowerCase() === statusFilter.toLowerCase()) - : tasksData.tasks; - - // Call the displayTaskList mock for testing - mockDisplayTaskList(tasksData, statusFilter, withSubtasks); - - return { - filteredTasks, - tasksData - }; + // Filter tasks by status if specified + const filteredTasks = statusFilter + ? tasksData.tasks.filter( + (task) => + task.status && + task.status.toLowerCase() === statusFilter.toLowerCase() + ) + : tasksData.tasks; + + // Call the displayTaskList mock for testing + mockDisplayTaskList(tasksData, statusFilter, withSubtasks); + + return { + filteredTasks, + tasksData + }; }; // Create a simplified version of addTask for testing -const testAddTask = (tasksData, taskPrompt, dependencies = [], priority = 'medium') => { - // Create a new task with a higher ID - const highestId = Math.max(...tasksData.tasks.map(t => t.id)); - const newId = highestId + 1; - - // Create mock task based on what would be generated by AI - const newTask = { - id: newId, - title: `Task from prompt: ${taskPrompt.substring(0, 20)}...`, - description: `Task generated from: ${taskPrompt}`, - status: 'pending', - dependencies: dependencies, - priority: priority, - details: `Implementation details for task generated from prompt: ${taskPrompt}`, - testStrategy: 'Write unit tests to verify functionality' - }; - - // Check dependencies - for (const depId of dependencies) { - const dependency = tasksData.tasks.find(t => t.id === depId); - if (!dependency) { - throw new Error(`Dependency task ${depId} not found`); - } - } - - // Add task to tasks array - tasksData.tasks.push(newTask); - - return { - updatedData: tasksData, - newTask - }; +const testAddTask = ( + tasksData, + taskPrompt, + dependencies = [], + priority = 'medium' +) => { + // Create a new task with a higher ID + const highestId = Math.max(...tasksData.tasks.map((t) => t.id)); + const newId = highestId + 1; + + // Create mock task based on what would be generated by AI + const newTask = { + id: newId, + title: `Task from prompt: ${taskPrompt.substring(0, 20)}...`, + description: `Task generated from: ${taskPrompt}`, + status: 'pending', + dependencies: dependencies, + priority: priority, + details: `Implementation details for task generated from prompt: ${taskPrompt}`, + testStrategy: 'Write unit tests to verify functionality' + }; + + // Check dependencies + for (const depId of dependencies) { + const dependency = tasksData.tasks.find((t) => t.id === depId); + if (!dependency) { + throw new Error(`Dependency task ${depId} not found`); + } + } + + // Add task to tasks array + tasksData.tasks.push(newTask); + + return { + updatedData: tasksData, + newTask + }; }; // Import after mocks @@ -292,2468 +316,2765 @@ import { sampleClaudeResponse } from '../fixtures/sample-claude-response.js'; import { sampleTasks, emptySampleTasks } from '../fixtures/sample-tasks.js'; // Destructure the required functions for convenience -const { findNextTask, generateTaskFiles, clearSubtasks, updateTaskById } = taskManager; +const { findNextTask, generateTaskFiles, clearSubtasks, updateTaskById } = + taskManager; describe('Task Manager Module', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); + beforeEach(() => { + jest.clearAllMocks(); + }); - describe('findNextTask function', () => { - test('should return the highest priority task with all dependencies satisfied', () => { - const tasks = [ - { - id: 1, - title: 'Setup Project', - status: 'done', - dependencies: [], - priority: 'high' - }, - { - id: 2, - title: 'Implement Core Features', - status: 'pending', - dependencies: [1], - priority: 'high' - }, - { - id: 3, - title: 'Create Documentation', - status: 'pending', - dependencies: [1], - priority: 'medium' - }, - { - id: 4, - title: 'Deploy Application', - status: 'pending', - dependencies: [2, 3], - priority: 'high' - } - ]; + describe('findNextTask function', () => { + test('should return the highest priority task with all dependencies satisfied', () => { + const tasks = [ + { + id: 1, + title: 'Setup Project', + status: 'done', + dependencies: [], + priority: 'high' + }, + { + id: 2, + title: 'Implement Core Features', + status: 'pending', + dependencies: [1], + priority: 'high' + }, + { + id: 3, + title: 'Create Documentation', + status: 'pending', + dependencies: [1], + priority: 'medium' + }, + { + id: 4, + title: 'Deploy Application', + status: 'pending', + dependencies: [2, 3], + priority: 'high' + } + ]; - const nextTask = findNextTask(tasks); - - expect(nextTask).toBeDefined(); - expect(nextTask.id).toBe(2); - expect(nextTask.title).toBe('Implement Core Features'); - }); + const nextTask = findNextTask(tasks); - test('should prioritize by priority level when dependencies are equal', () => { - const tasks = [ - { - id: 1, - title: 'Setup Project', - status: 'done', - dependencies: [], - priority: 'high' - }, - { - id: 2, - title: 'Low Priority Task', - status: 'pending', - dependencies: [1], - priority: 'low' - }, - { - id: 3, - title: 'Medium Priority Task', - status: 'pending', - dependencies: [1], - priority: 'medium' - }, - { - id: 4, - title: 'High Priority Task', - status: 'pending', - dependencies: [1], - priority: 'high' - } - ]; + expect(nextTask).toBeDefined(); + expect(nextTask.id).toBe(2); + expect(nextTask.title).toBe('Implement Core Features'); + }); - const nextTask = findNextTask(tasks); - - expect(nextTask.id).toBe(4); - expect(nextTask.priority).toBe('high'); - }); + test('should prioritize by priority level when dependencies are equal', () => { + const tasks = [ + { + id: 1, + title: 'Setup Project', + status: 'done', + dependencies: [], + priority: 'high' + }, + { + id: 2, + title: 'Low Priority Task', + status: 'pending', + dependencies: [1], + priority: 'low' + }, + { + id: 3, + title: 'Medium Priority Task', + status: 'pending', + dependencies: [1], + priority: 'medium' + }, + { + id: 4, + title: 'High Priority Task', + status: 'pending', + dependencies: [1], + priority: 'high' + } + ]; - test('should return null when all tasks are completed', () => { - const tasks = [ - { - id: 1, - title: 'Setup Project', - status: 'done', - dependencies: [], - priority: 'high' - }, - { - id: 2, - title: 'Implement Features', - status: 'done', - dependencies: [1], - priority: 'high' - } - ]; + const nextTask = findNextTask(tasks); - const nextTask = findNextTask(tasks); - - expect(nextTask).toBeNull(); - }); + expect(nextTask.id).toBe(4); + expect(nextTask.priority).toBe('high'); + }); - test('should return null when all pending tasks have unsatisfied dependencies', () => { - const tasks = [ - { - id: 1, - title: 'Setup Project', - status: 'pending', - dependencies: [2], - priority: 'high' - }, - { - id: 2, - title: 'Implement Features', - status: 'pending', - dependencies: [1], - priority: 'high' - } - ]; + test('should return null when all tasks are completed', () => { + const tasks = [ + { + id: 1, + title: 'Setup Project', + status: 'done', + dependencies: [], + priority: 'high' + }, + { + id: 2, + title: 'Implement Features', + status: 'done', + dependencies: [1], + priority: 'high' + } + ]; - const nextTask = findNextTask(tasks); - - expect(nextTask).toBeNull(); - }); + const nextTask = findNextTask(tasks); - test('should handle empty tasks array', () => { - const nextTask = findNextTask([]); - - expect(nextTask).toBeNull(); - }); - }); + expect(nextTask).toBeNull(); + }); - describe.skip('analyzeTaskComplexity function', () => { - // Setup common test variables - const tasksPath = 'tasks/tasks.json'; - const reportPath = 'scripts/task-complexity-report.json'; - const thresholdScore = 5; - const baseOptions = { - file: tasksPath, - output: reportPath, - threshold: thresholdScore.toString(), - research: false // Default to false - }; + test('should return null when all pending tasks have unsatisfied dependencies', () => { + const tasks = [ + { + id: 1, + title: 'Setup Project', + status: 'pending', + dependencies: [2], + priority: 'high' + }, + { + id: 2, + title: 'Implement Features', + status: 'pending', + dependencies: [1], + priority: 'high' + } + ]; - // Sample response structure (simplified for these tests) - const sampleApiResponse = { - tasks: [ - { id: 1, complexity: 3, subtaskCount: 2 }, - { id: 2, complexity: 7, subtaskCount: 5 }, - { id: 3, complexity: 9, subtaskCount: 8 } - ] - }; - - beforeEach(() => { - jest.clearAllMocks(); - - // Setup default mock implementations - mockReadJSON.mockReturnValue(JSON.parse(JSON.stringify(sampleTasks))); - mockWriteJSON.mockImplementation((path, data) => data); // Return data for chaining/assertions - // Just set the mock resolved values directly - no spies needed - mockCallClaude.mockResolvedValue(sampleApiResponse); - mockCallPerplexity.mockResolvedValue(sampleApiResponse); - - // Mock console methods to prevent test output clutter - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); - }); + const nextTask = findNextTask(tasks); - afterEach(() => { - // Restore console methods - console.log.mockRestore(); - console.error.mockRestore(); - }); + expect(nextTask).toBeNull(); + }); - test('should call Claude when research flag is false', async () => { - // Arrange - const options = { ...baseOptions, research: false }; + test('should handle empty tasks array', () => { + const nextTask = findNextTask([]); - // Act - await taskManager.analyzeTaskComplexity(options); + expect(nextTask).toBeNull(); + }); + }); - // Assert - expect(mockCallClaude).toHaveBeenCalled(); - expect(mockCallPerplexity).not.toHaveBeenCalled(); - expect(mockWriteJSON).toHaveBeenCalledWith(reportPath, expect.any(Object)); - }); + describe.skip('analyzeTaskComplexity function', () => { + // Setup common test variables + const tasksPath = 'tasks/tasks.json'; + const reportPath = 'scripts/task-complexity-report.json'; + const thresholdScore = 5; + const baseOptions = { + file: tasksPath, + output: reportPath, + threshold: thresholdScore.toString(), + research: false // Default to false + }; - test('should call Perplexity when research flag is true', async () => { - // Arrange - const options = { ...baseOptions, research: true }; + // Sample response structure (simplified for these tests) + const sampleApiResponse = { + tasks: [ + { id: 1, complexity: 3, subtaskCount: 2 }, + { id: 2, complexity: 7, subtaskCount: 5 }, + { id: 3, complexity: 9, subtaskCount: 8 } + ] + }; - // Act - await taskManager.analyzeTaskComplexity(options); + beforeEach(() => { + jest.clearAllMocks(); - // Assert - expect(mockCallPerplexity).toHaveBeenCalled(); - expect(mockCallClaude).not.toHaveBeenCalled(); - expect(mockWriteJSON).toHaveBeenCalledWith(reportPath, expect.any(Object)); - }); + // Setup default mock implementations + mockReadJSON.mockReturnValue(JSON.parse(JSON.stringify(sampleTasks))); + mockWriteJSON.mockImplementation((path, data) => data); // Return data for chaining/assertions + // Just set the mock resolved values directly - no spies needed + mockCallClaude.mockResolvedValue(sampleApiResponse); + mockCallPerplexity.mockResolvedValue(sampleApiResponse); - test('should handle valid JSON response from LLM (Claude)', async () => { - // Arrange - const options = { ...baseOptions, research: false }; + // Mock console methods to prevent test output clutter + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); - // Act - await taskManager.analyzeTaskComplexity(options); + afterEach(() => { + // Restore console methods + console.log.mockRestore(); + console.error.mockRestore(); + }); - // Assert - expect(mockReadJSON).toHaveBeenCalledWith(tasksPath); - expect(mockCallClaude).toHaveBeenCalled(); - expect(mockCallPerplexity).not.toHaveBeenCalled(); - expect(mockWriteJSON).toHaveBeenCalledWith( - reportPath, - expect.objectContaining({ - tasks: expect.arrayContaining([ - expect.objectContaining({ id: 1 }) - ]) - }) - ); - expect(mockLog).toHaveBeenCalledWith('info', expect.stringContaining('Successfully analyzed')); - }); + test('should call Claude when research flag is false', async () => { + // Arrange + const options = { ...baseOptions, research: false }; - test('should handle and fix malformed JSON string response (Claude)', async () => { - // Arrange - const malformedJsonResponse = `{"tasks": [{"id": 1, "complexity": 3, "subtaskCount: 2}]}`; - mockCallClaude.mockResolvedValueOnce(malformedJsonResponse); - const options = { ...baseOptions, research: false }; + // Act + await taskManager.analyzeTaskComplexity(options); - // Act - await taskManager.analyzeTaskComplexity(options); + // Assert + expect(mockCallClaude).toHaveBeenCalled(); + expect(mockCallPerplexity).not.toHaveBeenCalled(); + expect(mockWriteJSON).toHaveBeenCalledWith( + reportPath, + expect.any(Object) + ); + }); - // Assert - expect(mockCallClaude).toHaveBeenCalled(); - expect(mockCallPerplexity).not.toHaveBeenCalled(); - expect(mockWriteJSON).toHaveBeenCalled(); - expect(mockLog).toHaveBeenCalledWith('warn', expect.stringContaining('Malformed JSON')); - }); + test('should call Perplexity when research flag is true', async () => { + // Arrange + const options = { ...baseOptions, research: true }; - test('should handle missing tasks in the response (Claude)', async () => { - // Arrange - const incompleteResponse = { tasks: [sampleApiResponse.tasks[0]] }; - mockCallClaude.mockResolvedValueOnce(incompleteResponse); - const missingTaskResponse = { tasks: [sampleApiResponse.tasks[1], sampleApiResponse.tasks[2]] }; - mockCallClaude.mockResolvedValueOnce(missingTaskResponse); + // Act + await taskManager.analyzeTaskComplexity(options); - const options = { ...baseOptions, research: false }; + // Assert + expect(mockCallPerplexity).toHaveBeenCalled(); + expect(mockCallClaude).not.toHaveBeenCalled(); + expect(mockWriteJSON).toHaveBeenCalledWith( + reportPath, + expect.any(Object) + ); + }); - // Act - await taskManager.analyzeTaskComplexity(options); + test('should handle valid JSON response from LLM (Claude)', async () => { + // Arrange + const options = { ...baseOptions, research: false }; - // Assert - expect(mockCallClaude).toHaveBeenCalledTimes(2); - expect(mockCallPerplexity).not.toHaveBeenCalled(); - expect(mockWriteJSON).toHaveBeenCalledWith( - reportPath, - expect.objectContaining({ - tasks: expect.arrayContaining([ - expect.objectContaining({ id: 1 }), - expect.objectContaining({ id: 2 }), - expect.objectContaining({ id: 3 }) - ]) - }) - ); - }); - }); + // Act + await taskManager.analyzeTaskComplexity(options); - describe('parsePRD function', () => { - // Mock the sample PRD content - const samplePRDContent = '# Sample PRD for Testing'; - - beforeEach(() => { - // Reset all mocks - jest.clearAllMocks(); - - // Set up mocks for fs, path and other modules - mockReadFileSync.mockReturnValue(samplePRDContent); - mockExistsSync.mockReturnValue(true); - mockDirname.mockReturnValue('tasks'); - mockCallClaude.mockResolvedValue(sampleClaudeResponse); - mockGenerateTaskFiles.mockResolvedValue(undefined); - mockPromptYesNo.mockResolvedValue(true); // Default to "yes" for confirmation - }); - - test('should parse a PRD file and generate tasks', async () => { - // Call the test version of parsePRD - await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); - - // Verify fs.readFileSync was called with the correct arguments - expect(mockReadFileSync).toHaveBeenCalledWith('path/to/prd.txt', 'utf8'); - - // Verify callClaude was called with the correct arguments - expect(mockCallClaude).toHaveBeenCalledWith(samplePRDContent, 'path/to/prd.txt', 3); - - // Verify directory check - expect(mockExistsSync).toHaveBeenCalledWith('tasks'); - - // Verify writeJSON was called with the correct arguments - expect(mockWriteJSON).toHaveBeenCalledWith('tasks/tasks.json', sampleClaudeResponse); - - // Verify generateTaskFiles was called - expect(mockGenerateTaskFiles).toHaveBeenCalledWith('tasks/tasks.json', 'tasks'); - }); - - test('should create the tasks directory if it does not exist', async () => { - // Mock existsSync to return false specifically for the directory check - // but true for the output file check (so we don't trigger confirmation path) - mockExistsSync.mockImplementation((path) => { - if (path === 'tasks/tasks.json') return false; // Output file doesn't exist - if (path === 'tasks') return false; // Directory doesn't exist - return true; // Default for other paths - }); - - // Call the function - await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); - - // Verify mkdir was called - expect(mockMkdirSync).toHaveBeenCalledWith('tasks', { recursive: true }); - }); - - test('should handle errors in the PRD parsing process', async () => { - // Mock an error in callClaude - const testError = new Error('Test error in Claude API call'); - mockCallClaude.mockRejectedValueOnce(testError); - - // Mock console.error and process.exit - const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); - const mockProcessExit = jest.spyOn(process, 'exit').mockImplementation(() => {}); - - // Call the function - await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); - - // Verify error handling - expect(mockConsoleError).toHaveBeenCalled(); - expect(mockProcessExit).toHaveBeenCalledWith(1); - - // Restore mocks - mockConsoleError.mockRestore(); - mockProcessExit.mockRestore(); - }); - - test('should generate individual task files after creating tasks.json', async () => { - // Call the function - await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); - - // Verify generateTaskFiles was called - expect(mockGenerateTaskFiles).toHaveBeenCalledWith('tasks/tasks.json', 'tasks'); - }); - - test('should prompt for confirmation when tasks.json already exists', async () => { - // Setup mocks to simulate tasks.json already exists - mockExistsSync.mockImplementation((path) => { - if (path === 'tasks/tasks.json') return true; // Output file exists - if (path === 'tasks') return true; // Directory exists - return false; - }); - - // Call the function - await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); - - // Verify prompt was called with expected message - expect(mockPromptYesNo).toHaveBeenCalledWith( - 'Warning: tasks/tasks.json already exists. Overwrite?', - false - ); - - // Verify the file was written after confirmation - expect(mockWriteJSON).toHaveBeenCalledWith('tasks/tasks.json', sampleClaudeResponse); - }); - - test('should not overwrite tasks.json when user declines confirmation', async () => { - // Setup mocks to simulate tasks.json already exists - mockExistsSync.mockImplementation((path) => { - if (path === 'tasks/tasks.json') return true; // Output file exists - if (path === 'tasks') return true; // Directory exists - return false; - }); - - // Mock user declining the confirmation - mockPromptYesNo.mockResolvedValueOnce(false); - - // Mock console.log to capture output - const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); - - // Call the function - const result = await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); - - // Verify prompt was called - expect(mockPromptYesNo).toHaveBeenCalledWith( - 'Warning: tasks/tasks.json already exists. Overwrite?', - false - ); - - // Verify the file was NOT written - expect(mockWriteJSON).not.toHaveBeenCalled(); - - // Verify appropriate message was logged - expect(mockConsoleLog).toHaveBeenCalledWith( - 'Operation cancelled. tasks/tasks.json was not modified.' - ); - - // Verify result is null when operation is cancelled - expect(result).toBeNull(); - - // Restore console.log - mockConsoleLog.mockRestore(); - }); - - test('should not prompt for confirmation when tasks.json does not exist', async () => { - // Setup mocks to simulate tasks.json does not exist - mockExistsSync.mockImplementation((path) => { - if (path === 'tasks/tasks.json') return false; // Output file doesn't exist - if (path === 'tasks') return true; // Directory exists - return false; - }); - - // Call the function - await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); - - // Verify prompt was NOT called - expect(mockPromptYesNo).not.toHaveBeenCalled(); - - // Verify the file was written without confirmation - expect(mockWriteJSON).toHaveBeenCalledWith('tasks/tasks.json', sampleClaudeResponse); - }); - }); - - describe.skip('updateTasks function', () => { - test('should update tasks based on new context', async () => { - // This test would verify that: - // 1. The function reads the tasks file correctly - // 2. It filters tasks with ID >= fromId and not 'done' - // 3. It properly calls the AI model with the correct prompt - // 4. It updates the tasks with the AI response - // 5. It writes the updated tasks back to the file - expect(true).toBe(true); - }); - - test('should handle streaming responses from Claude API', async () => { - // This test would verify that: - // 1. The function correctly handles streaming API calls - // 2. It processes the stream data properly - // 3. It combines the chunks into a complete response - expect(true).toBe(true); - }); - - test('should use Perplexity AI when research flag is set', async () => { - // This test would verify that: - // 1. The function uses Perplexity when the research flag is set - // 2. It formats the prompt correctly for Perplexity - // 3. It properly processes the Perplexity response - expect(true).toBe(true); - }); - - test('should handle no tasks to update', async () => { - // This test would verify that: - // 1. The function handles the case when no tasks need updating - // 2. It provides appropriate feedback to the user - expect(true).toBe(true); - }); - - test('should handle errors during the update process', async () => { - // This test would verify that: - // 1. The function handles errors in the AI API calls - // 2. It provides appropriate error messages - // 3. It exits gracefully - expect(true).toBe(true); - }); - }); - - describe('generateTaskFiles function', () => { - // Sample task data for testing - const sampleTasks = { - meta: { projectName: 'Test Project' }, - tasks: [ - { - id: 1, - title: 'Task 1', - description: 'First task description', - status: 'pending', - dependencies: [], - priority: 'high', - details: 'Detailed information for task 1', - testStrategy: 'Test strategy for task 1' - }, - { - id: 2, - title: 'Task 2', - description: 'Second task description', - status: 'pending', - dependencies: [1], - priority: 'medium', - details: 'Detailed information for task 2', - testStrategy: 'Test strategy for task 2' - }, - { - id: 3, - title: 'Task with Subtasks', - description: 'Task with subtasks description', - status: 'pending', - dependencies: [1, 2], - priority: 'high', - details: 'Detailed information for task 3', - testStrategy: 'Test strategy for task 3', - subtasks: [ - { - id: 1, - title: 'Subtask 1', - description: 'First subtask', - status: 'pending', - dependencies: [], - details: 'Details for subtask 1' - }, - { - id: 2, - title: 'Subtask 2', - description: 'Second subtask', - status: 'pending', - dependencies: [1], - details: 'Details for subtask 2' - } - ] - } - ] - }; + // Assert + expect(mockReadJSON).toHaveBeenCalledWith(tasksPath); + expect(mockCallClaude).toHaveBeenCalled(); + expect(mockCallPerplexity).not.toHaveBeenCalled(); + expect(mockWriteJSON).toHaveBeenCalledWith( + reportPath, + expect.objectContaining({ + tasks: expect.arrayContaining([expect.objectContaining({ id: 1 })]) + }) + ); + expect(mockLog).toHaveBeenCalledWith( + 'info', + expect.stringContaining('Successfully analyzed') + ); + }); - test('should generate task files from tasks.json - working test', () => { - // Set up mocks for this specific test - mockReadJSON.mockImplementationOnce(() => sampleTasks); - mockExistsSync.mockImplementationOnce(() => true); - - // Implement a simplified version of generateTaskFiles - const tasksPath = 'tasks/tasks.json'; - const outputDir = 'tasks'; - - // Manual implementation instead of calling the function - // 1. Read the data - const data = mockReadJSON(tasksPath); - expect(mockReadJSON).toHaveBeenCalledWith(tasksPath); - - // 2. Validate and fix dependencies - mockValidateAndFixDependencies(data, tasksPath); - expect(mockValidateAndFixDependencies).toHaveBeenCalledWith(data, tasksPath); - - // 3. Generate files - data.tasks.forEach(task => { - const taskPath = `${outputDir}/task_${task.id.toString().padStart(3, '0')}.txt`; - let content = `# Task ID: ${task.id}\n`; - content += `# Title: ${task.title}\n`; - - mockWriteFileSync(taskPath, content); - }); - - // Verify the files were written - expect(mockWriteFileSync).toHaveBeenCalledTimes(3); - - // Verify specific file paths - expect(mockWriteFileSync).toHaveBeenCalledWith( - 'tasks/task_001.txt', - expect.any(String) - ); - expect(mockWriteFileSync).toHaveBeenCalledWith( - 'tasks/task_002.txt', - expect.any(String) - ); - expect(mockWriteFileSync).toHaveBeenCalledWith( - 'tasks/task_003.txt', - expect.any(String) - ); - }); + test('should handle and fix malformed JSON string response (Claude)', async () => { + // Arrange + const malformedJsonResponse = `{"tasks": [{"id": 1, "complexity": 3, "subtaskCount: 2}]}`; + mockCallClaude.mockResolvedValueOnce(malformedJsonResponse); + const options = { ...baseOptions, research: false }; - // Skip the remaining tests for now until we get the basic test working - test.skip('should format dependencies with status indicators', () => { - // Test implementation - }); - - test.skip('should handle tasks with no subtasks', () => { - // Test implementation - }); - - test.skip('should create the output directory if it doesn\'t exist', () => { - // This test skipped until we find a better way to mock the modules - // The key functionality is: - // 1. When outputDir doesn't exist (fs.existsSync returns false) - // 2. The function should call fs.mkdirSync to create it - }); - - test.skip('should format task files with proper sections', () => { - // Test implementation - }); - - test.skip('should include subtasks in task files when present', () => { - // Test implementation - }); - - test.skip('should handle errors during file generation', () => { - // Test implementation - }); - - test.skip('should validate dependencies before generating files', () => { - // Test implementation - }); - }); - - describe('setTaskStatus function', () => { - test('should update task status in tasks.json', async () => { - // Arrange - const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); - - // Act - const updatedData = testSetTaskStatus(testTasksData, '2', 'done'); - - // Assert - expect(updatedData.tasks[1].id).toBe(2); - expect(updatedData.tasks[1].status).toBe('done'); - }); + // Act + await taskManager.analyzeTaskComplexity(options); - test('should update subtask status when using dot notation', async () => { - // Arrange - const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); - - // Act - const updatedData = testSetTaskStatus(testTasksData, '3.1', 'done'); - - // Assert - const subtaskParent = updatedData.tasks.find(t => t.id === 3); - expect(subtaskParent).toBeDefined(); - expect(subtaskParent.subtasks[0].status).toBe('done'); - }); - - test('should update multiple tasks when given comma-separated IDs', async () => { - // Arrange - const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); - - // Act - const updatedData = testSetTaskStatus(testTasksData, '1,2', 'pending'); - - // Assert - expect(updatedData.tasks[0].status).toBe('pending'); - expect(updatedData.tasks[1].status).toBe('pending'); - }); - - test('should automatically mark subtasks as done when parent is marked done', async () => { - // Arrange - const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); - - // Act - const updatedData = testSetTaskStatus(testTasksData, '3', 'done'); - - // Assert - const parentTask = updatedData.tasks.find(t => t.id === 3); - expect(parentTask.status).toBe('done'); - expect(parentTask.subtasks[0].status).toBe('done'); - expect(parentTask.subtasks[1].status).toBe('done'); - }); - - test('should throw error for non-existent task ID', async () => { - // Arrange - const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); - - // Assert - expect(() => testSetTaskStatus(testTasksData, '99', 'done')).toThrow('Task 99 not found'); - }); - }); - - describe('updateSingleTaskStatus function', () => { - test('should update regular task status', async () => { - // Arrange - const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); - - // Act - const result = testUpdateSingleTaskStatus(testTasksData, '2', 'done'); - - // Assert - expect(result).toBe(true); - expect(testTasksData.tasks[1].status).toBe('done'); - }); - - test('should update subtask status', async () => { - // Arrange - const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); - - // Act - const result = testUpdateSingleTaskStatus(testTasksData, '3.1', 'done'); - - // Assert - expect(result).toBe(true); - expect(testTasksData.tasks[2].subtasks[0].status).toBe('done'); - }); - - test('should handle parent tasks without subtasks', async () => { - // Arrange - const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); - - // Remove subtasks from task 3 - const taskWithoutSubtasks = { ...testTasksData.tasks[2] }; - delete taskWithoutSubtasks.subtasks; - testTasksData.tasks[2] = taskWithoutSubtasks; - - // Assert - expect(() => testUpdateSingleTaskStatus(testTasksData, '3.1', 'done')).toThrow('has no subtasks'); - }); - - test('should handle non-existent subtask ID', async () => { - // Arrange - const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); - - // Assert - expect(() => testUpdateSingleTaskStatus(testTasksData, '3.99', 'done')).toThrow('Subtask 99 not found'); - }); - }); - - describe('listTasks function', () => { - test('should display all tasks when no filter is provided', async () => { - // Arrange - const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); - - // Act - const result = testListTasks(testTasksData); - - // Assert - expect(result.filteredTasks.length).toBe(testTasksData.tasks.length); - expect(mockDisplayTaskList).toHaveBeenCalledWith(testTasksData, undefined, false); - }); - - test('should filter tasks by status when filter is provided', async () => { - // Arrange - const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); - const statusFilter = 'done'; - - // Act - const result = testListTasks(testTasksData, statusFilter); - - // Assert - expect(result.filteredTasks.length).toBe( - testTasksData.tasks.filter(t => t.status === statusFilter).length - ); - expect(mockDisplayTaskList).toHaveBeenCalledWith(testTasksData, statusFilter, false); - }); - - test('should display subtasks when withSubtasks flag is true', async () => { - // Arrange - const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); - - // Act - testListTasks(testTasksData, undefined, true); - - // Assert - expect(mockDisplayTaskList).toHaveBeenCalledWith(testTasksData, undefined, true); - }); - - test('should handle empty tasks array', async () => { - // Arrange - const testTasksData = JSON.parse(JSON.stringify(emptySampleTasks)); - - // Act - const result = testListTasks(testTasksData); - - // Assert - expect(result.filteredTasks.length).toBe(0); - expect(mockDisplayTaskList).toHaveBeenCalledWith(testTasksData, undefined, false); - }); - }); - - describe.skip('expandTask function', () => { - test('should generate subtasks for a task', async () => { - // This test would verify that: - // 1. The function reads the tasks file correctly - // 2. It finds the target task by ID - // 3. It generates subtasks with unique IDs - // 4. It adds the subtasks to the task - // 5. It writes the updated tasks back to the file - expect(true).toBe(true); - }); - - test('should use complexity report for subtask count', async () => { - // This test would verify that: - // 1. The function checks for a complexity report - // 2. It uses the recommended subtask count from the report - // 3. It uses the expansion prompt from the report - expect(true).toBe(true); - }); - - test('should use Perplexity AI when research flag is set', async () => { - // This test would verify that: - // 1. The function uses Perplexity for research-backed generation - // 2. It handles the Perplexity response correctly - expect(true).toBe(true); - }); - - test('should append subtasks to existing ones', async () => { - // This test would verify that: - // 1. The function appends new subtasks to existing ones - // 2. It generates unique subtask IDs - expect(true).toBe(true); - }); - - test('should skip completed tasks', async () => { - // This test would verify that: - // 1. The function skips tasks marked as done or completed - // 2. It provides appropriate feedback - expect(true).toBe(true); - }); - - test('should handle errors during subtask generation', async () => { - // This test would verify that: - // 1. The function handles errors in the AI API calls - // 2. It provides appropriate error messages - // 3. It exits gracefully - expect(true).toBe(true); - }); - }); - - describe.skip('expandAllTasks function', () => { - test('should expand all pending tasks', async () => { - // This test would verify that: - // 1. The function identifies all pending tasks - // 2. It expands each task with appropriate subtasks - // 3. It writes the updated tasks back to the file - expect(true).toBe(true); - }); - - test('should sort tasks by complexity when report is available', async () => { - // This test would verify that: - // 1. The function reads the complexity report - // 2. It sorts tasks by complexity score - // 3. It prioritizes high-complexity tasks - expect(true).toBe(true); - }); - - test('should skip tasks with existing subtasks unless force flag is set', async () => { - // This test would verify that: - // 1. The function skips tasks with existing subtasks - // 2. It processes them when force flag is set - expect(true).toBe(true); - }); - - test('should use task-specific parameters from complexity report', async () => { - // This test would verify that: - // 1. The function uses task-specific subtask counts - // 2. It uses task-specific expansion prompts - expect(true).toBe(true); - }); - - test('should handle empty tasks array', async () => { - // This test would verify that: - // 1. The function handles an empty tasks array gracefully - // 2. It displays an appropriate message - expect(true).toBe(true); - }); - - test('should handle errors for individual tasks without failing the entire operation', async () => { - // This test would verify that: - // 1. The function continues processing tasks even if some fail - // 2. It reports errors for individual tasks - // 3. It completes the operation for successful tasks - expect(true).toBe(true); - }); - }); - - describe('clearSubtasks function', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); + // Assert + expect(mockCallClaude).toHaveBeenCalled(); + expect(mockCallPerplexity).not.toHaveBeenCalled(); + expect(mockWriteJSON).toHaveBeenCalled(); + expect(mockLog).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('Malformed JSON') + ); + }); - // Test implementation of clearSubtasks that just returns the updated data - const testClearSubtasks = (tasksData, taskIds) => { - // Create a deep copy of the data to avoid modifying the original - const data = JSON.parse(JSON.stringify(tasksData)); - let clearedCount = 0; - - // Handle multiple task IDs (comma-separated) - const taskIdArray = taskIds.split(',').map(id => id.trim()); - - taskIdArray.forEach(taskId => { - const id = parseInt(taskId, 10); - if (isNaN(id)) { - return; - } + test('should handle missing tasks in the response (Claude)', async () => { + // Arrange + const incompleteResponse = { tasks: [sampleApiResponse.tasks[0]] }; + mockCallClaude.mockResolvedValueOnce(incompleteResponse); + const missingTaskResponse = { + tasks: [sampleApiResponse.tasks[1], sampleApiResponse.tasks[2]] + }; + mockCallClaude.mockResolvedValueOnce(missingTaskResponse); - const task = data.tasks.find(t => t.id === id); - if (!task) { - // Log error for non-existent task - mockLog('error', `Task ${id} not found`); - return; - } + const options = { ...baseOptions, research: false }; - if (!task.subtasks || task.subtasks.length === 0) { - // No subtasks to clear - return; - } + // Act + await taskManager.analyzeTaskComplexity(options); - const subtaskCount = task.subtasks.length; - delete task.subtasks; - clearedCount++; - }); - - return { data, clearedCount }; - }; + // Assert + expect(mockCallClaude).toHaveBeenCalledTimes(2); + expect(mockCallPerplexity).not.toHaveBeenCalled(); + expect(mockWriteJSON).toHaveBeenCalledWith( + reportPath, + expect.objectContaining({ + tasks: expect.arrayContaining([ + expect.objectContaining({ id: 1 }), + expect.objectContaining({ id: 2 }), + expect.objectContaining({ id: 3 }) + ]) + }) + ); + }); + }); - test('should clear subtasks from a specific task', () => { - // Create a deep copy of the sample data - const testData = JSON.parse(JSON.stringify(sampleTasks)); - - // Execute the test function - const { data, clearedCount } = testClearSubtasks(testData, '3'); - - // Verify results - expect(clearedCount).toBe(1); - - // Verify the task's subtasks were removed - const task = data.tasks.find(t => t.id === 3); - expect(task).toBeDefined(); - expect(task.subtasks).toBeUndefined(); - }); + describe('parsePRD function', () => { + // Mock the sample PRD content + const samplePRDContent = '# Sample PRD for Testing'; - test('should clear subtasks from multiple tasks when given comma-separated IDs', () => { - // Setup data with subtasks on multiple tasks - const testData = JSON.parse(JSON.stringify(sampleTasks)); - // Add subtasks to task 2 - testData.tasks[1].subtasks = [ - { - id: 1, - title: "Test Subtask", - description: "A test subtask", - status: "pending", - dependencies: [] - } - ]; - - // Execute the test function - const { data, clearedCount } = testClearSubtasks(testData, '2,3'); - - // Verify results - expect(clearedCount).toBe(2); - - // Verify both tasks had their subtasks cleared - const task2 = data.tasks.find(t => t.id === 2); - const task3 = data.tasks.find(t => t.id === 3); - expect(task2.subtasks).toBeUndefined(); - expect(task3.subtasks).toBeUndefined(); - }); + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); - test('should handle tasks with no subtasks', () => { - // Task 1 has no subtasks in the sample data - const testData = JSON.parse(JSON.stringify(sampleTasks)); - - // Execute the test function - const { clearedCount } = testClearSubtasks(testData, '1'); - - // Verify no tasks were cleared - expect(clearedCount).toBe(0); - }); + // Set up mocks for fs, path and other modules + mockReadFileSync.mockReturnValue(samplePRDContent); + mockExistsSync.mockReturnValue(true); + mockDirname.mockReturnValue('tasks'); + mockCallClaude.mockResolvedValue(sampleClaudeResponse); + mockGenerateTaskFiles.mockResolvedValue(undefined); + mockPromptYesNo.mockResolvedValue(true); // Default to "yes" for confirmation + }); - test('should handle non-existent task IDs', () => { - const testData = JSON.parse(JSON.stringify(sampleTasks)); - - // Execute the test function - testClearSubtasks(testData, '99'); - - // Verify an error was logged - expect(mockLog).toHaveBeenCalledWith('error', expect.stringContaining('Task 99 not found')); - }); + test('should parse a PRD file and generate tasks', async () => { + // Call the test version of parsePRD + await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); - test('should handle multiple task IDs including both valid and non-existent IDs', () => { - const testData = JSON.parse(JSON.stringify(sampleTasks)); - - // Execute the test function - const { data, clearedCount } = testClearSubtasks(testData, '3,99'); - - // Verify results - expect(clearedCount).toBe(1); - expect(mockLog).toHaveBeenCalledWith('error', expect.stringContaining('Task 99 not found')); - - // Verify the valid task's subtasks were removed - const task3 = data.tasks.find(t => t.id === 3); - expect(task3.subtasks).toBeUndefined(); - }); - }); - - describe('addTask function', () => { - test('should add a new task using AI', async () => { - // Arrange - const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); - const prompt = "Create a new authentication system"; - - // Act - const result = testAddTask(testTasksData, prompt); - - // Assert - expect(result.newTask.id).toBe(Math.max(...sampleTasks.tasks.map(t => t.id)) + 1); - expect(result.newTask.status).toBe('pending'); - expect(result.newTask.title).toContain(prompt.substring(0, 20)); - expect(testTasksData.tasks.length).toBe(sampleTasks.tasks.length + 1); - }); - - test('should validate dependencies when adding a task', async () => { - // Arrange - const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); - const prompt = "Create a new authentication system"; - const validDependencies = [1, 2]; // These exist in sampleTasks - - // Act - const result = testAddTask(testTasksData, prompt, validDependencies); - - // Assert - expect(result.newTask.dependencies).toEqual(validDependencies); - - // Test invalid dependency - expect(() => { - testAddTask(testTasksData, prompt, [999]); // Non-existent task ID - }).toThrow('Dependency task 999 not found'); - }); - - test('should use specified priority', async () => { - // Arrange - const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); - const prompt = "Create a new authentication system"; - const priority = "high"; - - // Act - const result = testAddTask(testTasksData, prompt, [], priority); - - // Assert - expect(result.newTask.priority).toBe(priority); - }); - }); + // Verify fs.readFileSync was called with the correct arguments + expect(mockReadFileSync).toHaveBeenCalledWith('path/to/prd.txt', 'utf8'); - // Add test suite for addSubtask function - describe('addSubtask function', () => { - // Reset mocks before each test - beforeEach(() => { - jest.clearAllMocks(); - - // Default mock implementations - mockReadJSON.mockImplementation(() => ({ - tasks: [ - { - id: 1, - title: 'Parent Task', - description: 'This is a parent task', - status: 'pending', - dependencies: [] - }, - { - id: 2, - title: 'Existing Task', - description: 'This is an existing task', - status: 'pending', - dependencies: [] - }, - { - id: 3, - title: 'Another Task', - description: 'This is another task', - status: 'pending', - dependencies: [1] - } - ] - })); + // Verify callClaude was called with the correct arguments + expect(mockCallClaude).toHaveBeenCalledWith( + samplePRDContent, + 'path/to/prd.txt', + 3 + ); - // Setup success write response - mockWriteJSON.mockImplementation((path, data) => { - return data; - }); - - // Set up default behavior for dependency check - mockIsTaskDependentOn.mockReturnValue(false); - }); - - test('should add a new subtask to a parent task', async () => { - // Create new subtask data - const newSubtaskData = { - title: 'New Subtask', - description: 'This is a new subtask', - details: 'Implementation details for the subtask', - status: 'pending', - dependencies: [] - }; - - // Execute the test version of addSubtask - const newSubtask = testAddSubtask('tasks/tasks.json', 1, null, newSubtaskData, true); - - // Verify readJSON was called with the correct path - expect(mockReadJSON).toHaveBeenCalledWith('tasks/tasks.json'); - - // Verify writeJSON was called with the correct path - expect(mockWriteJSON).toHaveBeenCalledWith('tasks/tasks.json', expect.any(Object)); - - // Verify the subtask was created with correct data - expect(newSubtask).toBeDefined(); - expect(newSubtask.id).toBe(1); - expect(newSubtask.title).toBe('New Subtask'); - expect(newSubtask.parentTaskId).toBe(1); - - // Verify generateTaskFiles was called - expect(mockGenerateTaskFiles).toHaveBeenCalled(); - }); - - test('should convert an existing task to a subtask', async () => { - // Execute the test version of addSubtask to convert task 2 to a subtask of task 1 - const convertedSubtask = testAddSubtask('tasks/tasks.json', 1, 2, null, true); - - // Verify readJSON was called with the correct path - expect(mockReadJSON).toHaveBeenCalledWith('tasks/tasks.json'); - - // Verify writeJSON was called - expect(mockWriteJSON).toHaveBeenCalled(); - - // Verify the subtask was created with correct data - expect(convertedSubtask).toBeDefined(); - expect(convertedSubtask.id).toBe(1); - expect(convertedSubtask.title).toBe('Existing Task'); - expect(convertedSubtask.parentTaskId).toBe(1); - - // Verify generateTaskFiles was called - expect(mockGenerateTaskFiles).toHaveBeenCalled(); - }); - - test('should throw an error if parent task does not exist', async () => { - // Create new subtask data - const newSubtaskData = { - title: 'New Subtask', - description: 'This is a new subtask' - }; - - // Override mockReadJSON for this specific test case - mockReadJSON.mockImplementationOnce(() => ({ - tasks: [ - { - id: 1, - title: 'Task 1', - status: 'pending' - } - ] - })); - - // Expect an error when trying to add a subtask to a non-existent parent - expect(() => - testAddSubtask('tasks/tasks.json', 999, null, newSubtaskData) - ).toThrow(/Parent task with ID 999 not found/); - - // Verify writeJSON was not called - expect(mockWriteJSON).not.toHaveBeenCalled(); - }); - - test('should throw an error if existing task does not exist', async () => { - // Expect an error when trying to convert a non-existent task - expect(() => - testAddSubtask('tasks/tasks.json', 1, 999, null) - ).toThrow(/Task with ID 999 not found/); - - // Verify writeJSON was not called - expect(mockWriteJSON).not.toHaveBeenCalled(); - }); - - test('should throw an error if trying to create a circular dependency', async () => { - // Force the isTaskDependentOn mock to return true for this test only - mockIsTaskDependentOn.mockReturnValueOnce(true); - - // Expect an error when trying to create a circular dependency - expect(() => - testAddSubtask('tasks/tasks.json', 3, 1, null) - ).toThrow(/circular dependency/); - - // Verify writeJSON was not called - expect(mockWriteJSON).not.toHaveBeenCalled(); - }); - - test('should not regenerate task files if generateFiles is false', async () => { - // Create new subtask data - const newSubtaskData = { - title: 'New Subtask', - description: 'This is a new subtask' - }; - - // Execute the test version of addSubtask with generateFiles = false - testAddSubtask('tasks/tasks.json', 1, null, newSubtaskData, false); - - // Verify writeJSON was called - expect(mockWriteJSON).toHaveBeenCalled(); - - // Verify task files were not regenerated - expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); - }); - }); + // Verify directory check + expect(mockExistsSync).toHaveBeenCalledWith('tasks'); - // Test suite for removeSubtask function - describe('removeSubtask function', () => { - // Reset mocks before each test - beforeEach(() => { - jest.clearAllMocks(); - - // Default mock implementations - mockReadJSON.mockImplementation(() => ({ - tasks: [ - { - id: 1, - title: 'Parent Task', - description: 'This is a parent task', - status: 'pending', - dependencies: [], - subtasks: [ - { - id: 1, - title: 'Subtask 1', - description: 'This is subtask 1', - status: 'pending', - dependencies: [], - parentTaskId: 1 - }, - { - id: 2, - title: 'Subtask 2', - description: 'This is subtask 2', - status: 'in-progress', - dependencies: [1], // Depends on subtask 1 - parentTaskId: 1 - } - ] - }, - { - id: 2, - title: 'Another Task', - description: 'This is another task', - status: 'pending', - dependencies: [1] - } - ] - })); - - // Setup success write response - mockWriteJSON.mockImplementation((path, data) => { - return data; - }); - }); - - test('should remove a subtask from its parent task', async () => { - // Execute the test version of removeSubtask to remove subtask 1.1 - testRemoveSubtask('tasks/tasks.json', '1.1', false, true); - - // Verify readJSON was called with the correct path - expect(mockReadJSON).toHaveBeenCalledWith('tasks/tasks.json'); - - // Verify writeJSON was called with updated data - expect(mockWriteJSON).toHaveBeenCalled(); - - // Verify generateTaskFiles was called - expect(mockGenerateTaskFiles).toHaveBeenCalled(); - }); - - test('should convert a subtask to a standalone task', async () => { - // Execute the test version of removeSubtask to convert subtask 1.1 to a standalone task - const result = testRemoveSubtask('tasks/tasks.json', '1.1', true, true); - - // Verify the result is the new task - expect(result).toBeDefined(); - expect(result.id).toBe(3); - expect(result.title).toBe('Subtask 1'); - expect(result.dependencies).toContain(1); - - // Verify writeJSON was called - expect(mockWriteJSON).toHaveBeenCalled(); - - // Verify generateTaskFiles was called - expect(mockGenerateTaskFiles).toHaveBeenCalled(); - }); - - test('should throw an error if subtask ID format is invalid', async () => { - // Expect an error for invalid subtask ID format - expect(() => - testRemoveSubtask('tasks/tasks.json', '1', false) - ).toThrow(/Invalid subtask ID format/); - - // Verify writeJSON was not called - expect(mockWriteJSON).not.toHaveBeenCalled(); - }); - - test('should throw an error if parent task does not exist', async () => { - // Expect an error for non-existent parent task - expect(() => - testRemoveSubtask('tasks/tasks.json', '999.1', false) - ).toThrow(/Parent task with ID 999 not found/); - - // Verify writeJSON was not called - expect(mockWriteJSON).not.toHaveBeenCalled(); - }); - - test('should throw an error if subtask does not exist', async () => { - // Expect an error for non-existent subtask - expect(() => - testRemoveSubtask('tasks/tasks.json', '1.999', false) - ).toThrow(/Subtask 1.999 not found/); - - // Verify writeJSON was not called - expect(mockWriteJSON).not.toHaveBeenCalled(); - }); - - test('should remove subtasks array if last subtask is removed', async () => { - // Create a data object with just one subtask - mockReadJSON.mockImplementationOnce(() => ({ - tasks: [ - { - id: 1, - title: 'Parent Task', - description: 'This is a parent task', - status: 'pending', - dependencies: [], - subtasks: [ - { - id: 1, - title: 'Last Subtask', - description: 'This is the last subtask', - status: 'pending', - dependencies: [], - parentTaskId: 1 - } - ] - }, - { - id: 2, - title: 'Another Task', - description: 'This is another task', - status: 'pending', - dependencies: [1] - } - ] - })); - - // Mock the behavior of writeJSON to capture the updated tasks data - const updatedTasksData = { tasks: [] }; - mockWriteJSON.mockImplementation((path, data) => { - // Store the data for assertions - updatedTasksData.tasks = [...data.tasks]; - return data; - }); - - // Remove the last subtask - testRemoveSubtask('tasks/tasks.json', '1.1', false, true); - - // Verify writeJSON was called - expect(mockWriteJSON).toHaveBeenCalled(); - - // Verify the subtasks array was removed completely - const parentTask = updatedTasksData.tasks.find(t => t.id === 1); - expect(parentTask).toBeDefined(); - expect(parentTask.subtasks).toBeUndefined(); - - // Verify generateTaskFiles was called - expect(mockGenerateTaskFiles).toHaveBeenCalled(); - }); - - test('should not regenerate task files if generateFiles is false', async () => { - // Execute the test version of removeSubtask with generateFiles = false - testRemoveSubtask('tasks/tasks.json', '1.1', false, false); - - // Verify writeJSON was called - expect(mockWriteJSON).toHaveBeenCalled(); - - // Verify task files were not regenerated - expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); - }); - }); + // Verify writeJSON was called with the correct arguments + expect(mockWriteJSON).toHaveBeenCalledWith( + 'tasks/tasks.json', + sampleClaudeResponse + ); + + // Verify generateTaskFiles was called + expect(mockGenerateTaskFiles).toHaveBeenCalledWith( + 'tasks/tasks.json', + 'tasks' + ); + }); + + test('should create the tasks directory if it does not exist', async () => { + // Mock existsSync to return false specifically for the directory check + // but true for the output file check (so we don't trigger confirmation path) + mockExistsSync.mockImplementation((path) => { + if (path === 'tasks/tasks.json') return false; // Output file doesn't exist + if (path === 'tasks') return false; // Directory doesn't exist + return true; // Default for other paths + }); + + // Call the function + await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); + + // Verify mkdir was called + expect(mockMkdirSync).toHaveBeenCalledWith('tasks', { recursive: true }); + }); + + test('should handle errors in the PRD parsing process', async () => { + // Mock an error in callClaude + const testError = new Error('Test error in Claude API call'); + mockCallClaude.mockRejectedValueOnce(testError); + + // Mock console.error and process.exit + const mockConsoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const mockProcessExit = jest + .spyOn(process, 'exit') + .mockImplementation(() => {}); + + // Call the function + await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); + + // Verify error handling + expect(mockConsoleError).toHaveBeenCalled(); + expect(mockProcessExit).toHaveBeenCalledWith(1); + + // Restore mocks + mockConsoleError.mockRestore(); + mockProcessExit.mockRestore(); + }); + + test('should generate individual task files after creating tasks.json', async () => { + // Call the function + await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); + + // Verify generateTaskFiles was called + expect(mockGenerateTaskFiles).toHaveBeenCalledWith( + 'tasks/tasks.json', + 'tasks' + ); + }); + + test('should prompt for confirmation when tasks.json already exists', async () => { + // Setup mocks to simulate tasks.json already exists + mockExistsSync.mockImplementation((path) => { + if (path === 'tasks/tasks.json') return true; // Output file exists + if (path === 'tasks') return true; // Directory exists + return false; + }); + + // Call the function + await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); + + // Verify prompt was called with expected message + expect(mockPromptYesNo).toHaveBeenCalledWith( + 'Warning: tasks/tasks.json already exists. Overwrite?', + false + ); + + // Verify the file was written after confirmation + expect(mockWriteJSON).toHaveBeenCalledWith( + 'tasks/tasks.json', + sampleClaudeResponse + ); + }); + + test('should not overwrite tasks.json when user declines confirmation', async () => { + // Setup mocks to simulate tasks.json already exists + mockExistsSync.mockImplementation((path) => { + if (path === 'tasks/tasks.json') return true; // Output file exists + if (path === 'tasks') return true; // Directory exists + return false; + }); + + // Mock user declining the confirmation + mockPromptYesNo.mockResolvedValueOnce(false); + + // Mock console.log to capture output + const mockConsoleLog = jest + .spyOn(console, 'log') + .mockImplementation(() => {}); + + // Call the function + const result = await testParsePRD( + 'path/to/prd.txt', + 'tasks/tasks.json', + 3 + ); + + // Verify prompt was called + expect(mockPromptYesNo).toHaveBeenCalledWith( + 'Warning: tasks/tasks.json already exists. Overwrite?', + false + ); + + // Verify the file was NOT written + expect(mockWriteJSON).not.toHaveBeenCalled(); + + // Verify appropriate message was logged + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Operation cancelled. tasks/tasks.json was not modified.' + ); + + // Verify result is null when operation is cancelled + expect(result).toBeNull(); + + // Restore console.log + mockConsoleLog.mockRestore(); + }); + + test('should not prompt for confirmation when tasks.json does not exist', async () => { + // Setup mocks to simulate tasks.json does not exist + mockExistsSync.mockImplementation((path) => { + if (path === 'tasks/tasks.json') return false; // Output file doesn't exist + if (path === 'tasks') return true; // Directory exists + return false; + }); + + // Call the function + await testParsePRD('path/to/prd.txt', 'tasks/tasks.json', 3); + + // Verify prompt was NOT called + expect(mockPromptYesNo).not.toHaveBeenCalled(); + + // Verify the file was written without confirmation + expect(mockWriteJSON).toHaveBeenCalledWith( + 'tasks/tasks.json', + sampleClaudeResponse + ); + }); + }); + + describe.skip('updateTasks function', () => { + test('should update tasks based on new context', async () => { + // This test would verify that: + // 1. The function reads the tasks file correctly + // 2. It filters tasks with ID >= fromId and not 'done' + // 3. It properly calls the AI model with the correct prompt + // 4. It updates the tasks with the AI response + // 5. It writes the updated tasks back to the file + expect(true).toBe(true); + }); + + test('should handle streaming responses from Claude API', async () => { + // This test would verify that: + // 1. The function correctly handles streaming API calls + // 2. It processes the stream data properly + // 3. It combines the chunks into a complete response + expect(true).toBe(true); + }); + + test('should use Perplexity AI when research flag is set', async () => { + // This test would verify that: + // 1. The function uses Perplexity when the research flag is set + // 2. It formats the prompt correctly for Perplexity + // 3. It properly processes the Perplexity response + expect(true).toBe(true); + }); + + test('should handle no tasks to update', async () => { + // This test would verify that: + // 1. The function handles the case when no tasks need updating + // 2. It provides appropriate feedback to the user + expect(true).toBe(true); + }); + + test('should handle errors during the update process', async () => { + // This test would verify that: + // 1. The function handles errors in the AI API calls + // 2. It provides appropriate error messages + // 3. It exits gracefully + expect(true).toBe(true); + }); + }); + + describe('generateTaskFiles function', () => { + // Sample task data for testing + const sampleTasks = { + meta: { projectName: 'Test Project' }, + tasks: [ + { + id: 1, + title: 'Task 1', + description: 'First task description', + status: 'pending', + dependencies: [], + priority: 'high', + details: 'Detailed information for task 1', + testStrategy: 'Test strategy for task 1' + }, + { + id: 2, + title: 'Task 2', + description: 'Second task description', + status: 'pending', + dependencies: [1], + priority: 'medium', + details: 'Detailed information for task 2', + testStrategy: 'Test strategy for task 2' + }, + { + id: 3, + title: 'Task with Subtasks', + description: 'Task with subtasks description', + status: 'pending', + dependencies: [1, 2], + priority: 'high', + details: 'Detailed information for task 3', + testStrategy: 'Test strategy for task 3', + subtasks: [ + { + id: 1, + title: 'Subtask 1', + description: 'First subtask', + status: 'pending', + dependencies: [], + details: 'Details for subtask 1' + }, + { + id: 2, + title: 'Subtask 2', + description: 'Second subtask', + status: 'pending', + dependencies: [1], + details: 'Details for subtask 2' + } + ] + } + ] + }; + + test('should generate task files from tasks.json - working test', () => { + // Set up mocks for this specific test + mockReadJSON.mockImplementationOnce(() => sampleTasks); + mockExistsSync.mockImplementationOnce(() => true); + + // Implement a simplified version of generateTaskFiles + const tasksPath = 'tasks/tasks.json'; + const outputDir = 'tasks'; + + // Manual implementation instead of calling the function + // 1. Read the data + const data = mockReadJSON(tasksPath); + expect(mockReadJSON).toHaveBeenCalledWith(tasksPath); + + // 2. Validate and fix dependencies + mockValidateAndFixDependencies(data, tasksPath); + expect(mockValidateAndFixDependencies).toHaveBeenCalledWith( + data, + tasksPath + ); + + // 3. Generate files + data.tasks.forEach((task) => { + const taskPath = `${outputDir}/task_${task.id.toString().padStart(3, '0')}.txt`; + let content = `# Task ID: ${task.id}\n`; + content += `# Title: ${task.title}\n`; + + mockWriteFileSync(taskPath, content); + }); + + // Verify the files were written + expect(mockWriteFileSync).toHaveBeenCalledTimes(3); + + // Verify specific file paths + expect(mockWriteFileSync).toHaveBeenCalledWith( + 'tasks/task_001.txt', + expect.any(String) + ); + expect(mockWriteFileSync).toHaveBeenCalledWith( + 'tasks/task_002.txt', + expect.any(String) + ); + expect(mockWriteFileSync).toHaveBeenCalledWith( + 'tasks/task_003.txt', + expect.any(String) + ); + }); + + // Skip the remaining tests for now until we get the basic test working + test.skip('should format dependencies with status indicators', () => { + // Test implementation + }); + + test.skip('should handle tasks with no subtasks', () => { + // Test implementation + }); + + test.skip("should create the output directory if it doesn't exist", () => { + // This test skipped until we find a better way to mock the modules + // The key functionality is: + // 1. When outputDir doesn't exist (fs.existsSync returns false) + // 2. The function should call fs.mkdirSync to create it + }); + + test.skip('should format task files with proper sections', () => { + // Test implementation + }); + + test.skip('should include subtasks in task files when present', () => { + // Test implementation + }); + + test.skip('should handle errors during file generation', () => { + // Test implementation + }); + + test.skip('should validate dependencies before generating files', () => { + // Test implementation + }); + }); + + describe('setTaskStatus function', () => { + test('should update task status in tasks.json', async () => { + // Arrange + const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); + + // Act + const updatedData = testSetTaskStatus(testTasksData, '2', 'done'); + + // Assert + expect(updatedData.tasks[1].id).toBe(2); + expect(updatedData.tasks[1].status).toBe('done'); + }); + + test('should update subtask status when using dot notation', async () => { + // Arrange + const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); + + // Act + const updatedData = testSetTaskStatus(testTasksData, '3.1', 'done'); + + // Assert + const subtaskParent = updatedData.tasks.find((t) => t.id === 3); + expect(subtaskParent).toBeDefined(); + expect(subtaskParent.subtasks[0].status).toBe('done'); + }); + + test('should update multiple tasks when given comma-separated IDs', async () => { + // Arrange + const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); + + // Act + const updatedData = testSetTaskStatus(testTasksData, '1,2', 'pending'); + + // Assert + expect(updatedData.tasks[0].status).toBe('pending'); + expect(updatedData.tasks[1].status).toBe('pending'); + }); + + test('should automatically mark subtasks as done when parent is marked done', async () => { + // Arrange + const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); + + // Act + const updatedData = testSetTaskStatus(testTasksData, '3', 'done'); + + // Assert + const parentTask = updatedData.tasks.find((t) => t.id === 3); + expect(parentTask.status).toBe('done'); + expect(parentTask.subtasks[0].status).toBe('done'); + expect(parentTask.subtasks[1].status).toBe('done'); + }); + + test('should throw error for non-existent task ID', async () => { + // Arrange + const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); + + // Assert + expect(() => testSetTaskStatus(testTasksData, '99', 'done')).toThrow( + 'Task 99 not found' + ); + }); + }); + + describe('updateSingleTaskStatus function', () => { + test('should update regular task status', async () => { + // Arrange + const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); + + // Act + const result = testUpdateSingleTaskStatus(testTasksData, '2', 'done'); + + // Assert + expect(result).toBe(true); + expect(testTasksData.tasks[1].status).toBe('done'); + }); + + test('should update subtask status', async () => { + // Arrange + const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); + + // Act + const result = testUpdateSingleTaskStatus(testTasksData, '3.1', 'done'); + + // Assert + expect(result).toBe(true); + expect(testTasksData.tasks[2].subtasks[0].status).toBe('done'); + }); + + test('should handle parent tasks without subtasks', async () => { + // Arrange + const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); + + // Remove subtasks from task 3 + const taskWithoutSubtasks = { ...testTasksData.tasks[2] }; + delete taskWithoutSubtasks.subtasks; + testTasksData.tasks[2] = taskWithoutSubtasks; + + // Assert + expect(() => + testUpdateSingleTaskStatus(testTasksData, '3.1', 'done') + ).toThrow('has no subtasks'); + }); + + test('should handle non-existent subtask ID', async () => { + // Arrange + const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); + + // Assert + expect(() => + testUpdateSingleTaskStatus(testTasksData, '3.99', 'done') + ).toThrow('Subtask 99 not found'); + }); + }); + + describe('listTasks function', () => { + test('should display all tasks when no filter is provided', async () => { + // Arrange + const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); + + // Act + const result = testListTasks(testTasksData); + + // Assert + expect(result.filteredTasks.length).toBe(testTasksData.tasks.length); + expect(mockDisplayTaskList).toHaveBeenCalledWith( + testTasksData, + undefined, + false + ); + }); + + test('should filter tasks by status when filter is provided', async () => { + // Arrange + const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); + const statusFilter = 'done'; + + // Act + const result = testListTasks(testTasksData, statusFilter); + + // Assert + expect(result.filteredTasks.length).toBe( + testTasksData.tasks.filter((t) => t.status === statusFilter).length + ); + expect(mockDisplayTaskList).toHaveBeenCalledWith( + testTasksData, + statusFilter, + false + ); + }); + + test('should display subtasks when withSubtasks flag is true', async () => { + // Arrange + const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); + + // Act + testListTasks(testTasksData, undefined, true); + + // Assert + expect(mockDisplayTaskList).toHaveBeenCalledWith( + testTasksData, + undefined, + true + ); + }); + + test('should handle empty tasks array', async () => { + // Arrange + const testTasksData = JSON.parse(JSON.stringify(emptySampleTasks)); + + // Act + const result = testListTasks(testTasksData); + + // Assert + expect(result.filteredTasks.length).toBe(0); + expect(mockDisplayTaskList).toHaveBeenCalledWith( + testTasksData, + undefined, + false + ); + }); + }); + + describe.skip('expandTask function', () => { + test('should generate subtasks for a task', async () => { + // This test would verify that: + // 1. The function reads the tasks file correctly + // 2. It finds the target task by ID + // 3. It generates subtasks with unique IDs + // 4. It adds the subtasks to the task + // 5. It writes the updated tasks back to the file + expect(true).toBe(true); + }); + + test('should use complexity report for subtask count', async () => { + // This test would verify that: + // 1. The function checks for a complexity report + // 2. It uses the recommended subtask count from the report + // 3. It uses the expansion prompt from the report + expect(true).toBe(true); + }); + + test('should use Perplexity AI when research flag is set', async () => { + // This test would verify that: + // 1. The function uses Perplexity for research-backed generation + // 2. It handles the Perplexity response correctly + expect(true).toBe(true); + }); + + test('should append subtasks to existing ones', async () => { + // This test would verify that: + // 1. The function appends new subtasks to existing ones + // 2. It generates unique subtask IDs + expect(true).toBe(true); + }); + + test('should skip completed tasks', async () => { + // This test would verify that: + // 1. The function skips tasks marked as done or completed + // 2. It provides appropriate feedback + expect(true).toBe(true); + }); + + test('should handle errors during subtask generation', async () => { + // This test would verify that: + // 1. The function handles errors in the AI API calls + // 2. It provides appropriate error messages + // 3. It exits gracefully + expect(true).toBe(true); + }); + }); + + describe.skip('expandAllTasks function', () => { + test('should expand all pending tasks', async () => { + // This test would verify that: + // 1. The function identifies all pending tasks + // 2. It expands each task with appropriate subtasks + // 3. It writes the updated tasks back to the file + expect(true).toBe(true); + }); + + test('should sort tasks by complexity when report is available', async () => { + // This test would verify that: + // 1. The function reads the complexity report + // 2. It sorts tasks by complexity score + // 3. It prioritizes high-complexity tasks + expect(true).toBe(true); + }); + + test('should skip tasks with existing subtasks unless force flag is set', async () => { + // This test would verify that: + // 1. The function skips tasks with existing subtasks + // 2. It processes them when force flag is set + expect(true).toBe(true); + }); + + test('should use task-specific parameters from complexity report', async () => { + // This test would verify that: + // 1. The function uses task-specific subtask counts + // 2. It uses task-specific expansion prompts + expect(true).toBe(true); + }); + + test('should handle empty tasks array', async () => { + // This test would verify that: + // 1. The function handles an empty tasks array gracefully + // 2. It displays an appropriate message + expect(true).toBe(true); + }); + + test('should handle errors for individual tasks without failing the entire operation', async () => { + // This test would verify that: + // 1. The function continues processing tasks even if some fail + // 2. It reports errors for individual tasks + // 3. It completes the operation for successful tasks + expect(true).toBe(true); + }); + }); + + describe('clearSubtasks function', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Test implementation of clearSubtasks that just returns the updated data + const testClearSubtasks = (tasksData, taskIds) => { + // Create a deep copy of the data to avoid modifying the original + const data = JSON.parse(JSON.stringify(tasksData)); + let clearedCount = 0; + + // Handle multiple task IDs (comma-separated) + const taskIdArray = taskIds.split(',').map((id) => id.trim()); + + taskIdArray.forEach((taskId) => { + const id = parseInt(taskId, 10); + if (isNaN(id)) { + return; + } + + const task = data.tasks.find((t) => t.id === id); + if (!task) { + // Log error for non-existent task + mockLog('error', `Task ${id} not found`); + return; + } + + if (!task.subtasks || task.subtasks.length === 0) { + // No subtasks to clear + return; + } + + const subtaskCount = task.subtasks.length; + delete task.subtasks; + clearedCount++; + }); + + return { data, clearedCount }; + }; + + test('should clear subtasks from a specific task', () => { + // Create a deep copy of the sample data + const testData = JSON.parse(JSON.stringify(sampleTasks)); + + // Execute the test function + const { data, clearedCount } = testClearSubtasks(testData, '3'); + + // Verify results + expect(clearedCount).toBe(1); + + // Verify the task's subtasks were removed + const task = data.tasks.find((t) => t.id === 3); + expect(task).toBeDefined(); + expect(task.subtasks).toBeUndefined(); + }); + + test('should clear subtasks from multiple tasks when given comma-separated IDs', () => { + // Setup data with subtasks on multiple tasks + const testData = JSON.parse(JSON.stringify(sampleTasks)); + // Add subtasks to task 2 + testData.tasks[1].subtasks = [ + { + id: 1, + title: 'Test Subtask', + description: 'A test subtask', + status: 'pending', + dependencies: [] + } + ]; + + // Execute the test function + const { data, clearedCount } = testClearSubtasks(testData, '2,3'); + + // Verify results + expect(clearedCount).toBe(2); + + // Verify both tasks had their subtasks cleared + const task2 = data.tasks.find((t) => t.id === 2); + const task3 = data.tasks.find((t) => t.id === 3); + expect(task2.subtasks).toBeUndefined(); + expect(task3.subtasks).toBeUndefined(); + }); + + test('should handle tasks with no subtasks', () => { + // Task 1 has no subtasks in the sample data + const testData = JSON.parse(JSON.stringify(sampleTasks)); + + // Execute the test function + const { clearedCount } = testClearSubtasks(testData, '1'); + + // Verify no tasks were cleared + expect(clearedCount).toBe(0); + }); + + test('should handle non-existent task IDs', () => { + const testData = JSON.parse(JSON.stringify(sampleTasks)); + + // Execute the test function + testClearSubtasks(testData, '99'); + + // Verify an error was logged + expect(mockLog).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Task 99 not found') + ); + }); + + test('should handle multiple task IDs including both valid and non-existent IDs', () => { + const testData = JSON.parse(JSON.stringify(sampleTasks)); + + // Execute the test function + const { data, clearedCount } = testClearSubtasks(testData, '3,99'); + + // Verify results + expect(clearedCount).toBe(1); + expect(mockLog).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Task 99 not found') + ); + + // Verify the valid task's subtasks were removed + const task3 = data.tasks.find((t) => t.id === 3); + expect(task3.subtasks).toBeUndefined(); + }); + }); + + describe('addTask function', () => { + test('should add a new task using AI', async () => { + // Arrange + const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); + const prompt = 'Create a new authentication system'; + + // Act + const result = testAddTask(testTasksData, prompt); + + // Assert + expect(result.newTask.id).toBe( + Math.max(...sampleTasks.tasks.map((t) => t.id)) + 1 + ); + expect(result.newTask.status).toBe('pending'); + expect(result.newTask.title).toContain(prompt.substring(0, 20)); + expect(testTasksData.tasks.length).toBe(sampleTasks.tasks.length + 1); + }); + + test('should validate dependencies when adding a task', async () => { + // Arrange + const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); + const prompt = 'Create a new authentication system'; + const validDependencies = [1, 2]; // These exist in sampleTasks + + // Act + const result = testAddTask(testTasksData, prompt, validDependencies); + + // Assert + expect(result.newTask.dependencies).toEqual(validDependencies); + + // Test invalid dependency + expect(() => { + testAddTask(testTasksData, prompt, [999]); // Non-existent task ID + }).toThrow('Dependency task 999 not found'); + }); + + test('should use specified priority', async () => { + // Arrange + const testTasksData = JSON.parse(JSON.stringify(sampleTasks)); + const prompt = 'Create a new authentication system'; + const priority = 'high'; + + // Act + const result = testAddTask(testTasksData, prompt, [], priority); + + // Assert + expect(result.newTask.priority).toBe(priority); + }); + }); + + // Add test suite for addSubtask function + describe('addSubtask function', () => { + // Reset mocks before each test + beforeEach(() => { + jest.clearAllMocks(); + + // Default mock implementations + mockReadJSON.mockImplementation(() => ({ + tasks: [ + { + id: 1, + title: 'Parent Task', + description: 'This is a parent task', + status: 'pending', + dependencies: [] + }, + { + id: 2, + title: 'Existing Task', + description: 'This is an existing task', + status: 'pending', + dependencies: [] + }, + { + id: 3, + title: 'Another Task', + description: 'This is another task', + status: 'pending', + dependencies: [1] + } + ] + })); + + // Setup success write response + mockWriteJSON.mockImplementation((path, data) => { + return data; + }); + + // Set up default behavior for dependency check + mockIsTaskDependentOn.mockReturnValue(false); + }); + + test('should add a new subtask to a parent task', async () => { + // Create new subtask data + const newSubtaskData = { + title: 'New Subtask', + description: 'This is a new subtask', + details: 'Implementation details for the subtask', + status: 'pending', + dependencies: [] + }; + + // Execute the test version of addSubtask + const newSubtask = testAddSubtask( + 'tasks/tasks.json', + 1, + null, + newSubtaskData, + true + ); + + // Verify readJSON was called with the correct path + expect(mockReadJSON).toHaveBeenCalledWith('tasks/tasks.json'); + + // Verify writeJSON was called with the correct path + expect(mockWriteJSON).toHaveBeenCalledWith( + 'tasks/tasks.json', + expect.any(Object) + ); + + // Verify the subtask was created with correct data + expect(newSubtask).toBeDefined(); + expect(newSubtask.id).toBe(1); + expect(newSubtask.title).toBe('New Subtask'); + expect(newSubtask.parentTaskId).toBe(1); + + // Verify generateTaskFiles was called + expect(mockGenerateTaskFiles).toHaveBeenCalled(); + }); + + test('should convert an existing task to a subtask', async () => { + // Execute the test version of addSubtask to convert task 2 to a subtask of task 1 + const convertedSubtask = testAddSubtask( + 'tasks/tasks.json', + 1, + 2, + null, + true + ); + + // Verify readJSON was called with the correct path + expect(mockReadJSON).toHaveBeenCalledWith('tasks/tasks.json'); + + // Verify writeJSON was called + expect(mockWriteJSON).toHaveBeenCalled(); + + // Verify the subtask was created with correct data + expect(convertedSubtask).toBeDefined(); + expect(convertedSubtask.id).toBe(1); + expect(convertedSubtask.title).toBe('Existing Task'); + expect(convertedSubtask.parentTaskId).toBe(1); + + // Verify generateTaskFiles was called + expect(mockGenerateTaskFiles).toHaveBeenCalled(); + }); + + test('should throw an error if parent task does not exist', async () => { + // Create new subtask data + const newSubtaskData = { + title: 'New Subtask', + description: 'This is a new subtask' + }; + + // Override mockReadJSON for this specific test case + mockReadJSON.mockImplementationOnce(() => ({ + tasks: [ + { + id: 1, + title: 'Task 1', + status: 'pending' + } + ] + })); + + // Expect an error when trying to add a subtask to a non-existent parent + expect(() => + testAddSubtask('tasks/tasks.json', 999, null, newSubtaskData) + ).toThrow(/Parent task with ID 999 not found/); + + // Verify writeJSON was not called + expect(mockWriteJSON).not.toHaveBeenCalled(); + }); + + test('should throw an error if existing task does not exist', async () => { + // Expect an error when trying to convert a non-existent task + expect(() => testAddSubtask('tasks/tasks.json', 1, 999, null)).toThrow( + /Task with ID 999 not found/ + ); + + // Verify writeJSON was not called + expect(mockWriteJSON).not.toHaveBeenCalled(); + }); + + test('should throw an error if trying to create a circular dependency', async () => { + // Force the isTaskDependentOn mock to return true for this test only + mockIsTaskDependentOn.mockReturnValueOnce(true); + + // Expect an error when trying to create a circular dependency + expect(() => testAddSubtask('tasks/tasks.json', 3, 1, null)).toThrow( + /circular dependency/ + ); + + // Verify writeJSON was not called + expect(mockWriteJSON).not.toHaveBeenCalled(); + }); + + test('should not regenerate task files if generateFiles is false', async () => { + // Create new subtask data + const newSubtaskData = { + title: 'New Subtask', + description: 'This is a new subtask' + }; + + // Execute the test version of addSubtask with generateFiles = false + testAddSubtask('tasks/tasks.json', 1, null, newSubtaskData, false); + + // Verify writeJSON was called + expect(mockWriteJSON).toHaveBeenCalled(); + + // Verify task files were not regenerated + expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); + }); + }); + + // Test suite for removeSubtask function + describe('removeSubtask function', () => { + // Reset mocks before each test + beforeEach(() => { + jest.clearAllMocks(); + + // Default mock implementations + mockReadJSON.mockImplementation(() => ({ + tasks: [ + { + id: 1, + title: 'Parent Task', + description: 'This is a parent task', + status: 'pending', + dependencies: [], + subtasks: [ + { + id: 1, + title: 'Subtask 1', + description: 'This is subtask 1', + status: 'pending', + dependencies: [], + parentTaskId: 1 + }, + { + id: 2, + title: 'Subtask 2', + description: 'This is subtask 2', + status: 'in-progress', + dependencies: [1], // Depends on subtask 1 + parentTaskId: 1 + } + ] + }, + { + id: 2, + title: 'Another Task', + description: 'This is another task', + status: 'pending', + dependencies: [1] + } + ] + })); + + // Setup success write response + mockWriteJSON.mockImplementation((path, data) => { + return data; + }); + }); + + test('should remove a subtask from its parent task', async () => { + // Execute the test version of removeSubtask to remove subtask 1.1 + testRemoveSubtask('tasks/tasks.json', '1.1', false, true); + + // Verify readJSON was called with the correct path + expect(mockReadJSON).toHaveBeenCalledWith('tasks/tasks.json'); + + // Verify writeJSON was called with updated data + expect(mockWriteJSON).toHaveBeenCalled(); + + // Verify generateTaskFiles was called + expect(mockGenerateTaskFiles).toHaveBeenCalled(); + }); + + test('should convert a subtask to a standalone task', async () => { + // Execute the test version of removeSubtask to convert subtask 1.1 to a standalone task + const result = testRemoveSubtask('tasks/tasks.json', '1.1', true, true); + + // Verify the result is the new task + expect(result).toBeDefined(); + expect(result.id).toBe(3); + expect(result.title).toBe('Subtask 1'); + expect(result.dependencies).toContain(1); + + // Verify writeJSON was called + expect(mockWriteJSON).toHaveBeenCalled(); + + // Verify generateTaskFiles was called + expect(mockGenerateTaskFiles).toHaveBeenCalled(); + }); + + test('should throw an error if subtask ID format is invalid', async () => { + // Expect an error for invalid subtask ID format + expect(() => testRemoveSubtask('tasks/tasks.json', '1', false)).toThrow( + /Invalid subtask ID format/ + ); + + // Verify writeJSON was not called + expect(mockWriteJSON).not.toHaveBeenCalled(); + }); + + test('should throw an error if parent task does not exist', async () => { + // Expect an error for non-existent parent task + expect(() => + testRemoveSubtask('tasks/tasks.json', '999.1', false) + ).toThrow(/Parent task with ID 999 not found/); + + // Verify writeJSON was not called + expect(mockWriteJSON).not.toHaveBeenCalled(); + }); + + test('should throw an error if subtask does not exist', async () => { + // Expect an error for non-existent subtask + expect(() => + testRemoveSubtask('tasks/tasks.json', '1.999', false) + ).toThrow(/Subtask 1.999 not found/); + + // Verify writeJSON was not called + expect(mockWriteJSON).not.toHaveBeenCalled(); + }); + + test('should remove subtasks array if last subtask is removed', async () => { + // Create a data object with just one subtask + mockReadJSON.mockImplementationOnce(() => ({ + tasks: [ + { + id: 1, + title: 'Parent Task', + description: 'This is a parent task', + status: 'pending', + dependencies: [], + subtasks: [ + { + id: 1, + title: 'Last Subtask', + description: 'This is the last subtask', + status: 'pending', + dependencies: [], + parentTaskId: 1 + } + ] + }, + { + id: 2, + title: 'Another Task', + description: 'This is another task', + status: 'pending', + dependencies: [1] + } + ] + })); + + // Mock the behavior of writeJSON to capture the updated tasks data + const updatedTasksData = { tasks: [] }; + mockWriteJSON.mockImplementation((path, data) => { + // Store the data for assertions + updatedTasksData.tasks = [...data.tasks]; + return data; + }); + + // Remove the last subtask + testRemoveSubtask('tasks/tasks.json', '1.1', false, true); + + // Verify writeJSON was called + expect(mockWriteJSON).toHaveBeenCalled(); + + // Verify the subtasks array was removed completely + const parentTask = updatedTasksData.tasks.find((t) => t.id === 1); + expect(parentTask).toBeDefined(); + expect(parentTask.subtasks).toBeUndefined(); + + // Verify generateTaskFiles was called + expect(mockGenerateTaskFiles).toHaveBeenCalled(); + }); + + test('should not regenerate task files if generateFiles is false', async () => { + // Execute the test version of removeSubtask with generateFiles = false + testRemoveSubtask('tasks/tasks.json', '1.1', false, false); + + // Verify writeJSON was called + expect(mockWriteJSON).toHaveBeenCalled(); + + // Verify task files were not regenerated + expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); + }); + }); }); // Define test versions of the addSubtask and removeSubtask functions -const testAddSubtask = (tasksPath, parentId, existingTaskId, newSubtaskData, generateFiles = true) => { - // Read the existing tasks - const data = mockReadJSON(tasksPath); - if (!data || !data.tasks) { - throw new Error(`Invalid or missing tasks file at ${tasksPath}`); - } - - // Convert parent ID to number - const parentIdNum = parseInt(parentId, 10); - - // Find the parent task - const parentTask = data.tasks.find(t => t.id === parentIdNum); - if (!parentTask) { - throw new Error(`Parent task with ID ${parentIdNum} not found`); - } - - // Initialize subtasks array if it doesn't exist - if (!parentTask.subtasks) { - parentTask.subtasks = []; - } - - let newSubtask; - - // Case 1: Convert an existing task to a subtask - if (existingTaskId !== null) { - const existingTaskIdNum = parseInt(existingTaskId, 10); - - // Find the existing task - const existingTaskIndex = data.tasks.findIndex(t => t.id === existingTaskIdNum); - if (existingTaskIndex === -1) { - throw new Error(`Task with ID ${existingTaskIdNum} not found`); - } - - const existingTask = data.tasks[existingTaskIndex]; - - // Check if task is already a subtask - if (existingTask.parentTaskId) { - throw new Error(`Task ${existingTaskIdNum} is already a subtask of task ${existingTask.parentTaskId}`); - } - - // Check for circular dependency - if (existingTaskIdNum === parentIdNum) { - throw new Error(`Cannot make a task a subtask of itself`); - } - - // Check for circular dependency using mockIsTaskDependentOn - if (mockIsTaskDependentOn()) { - throw new Error(`Cannot create circular dependency: task ${parentIdNum} is already a subtask or dependent of task ${existingTaskIdNum}`); - } - - // Find the highest subtask ID to determine the next ID - const highestSubtaskId = parentTask.subtasks.length > 0 - ? Math.max(...parentTask.subtasks.map(st => st.id)) - : 0; - const newSubtaskId = highestSubtaskId + 1; - - // Clone the existing task to be converted to a subtask - newSubtask = { ...existingTask, id: newSubtaskId, parentTaskId: parentIdNum }; - - // Add to parent's subtasks - parentTask.subtasks.push(newSubtask); - - // Remove the task from the main tasks array - data.tasks.splice(existingTaskIndex, 1); - } - // Case 2: Create a new subtask - else if (newSubtaskData) { - // Find the highest subtask ID to determine the next ID - const highestSubtaskId = parentTask.subtasks.length > 0 - ? Math.max(...parentTask.subtasks.map(st => st.id)) - : 0; - const newSubtaskId = highestSubtaskId + 1; - - // Create the new subtask object - newSubtask = { - id: newSubtaskId, - title: newSubtaskData.title, - description: newSubtaskData.description || '', - details: newSubtaskData.details || '', - status: newSubtaskData.status || 'pending', - dependencies: newSubtaskData.dependencies || [], - parentTaskId: parentIdNum - }; - - // Add to parent's subtasks - parentTask.subtasks.push(newSubtask); - } else { - throw new Error('Either existingTaskId or newSubtaskData must be provided'); - } - - // Write the updated tasks back to the file - mockWriteJSON(tasksPath, data); - - // Generate task files if requested - if (generateFiles) { - mockGenerateTaskFiles(tasksPath, path.dirname(tasksPath)); - } - - return newSubtask; +const testAddSubtask = ( + tasksPath, + parentId, + existingTaskId, + newSubtaskData, + generateFiles = true +) => { + // Read the existing tasks + const data = mockReadJSON(tasksPath); + if (!data || !data.tasks) { + throw new Error(`Invalid or missing tasks file at ${tasksPath}`); + } + + // Convert parent ID to number + const parentIdNum = parseInt(parentId, 10); + + // Find the parent task + const parentTask = data.tasks.find((t) => t.id === parentIdNum); + if (!parentTask) { + throw new Error(`Parent task with ID ${parentIdNum} not found`); + } + + // Initialize subtasks array if it doesn't exist + if (!parentTask.subtasks) { + parentTask.subtasks = []; + } + + let newSubtask; + + // Case 1: Convert an existing task to a subtask + if (existingTaskId !== null) { + const existingTaskIdNum = parseInt(existingTaskId, 10); + + // Find the existing task + const existingTaskIndex = data.tasks.findIndex( + (t) => t.id === existingTaskIdNum + ); + if (existingTaskIndex === -1) { + throw new Error(`Task with ID ${existingTaskIdNum} not found`); + } + + const existingTask = data.tasks[existingTaskIndex]; + + // Check if task is already a subtask + if (existingTask.parentTaskId) { + throw new Error( + `Task ${existingTaskIdNum} is already a subtask of task ${existingTask.parentTaskId}` + ); + } + + // Check for circular dependency + if (existingTaskIdNum === parentIdNum) { + throw new Error(`Cannot make a task a subtask of itself`); + } + + // Check for circular dependency using mockIsTaskDependentOn + if (mockIsTaskDependentOn()) { + throw new Error( + `Cannot create circular dependency: task ${parentIdNum} is already a subtask or dependent of task ${existingTaskIdNum}` + ); + } + + // Find the highest subtask ID to determine the next ID + const highestSubtaskId = + parentTask.subtasks.length > 0 + ? Math.max(...parentTask.subtasks.map((st) => st.id)) + : 0; + const newSubtaskId = highestSubtaskId + 1; + + // Clone the existing task to be converted to a subtask + newSubtask = { + ...existingTask, + id: newSubtaskId, + parentTaskId: parentIdNum + }; + + // Add to parent's subtasks + parentTask.subtasks.push(newSubtask); + + // Remove the task from the main tasks array + data.tasks.splice(existingTaskIndex, 1); + } + // Case 2: Create a new subtask + else if (newSubtaskData) { + // Find the highest subtask ID to determine the next ID + const highestSubtaskId = + parentTask.subtasks.length > 0 + ? Math.max(...parentTask.subtasks.map((st) => st.id)) + : 0; + const newSubtaskId = highestSubtaskId + 1; + + // Create the new subtask object + newSubtask = { + id: newSubtaskId, + title: newSubtaskData.title, + description: newSubtaskData.description || '', + details: newSubtaskData.details || '', + status: newSubtaskData.status || 'pending', + dependencies: newSubtaskData.dependencies || [], + parentTaskId: parentIdNum + }; + + // Add to parent's subtasks + parentTask.subtasks.push(newSubtask); + } else { + throw new Error('Either existingTaskId or newSubtaskData must be provided'); + } + + // Write the updated tasks back to the file + mockWriteJSON(tasksPath, data); + + // Generate task files if requested + if (generateFiles) { + mockGenerateTaskFiles(tasksPath, path.dirname(tasksPath)); + } + + return newSubtask; }; -const testRemoveSubtask = (tasksPath, subtaskId, convertToTask = false, generateFiles = true) => { - // Read the existing tasks - const data = mockReadJSON(tasksPath); - if (!data || !data.tasks) { - throw new Error(`Invalid or missing tasks file at ${tasksPath}`); - } - - // Parse the subtask ID (format: "parentId.subtaskId") - if (!subtaskId.includes('.')) { - throw new Error(`Invalid subtask ID format: ${subtaskId}`); - } - - const [parentIdStr, subtaskIdStr] = subtaskId.split('.'); - const parentId = parseInt(parentIdStr, 10); - const subtaskIdNum = parseInt(subtaskIdStr, 10); - - // Find the parent task - const parentTask = data.tasks.find(t => t.id === parentId); - if (!parentTask) { - throw new Error(`Parent task with ID ${parentId} not found`); - } - - // Check if parent has subtasks - if (!parentTask.subtasks || parentTask.subtasks.length === 0) { - throw new Error(`Parent task ${parentId} has no subtasks`); - } - - // Find the subtask to remove - const subtaskIndex = parentTask.subtasks.findIndex(st => st.id === subtaskIdNum); - if (subtaskIndex === -1) { - throw new Error(`Subtask ${subtaskId} not found`); - } - - // Get a copy of the subtask before removing it - const removedSubtask = { ...parentTask.subtasks[subtaskIndex] }; - - // Remove the subtask from the parent - parentTask.subtasks.splice(subtaskIndex, 1); - - // If parent has no more subtasks, remove the subtasks array - if (parentTask.subtasks.length === 0) { - delete parentTask.subtasks; - } - - let convertedTask = null; - - // Convert the subtask to a standalone task if requested - if (convertToTask) { - // Find the highest task ID to determine the next ID - const highestId = Math.max(...data.tasks.map(t => t.id)); - const newTaskId = highestId + 1; - - // Create the new task from the subtask - convertedTask = { - id: newTaskId, - title: removedSubtask.title, - description: removedSubtask.description || '', - details: removedSubtask.details || '', - status: removedSubtask.status || 'pending', - dependencies: removedSubtask.dependencies || [], - priority: parentTask.priority || 'medium' // Inherit priority from parent - }; - - // Add the parent task as a dependency if not already present - if (!convertedTask.dependencies.includes(parentId)) { - convertedTask.dependencies.push(parentId); - } - - // Add the converted task to the tasks array - data.tasks.push(convertedTask); - } - - // Write the updated tasks back to the file - mockWriteJSON(tasksPath, data); - - // Generate task files if requested - if (generateFiles) { - mockGenerateTaskFiles(tasksPath, path.dirname(tasksPath)); - } - - return convertedTask; +const testRemoveSubtask = ( + tasksPath, + subtaskId, + convertToTask = false, + generateFiles = true +) => { + // Read the existing tasks + const data = mockReadJSON(tasksPath); + if (!data || !data.tasks) { + throw new Error(`Invalid or missing tasks file at ${tasksPath}`); + } + + // Parse the subtask ID (format: "parentId.subtaskId") + if (!subtaskId.includes('.')) { + throw new Error(`Invalid subtask ID format: ${subtaskId}`); + } + + const [parentIdStr, subtaskIdStr] = subtaskId.split('.'); + const parentId = parseInt(parentIdStr, 10); + const subtaskIdNum = parseInt(subtaskIdStr, 10); + + // Find the parent task + const parentTask = data.tasks.find((t) => t.id === parentId); + if (!parentTask) { + throw new Error(`Parent task with ID ${parentId} not found`); + } + + // Check if parent has subtasks + if (!parentTask.subtasks || parentTask.subtasks.length === 0) { + throw new Error(`Parent task ${parentId} has no subtasks`); + } + + // Find the subtask to remove + const subtaskIndex = parentTask.subtasks.findIndex( + (st) => st.id === subtaskIdNum + ); + if (subtaskIndex === -1) { + throw new Error(`Subtask ${subtaskId} not found`); + } + + // Get a copy of the subtask before removing it + const removedSubtask = { ...parentTask.subtasks[subtaskIndex] }; + + // Remove the subtask from the parent + parentTask.subtasks.splice(subtaskIndex, 1); + + // If parent has no more subtasks, remove the subtasks array + if (parentTask.subtasks.length === 0) { + delete parentTask.subtasks; + } + + let convertedTask = null; + + // Convert the subtask to a standalone task if requested + if (convertToTask) { + // Find the highest task ID to determine the next ID + const highestId = Math.max(...data.tasks.map((t) => t.id)); + const newTaskId = highestId + 1; + + // Create the new task from the subtask + convertedTask = { + id: newTaskId, + title: removedSubtask.title, + description: removedSubtask.description || '', + details: removedSubtask.details || '', + status: removedSubtask.status || 'pending', + dependencies: removedSubtask.dependencies || [], + priority: parentTask.priority || 'medium' // Inherit priority from parent + }; + + // Add the parent task as a dependency if not already present + if (!convertedTask.dependencies.includes(parentId)) { + convertedTask.dependencies.push(parentId); + } + + // Add the converted task to the tasks array + data.tasks.push(convertedTask); + } + + // Write the updated tasks back to the file + mockWriteJSON(tasksPath, data); + + // Generate task files if requested + if (generateFiles) { + mockGenerateTaskFiles(tasksPath, path.dirname(tasksPath)); + } + + return convertedTask; }; describe.skip('updateTaskById function', () => { - let mockConsoleLog; - let mockConsoleError; - let mockProcess; - - beforeEach(() => { - // Reset all mocks - jest.clearAllMocks(); - - // Set up default mock values - mockExistsSync.mockReturnValue(true); - mockWriteJSON.mockImplementation(() => {}); - mockGenerateTaskFiles.mockResolvedValue(undefined); - - // Create a deep copy of sample tasks for tests - use imported ES module instead of require - const sampleTasksDeepCopy = JSON.parse(JSON.stringify(sampleTasks)); - mockReadJSON.mockReturnValue(sampleTasksDeepCopy); - - // Mock console and process.exit - mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); - mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); - mockProcess = jest.spyOn(process, 'exit').mockImplementation(() => {}); - }); - - afterEach(() => { - // Restore console and process.exit - mockConsoleLog.mockRestore(); - mockConsoleError.mockRestore(); - mockProcess.mockRestore(); - }); - - test('should update a task successfully', async () => { - // Mock the return value of messages.create and Anthropic - const mockTask = { - id: 2, - title: "Updated Core Functionality", - description: "Updated description", - status: "in-progress", - dependencies: [1], - priority: "high", - details: "Updated details", - testStrategy: "Updated test strategy" - }; - - // Mock streaming for successful response - const mockStream = { - [Symbol.asyncIterator]: jest.fn().mockImplementation(() => { - return { - next: jest.fn() - .mockResolvedValueOnce({ - done: false, - value: { - type: 'content_block_delta', - delta: { text: '{"id": 2, "title": "Updated Core Functionality",' } - } - }) - .mockResolvedValueOnce({ - done: false, - value: { - type: 'content_block_delta', - delta: { text: '"description": "Updated description", "status": "in-progress",' } - } - }) - .mockResolvedValueOnce({ - done: false, - value: { - type: 'content_block_delta', - delta: { text: '"dependencies": [1], "priority": "high", "details": "Updated details",' } - } - }) - .mockResolvedValueOnce({ - done: false, - value: { - type: 'content_block_delta', - delta: { text: '"testStrategy": "Updated test strategy"}' } - } - }) - .mockResolvedValueOnce({ done: true }) - }; - }) - }; - - mockCreate.mockResolvedValue(mockStream); - - // Call the function - const result = await updateTaskById('test-tasks.json', 2, 'Update task 2 with new information'); - - // Verify the task was updated - expect(result).toBeDefined(); - expect(result.title).toBe("Updated Core Functionality"); - expect(result.description).toBe("Updated description"); - - // Verify the correct functions were called - expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); - expect(mockCreate).toHaveBeenCalled(); - expect(mockWriteJSON).toHaveBeenCalled(); - expect(mockGenerateTaskFiles).toHaveBeenCalled(); - - // Verify the task was updated in the tasks data - const tasksData = mockWriteJSON.mock.calls[0][1]; - const updatedTask = tasksData.tasks.find(task => task.id === 2); - expect(updatedTask).toEqual(mockTask); - }); - - test('should return null when task is already completed', async () => { - // Call the function with a completed task - const result = await updateTaskById('test-tasks.json', 1, 'Update task 1 with new information'); - - // Verify the result is null - expect(result).toBeNull(); - - // Verify the correct functions were called - expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); - expect(mockCreate).not.toHaveBeenCalled(); - expect(mockWriteJSON).not.toHaveBeenCalled(); - expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); - }); - - test('should handle task not found error', async () => { - // Call the function with a non-existent task - const result = await updateTaskById('test-tasks.json', 999, 'Update non-existent task'); - - // Verify the result is null - expect(result).toBeNull(); - - // Verify the error was logged - expect(mockLog).toHaveBeenCalledWith('error', expect.stringContaining('Task with ID 999 not found')); - expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Task with ID 999 not found')); - - // Verify the correct functions were called - expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); - expect(mockCreate).not.toHaveBeenCalled(); - expect(mockWriteJSON).not.toHaveBeenCalled(); - expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); - }); - - test('should preserve completed subtasks', async () => { - // Modify the sample data to have a task with completed subtasks - const tasksData = mockReadJSON(); - const task = tasksData.tasks.find(t => t.id === 3); - if (task && task.subtasks && task.subtasks.length > 0) { - // Mark the first subtask as completed - task.subtasks[0].status = 'done'; - task.subtasks[0].title = 'Completed Header Component'; - mockReadJSON.mockReturnValue(tasksData); - } - - // Mock a response that tries to modify the completed subtask - const mockStream = { - [Symbol.asyncIterator]: jest.fn().mockImplementation(() => { - return { - next: jest.fn() - .mockResolvedValueOnce({ - done: false, - value: { - type: 'content_block_delta', - delta: { text: '{"id": 3, "title": "Updated UI Components",' } - } - }) - .mockResolvedValueOnce({ - done: false, - value: { - type: 'content_block_delta', - delta: { text: '"description": "Updated description", "status": "pending",' } - } - }) - .mockResolvedValueOnce({ - done: false, - value: { - type: 'content_block_delta', - delta: { text: '"dependencies": [2], "priority": "medium", "subtasks": [' } - } - }) - .mockResolvedValueOnce({ - done: false, - value: { - type: 'content_block_delta', - delta: { text: '{"id": 1, "title": "Modified Header Component", "status": "pending"},' } - } - }) - .mockResolvedValueOnce({ - done: false, - value: { - type: 'content_block_delta', - delta: { text: '{"id": 2, "title": "Create Footer Component", "status": "pending"}]}' } - } - }) - .mockResolvedValueOnce({ done: true }) - }; - }) - }; - - mockCreate.mockResolvedValue(mockStream); - - // Call the function - const result = await updateTaskById('test-tasks.json', 3, 'Update UI components task'); - - // Verify the subtasks were preserved - expect(result).toBeDefined(); - expect(result.subtasks[0].title).toBe('Completed Header Component'); - expect(result.subtasks[0].status).toBe('done'); - - // Verify the correct functions were called - expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); - expect(mockCreate).toHaveBeenCalled(); - expect(mockWriteJSON).toHaveBeenCalled(); - expect(mockGenerateTaskFiles).toHaveBeenCalled(); - }); - - test('should handle missing tasks file', async () => { - // Mock file not existing - mockExistsSync.mockReturnValue(false); - - // Call the function - const result = await updateTaskById('missing-tasks.json', 2, 'Update task'); - - // Verify the result is null - expect(result).toBeNull(); - - // Verify the error was logged - expect(mockLog).toHaveBeenCalledWith('error', expect.stringContaining('Tasks file not found')); - expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Tasks file not found')); - - // Verify the correct functions were called - expect(mockReadJSON).not.toHaveBeenCalled(); - expect(mockCreate).not.toHaveBeenCalled(); - expect(mockWriteJSON).not.toHaveBeenCalled(); - expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); - }); - - test('should handle API errors', async () => { - // Mock API error - mockCreate.mockRejectedValue(new Error('API error')); - - // Call the function - const result = await updateTaskById('test-tasks.json', 2, 'Update task'); - - // Verify the result is null - expect(result).toBeNull(); - - // Verify the error was logged - expect(mockLog).toHaveBeenCalledWith('error', expect.stringContaining('API error')); - expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('API error')); - - // Verify the correct functions were called - expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); - expect(mockCreate).toHaveBeenCalled(); - expect(mockWriteJSON).not.toHaveBeenCalled(); // Should not write on error - expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); // Should not generate on error - }); - - test('should use Perplexity AI when research flag is true', async () => { - // Mock Perplexity API response - const mockPerplexityResponse = { - choices: [ - { - message: { - content: '{"id": 2, "title": "Researched Core Functionality", "description": "Research-backed description", "status": "in-progress", "dependencies": [1], "priority": "high", "details": "Research-backed details", "testStrategy": "Research-backed test strategy"}' - } - } - ] - }; - - mockChatCompletionsCreate.mockResolvedValue(mockPerplexityResponse); - - // Set the Perplexity API key in environment - process.env.PERPLEXITY_API_KEY = 'dummy-key'; - - // Call the function with research flag - const result = await updateTaskById('test-tasks.json', 2, 'Update task with research', true); - - // Verify the task was updated with research-backed information - expect(result).toBeDefined(); - expect(result.title).toBe("Researched Core Functionality"); - expect(result.description).toBe("Research-backed description"); - - // Verify the Perplexity API was called - expect(mockChatCompletionsCreate).toHaveBeenCalled(); - expect(mockCreate).not.toHaveBeenCalled(); // Claude should not be called - - // Verify the correct functions were called - expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); - expect(mockWriteJSON).toHaveBeenCalled(); - expect(mockGenerateTaskFiles).toHaveBeenCalled(); - - // Clean up - delete process.env.PERPLEXITY_API_KEY; - }); + let mockConsoleLog; + let mockConsoleError; + let mockProcess; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Set up default mock values + mockExistsSync.mockReturnValue(true); + mockWriteJSON.mockImplementation(() => {}); + mockGenerateTaskFiles.mockResolvedValue(undefined); + + // Create a deep copy of sample tasks for tests - use imported ES module instead of require + const sampleTasksDeepCopy = JSON.parse(JSON.stringify(sampleTasks)); + mockReadJSON.mockReturnValue(sampleTasksDeepCopy); + + // Mock console and process.exit + mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + mockConsoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + mockProcess = jest.spyOn(process, 'exit').mockImplementation(() => {}); + }); + + afterEach(() => { + // Restore console and process.exit + mockConsoleLog.mockRestore(); + mockConsoleError.mockRestore(); + mockProcess.mockRestore(); + }); + + test('should update a task successfully', async () => { + // Mock the return value of messages.create and Anthropic + const mockTask = { + id: 2, + title: 'Updated Core Functionality', + description: 'Updated description', + status: 'in-progress', + dependencies: [1], + priority: 'high', + details: 'Updated details', + testStrategy: 'Updated test strategy' + }; + + // Mock streaming for successful response + const mockStream = { + [Symbol.asyncIterator]: jest.fn().mockImplementation(() => { + return { + next: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + type: 'content_block_delta', + delta: { + text: '{"id": 2, "title": "Updated Core Functionality",' + } + } + }) + .mockResolvedValueOnce({ + done: false, + value: { + type: 'content_block_delta', + delta: { + text: '"description": "Updated description", "status": "in-progress",' + } + } + }) + .mockResolvedValueOnce({ + done: false, + value: { + type: 'content_block_delta', + delta: { + text: '"dependencies": [1], "priority": "high", "details": "Updated details",' + } + } + }) + .mockResolvedValueOnce({ + done: false, + value: { + type: 'content_block_delta', + delta: { text: '"testStrategy": "Updated test strategy"}' } + } + }) + .mockResolvedValueOnce({ done: true }) + }; + }) + }; + + mockCreate.mockResolvedValue(mockStream); + + // Call the function + const result = await updateTaskById( + 'test-tasks.json', + 2, + 'Update task 2 with new information' + ); + + // Verify the task was updated + expect(result).toBeDefined(); + expect(result.title).toBe('Updated Core Functionality'); + expect(result.description).toBe('Updated description'); + + // Verify the correct functions were called + expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); + expect(mockCreate).toHaveBeenCalled(); + expect(mockWriteJSON).toHaveBeenCalled(); + expect(mockGenerateTaskFiles).toHaveBeenCalled(); + + // Verify the task was updated in the tasks data + const tasksData = mockWriteJSON.mock.calls[0][1]; + const updatedTask = tasksData.tasks.find((task) => task.id === 2); + expect(updatedTask).toEqual(mockTask); + }); + + test('should return null when task is already completed', async () => { + // Call the function with a completed task + const result = await updateTaskById( + 'test-tasks.json', + 1, + 'Update task 1 with new information' + ); + + // Verify the result is null + expect(result).toBeNull(); + + // Verify the correct functions were called + expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockWriteJSON).not.toHaveBeenCalled(); + expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); + }); + + test('should handle task not found error', async () => { + // Call the function with a non-existent task + const result = await updateTaskById( + 'test-tasks.json', + 999, + 'Update non-existent task' + ); + + // Verify the result is null + expect(result).toBeNull(); + + // Verify the error was logged + expect(mockLog).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Task with ID 999 not found') + ); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Task with ID 999 not found') + ); + + // Verify the correct functions were called + expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockWriteJSON).not.toHaveBeenCalled(); + expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); + }); + + test('should preserve completed subtasks', async () => { + // Modify the sample data to have a task with completed subtasks + const tasksData = mockReadJSON(); + const task = tasksData.tasks.find((t) => t.id === 3); + if (task && task.subtasks && task.subtasks.length > 0) { + // Mark the first subtask as completed + task.subtasks[0].status = 'done'; + task.subtasks[0].title = 'Completed Header Component'; + mockReadJSON.mockReturnValue(tasksData); + } + + // Mock a response that tries to modify the completed subtask + const mockStream = { + [Symbol.asyncIterator]: jest.fn().mockImplementation(() => { + return { + next: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + type: 'content_block_delta', + delta: { text: '{"id": 3, "title": "Updated UI Components",' } + } + }) + .mockResolvedValueOnce({ + done: false, + value: { + type: 'content_block_delta', + delta: { + text: '"description": "Updated description", "status": "pending",' + } + } + }) + .mockResolvedValueOnce({ + done: false, + value: { + type: 'content_block_delta', + delta: { + text: '"dependencies": [2], "priority": "medium", "subtasks": [' + } + } + }) + .mockResolvedValueOnce({ + done: false, + value: { + type: 'content_block_delta', + delta: { + text: '{"id": 1, "title": "Modified Header Component", "status": "pending"},' + } + } + }) + .mockResolvedValueOnce({ + done: false, + value: { + type: 'content_block_delta', + delta: { + text: '{"id": 2, "title": "Create Footer Component", "status": "pending"}]}' + } + } + }) + .mockResolvedValueOnce({ done: true }) + }; + }) + }; + + mockCreate.mockResolvedValue(mockStream); + + // Call the function + const result = await updateTaskById( + 'test-tasks.json', + 3, + 'Update UI components task' + ); + + // Verify the subtasks were preserved + expect(result).toBeDefined(); + expect(result.subtasks[0].title).toBe('Completed Header Component'); + expect(result.subtasks[0].status).toBe('done'); + + // Verify the correct functions were called + expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); + expect(mockCreate).toHaveBeenCalled(); + expect(mockWriteJSON).toHaveBeenCalled(); + expect(mockGenerateTaskFiles).toHaveBeenCalled(); + }); + + test('should handle missing tasks file', async () => { + // Mock file not existing + mockExistsSync.mockReturnValue(false); + + // Call the function + const result = await updateTaskById('missing-tasks.json', 2, 'Update task'); + + // Verify the result is null + expect(result).toBeNull(); + + // Verify the error was logged + expect(mockLog).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Tasks file not found') + ); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Tasks file not found') + ); + + // Verify the correct functions were called + expect(mockReadJSON).not.toHaveBeenCalled(); + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockWriteJSON).not.toHaveBeenCalled(); + expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); + }); + + test('should handle API errors', async () => { + // Mock API error + mockCreate.mockRejectedValue(new Error('API error')); + + // Call the function + const result = await updateTaskById('test-tasks.json', 2, 'Update task'); + + // Verify the result is null + expect(result).toBeNull(); + + // Verify the error was logged + expect(mockLog).toHaveBeenCalledWith( + 'error', + expect.stringContaining('API error') + ); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('API error') + ); + + // Verify the correct functions were called + expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); + expect(mockCreate).toHaveBeenCalled(); + expect(mockWriteJSON).not.toHaveBeenCalled(); // Should not write on error + expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); // Should not generate on error + }); + + test('should use Perplexity AI when research flag is true', async () => { + // Mock Perplexity API response + const mockPerplexityResponse = { + choices: [ + { + message: { + content: + '{"id": 2, "title": "Researched Core Functionality", "description": "Research-backed description", "status": "in-progress", "dependencies": [1], "priority": "high", "details": "Research-backed details", "testStrategy": "Research-backed test strategy"}' + } + } + ] + }; + + mockChatCompletionsCreate.mockResolvedValue(mockPerplexityResponse); + + // Set the Perplexity API key in environment + process.env.PERPLEXITY_API_KEY = 'dummy-key'; + + // Call the function with research flag + const result = await updateTaskById( + 'test-tasks.json', + 2, + 'Update task with research', + true + ); + + // Verify the task was updated with research-backed information + expect(result).toBeDefined(); + expect(result.title).toBe('Researched Core Functionality'); + expect(result.description).toBe('Research-backed description'); + + // Verify the Perplexity API was called + expect(mockChatCompletionsCreate).toHaveBeenCalled(); + expect(mockCreate).not.toHaveBeenCalled(); // Claude should not be called + + // Verify the correct functions were called + expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); + expect(mockWriteJSON).toHaveBeenCalled(); + expect(mockGenerateTaskFiles).toHaveBeenCalled(); + + // Clean up + delete process.env.PERPLEXITY_API_KEY; + }); }); // Mock implementation of updateSubtaskById for testing -const testUpdateSubtaskById = async (tasksPath, subtaskId, prompt, useResearch = false) => { - try { - // Parse parent and subtask IDs - if (!subtaskId || typeof subtaskId !== 'string' || !subtaskId.includes('.')) { - throw new Error(`Invalid subtask ID format: ${subtaskId}`); - } - - const [parentIdStr, subtaskIdStr] = subtaskId.split('.'); - const parentId = parseInt(parentIdStr, 10); - const subtaskIdNum = parseInt(subtaskIdStr, 10); - - if (isNaN(parentId) || parentId <= 0 || isNaN(subtaskIdNum) || subtaskIdNum <= 0) { - throw new Error(`Invalid subtask ID format: ${subtaskId}`); - } - - // Validate prompt - if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') { - throw new Error('Prompt cannot be empty'); - } - - // Check if tasks file exists - if (!mockExistsSync(tasksPath)) { - throw new Error(`Tasks file not found at path: ${tasksPath}`); - } - - // Read the tasks file - const data = mockReadJSON(tasksPath); - if (!data || !data.tasks) { - throw new Error(`No valid tasks found in ${tasksPath}`); - } - - // Find the parent task - const parentTask = data.tasks.find(t => t.id === parentId); - if (!parentTask) { - throw new Error(`Parent task with ID ${parentId} not found`); - } - - // Find the subtask - if (!parentTask.subtasks || !Array.isArray(parentTask.subtasks)) { - throw new Error(`Parent task ${parentId} has no subtasks`); - } - - const subtask = parentTask.subtasks.find(st => st.id === subtaskIdNum); - if (!subtask) { - throw new Error(`Subtask with ID ${subtaskId} not found`); - } - - // Check if subtask is already completed - if (subtask.status === 'done' || subtask.status === 'completed') { - return null; - } - - // Generate additional information - let additionalInformation; - if (useResearch) { - const result = await mockChatCompletionsCreate(); - additionalInformation = result.choices[0].message.content; - } else { - const mockStream = { - [Symbol.asyncIterator]: jest.fn().mockImplementation(() => { - return { - next: jest.fn() - .mockResolvedValueOnce({ - done: false, - value: { - type: 'content_block_delta', - delta: { text: 'Additional information about' } - } - }) - .mockResolvedValueOnce({ - done: false, - value: { - type: 'content_block_delta', - delta: { text: ' the subtask implementation.' } - } - }) - .mockResolvedValueOnce({ done: true }) - }; - }) - }; - - const stream = await mockCreate(); - additionalInformation = 'Additional information about the subtask implementation.'; - } - - // Create timestamp - const timestamp = new Date().toISOString(); - - // Format the additional information with timestamp - const formattedInformation = `\n\n<info added on ${timestamp}>\n${additionalInformation}\n</info added on ${timestamp}>`; - - // Append to subtask details - if (subtask.details) { - subtask.details += formattedInformation; - } else { - subtask.details = formattedInformation; - } - - // Update description with update marker for shorter updates - if (subtask.description && additionalInformation.length < 200) { - subtask.description += ` [Updated: ${new Date().toLocaleDateString()}]`; - } - - // Write the updated tasks to the file - mockWriteJSON(tasksPath, data); - - // Generate individual task files - await mockGenerateTaskFiles(tasksPath, path.dirname(tasksPath)); - - return subtask; - } catch (error) { - mockLog('error', `Error updating subtask: ${error.message}`); - return null; - } +const testUpdateSubtaskById = async ( + tasksPath, + subtaskId, + prompt, + useResearch = false +) => { + try { + // Parse parent and subtask IDs + if ( + !subtaskId || + typeof subtaskId !== 'string' || + !subtaskId.includes('.') + ) { + throw new Error(`Invalid subtask ID format: ${subtaskId}`); + } + + const [parentIdStr, subtaskIdStr] = subtaskId.split('.'); + const parentId = parseInt(parentIdStr, 10); + const subtaskIdNum = parseInt(subtaskIdStr, 10); + + if ( + isNaN(parentId) || + parentId <= 0 || + isNaN(subtaskIdNum) || + subtaskIdNum <= 0 + ) { + throw new Error(`Invalid subtask ID format: ${subtaskId}`); + } + + // Validate prompt + if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') { + throw new Error('Prompt cannot be empty'); + } + + // Check if tasks file exists + if (!mockExistsSync(tasksPath)) { + throw new Error(`Tasks file not found at path: ${tasksPath}`); + } + + // Read the tasks file + const data = mockReadJSON(tasksPath); + if (!data || !data.tasks) { + throw new Error(`No valid tasks found in ${tasksPath}`); + } + + // Find the parent task + const parentTask = data.tasks.find((t) => t.id === parentId); + if (!parentTask) { + throw new Error(`Parent task with ID ${parentId} not found`); + } + + // Find the subtask + if (!parentTask.subtasks || !Array.isArray(parentTask.subtasks)) { + throw new Error(`Parent task ${parentId} has no subtasks`); + } + + const subtask = parentTask.subtasks.find((st) => st.id === subtaskIdNum); + if (!subtask) { + throw new Error(`Subtask with ID ${subtaskId} not found`); + } + + // Check if subtask is already completed + if (subtask.status === 'done' || subtask.status === 'completed') { + return null; + } + + // Generate additional information + let additionalInformation; + if (useResearch) { + const result = await mockChatCompletionsCreate(); + additionalInformation = result.choices[0].message.content; + } else { + const mockStream = { + [Symbol.asyncIterator]: jest.fn().mockImplementation(() => { + return { + next: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + type: 'content_block_delta', + delta: { text: 'Additional information about' } + } + }) + .mockResolvedValueOnce({ + done: false, + value: { + type: 'content_block_delta', + delta: { text: ' the subtask implementation.' } + } + }) + .mockResolvedValueOnce({ done: true }) + }; + }) + }; + + const stream = await mockCreate(); + additionalInformation = + 'Additional information about the subtask implementation.'; + } + + // Create timestamp + const timestamp = new Date().toISOString(); + + // Format the additional information with timestamp + const formattedInformation = `\n\n<info added on ${timestamp}>\n${additionalInformation}\n</info added on ${timestamp}>`; + + // Append to subtask details + if (subtask.details) { + subtask.details += formattedInformation; + } else { + subtask.details = formattedInformation; + } + + // Update description with update marker for shorter updates + if (subtask.description && additionalInformation.length < 200) { + subtask.description += ` [Updated: ${new Date().toLocaleDateString()}]`; + } + + // Write the updated tasks to the file + mockWriteJSON(tasksPath, data); + + // Generate individual task files + await mockGenerateTaskFiles(tasksPath, path.dirname(tasksPath)); + + return subtask; + } catch (error) { + mockLog('error', `Error updating subtask: ${error.message}`); + return null; + } }; describe.skip('updateSubtaskById function', () => { - let mockConsoleLog; - let mockConsoleError; - let mockProcess; - - beforeEach(() => { - // Reset all mocks - jest.clearAllMocks(); - - // Set up default mock values - mockExistsSync.mockReturnValue(true); - mockWriteJSON.mockImplementation(() => {}); - mockGenerateTaskFiles.mockResolvedValue(undefined); - - // Create a deep copy of sample tasks for tests - use imported ES module instead of require - const sampleTasksDeepCopy = JSON.parse(JSON.stringify(sampleTasks)); - - // Ensure the sample tasks has a task with subtasks for testing - // Task 3 should have subtasks - if (sampleTasksDeepCopy.tasks && sampleTasksDeepCopy.tasks.length > 2) { - const task3 = sampleTasksDeepCopy.tasks.find(t => t.id === 3); - if (task3 && (!task3.subtasks || task3.subtasks.length === 0)) { - task3.subtasks = [ - { - id: 1, - title: 'Create Header Component', - description: 'Create a reusable header component', - status: 'pending' - }, - { - id: 2, - title: 'Create Footer Component', - description: 'Create a reusable footer component', - status: 'pending' - } - ]; - } - } - - mockReadJSON.mockReturnValue(sampleTasksDeepCopy); - - // Mock console and process.exit - mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); - mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); - mockProcess = jest.spyOn(process, 'exit').mockImplementation(() => {}); - }); - - afterEach(() => { - // Restore console and process.exit - mockConsoleLog.mockRestore(); - mockConsoleError.mockRestore(); - mockProcess.mockRestore(); - }); - - test('should update a subtask successfully', async () => { - // Mock streaming for successful response - const mockStream = { - [Symbol.asyncIterator]: jest.fn().mockImplementation(() => { - return { - next: jest.fn() - .mockResolvedValueOnce({ - done: false, - value: { - type: 'content_block_delta', - delta: { text: 'Additional information about the subtask implementation.' } - } - }) - .mockResolvedValueOnce({ done: true }) - }; - }) - }; - - mockCreate.mockResolvedValue(mockStream); - - // Call the function - const result = await testUpdateSubtaskById('test-tasks.json', '3.1', 'Add details about API endpoints'); - - // Verify the subtask was updated - expect(result).toBeDefined(); - expect(result.details).toContain('<info added on'); - expect(result.details).toContain('Additional information about the subtask implementation'); - expect(result.details).toContain('</info added on'); - - // Verify the correct functions were called - expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); - expect(mockCreate).toHaveBeenCalled(); - expect(mockWriteJSON).toHaveBeenCalled(); - expect(mockGenerateTaskFiles).toHaveBeenCalled(); - - // Verify the subtask was updated in the tasks data - const tasksData = mockWriteJSON.mock.calls[0][1]; - const parentTask = tasksData.tasks.find(task => task.id === 3); - const updatedSubtask = parentTask.subtasks.find(st => st.id === 1); - expect(updatedSubtask.details).toContain('Additional information about the subtask implementation'); - }); - - test('should return null when subtask is already completed', async () => { - // Modify the sample data to have a completed subtask - const tasksData = mockReadJSON(); - const task = tasksData.tasks.find(t => t.id === 3); - if (task && task.subtasks && task.subtasks.length > 0) { - // Mark the first subtask as completed - task.subtasks[0].status = 'done'; - mockReadJSON.mockReturnValue(tasksData); - } - - // Call the function with a completed subtask - const result = await testUpdateSubtaskById('test-tasks.json', '3.1', 'Update completed subtask'); - - // Verify the result is null - expect(result).toBeNull(); - - // Verify the correct functions were called - expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); - expect(mockCreate).not.toHaveBeenCalled(); - expect(mockWriteJSON).not.toHaveBeenCalled(); - expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); - }); - - test('should handle subtask not found error', async () => { - // Call the function with a non-existent subtask - const result = await testUpdateSubtaskById('test-tasks.json', '3.999', 'Update non-existent subtask'); - - // Verify the result is null - expect(result).toBeNull(); - - // Verify the error was logged - expect(mockLog).toHaveBeenCalledWith('error', expect.stringContaining('Subtask with ID 3.999 not found')); - - // Verify the correct functions were called - expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); - expect(mockCreate).not.toHaveBeenCalled(); - expect(mockWriteJSON).not.toHaveBeenCalled(); - expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); - }); - - test('should handle invalid subtask ID format', async () => { - // Call the function with an invalid subtask ID - const result = await testUpdateSubtaskById('test-tasks.json', 'invalid-id', 'Update subtask with invalid ID'); - - // Verify the result is null - expect(result).toBeNull(); - - // Verify the error was logged - expect(mockLog).toHaveBeenCalledWith('error', expect.stringContaining('Invalid subtask ID format')); - - // Verify the correct functions were called - expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); - expect(mockCreate).not.toHaveBeenCalled(); - expect(mockWriteJSON).not.toHaveBeenCalled(); - expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); - }); - - test('should handle missing tasks file', async () => { - // Mock file not existing - mockExistsSync.mockReturnValue(false); - - // Call the function - const result = await testUpdateSubtaskById('missing-tasks.json', '3.1', 'Update subtask'); - - // Verify the result is null - expect(result).toBeNull(); - - // Verify the error was logged - expect(mockLog).toHaveBeenCalledWith('error', expect.stringContaining('Tasks file not found')); - - // Verify the correct functions were called - expect(mockReadJSON).not.toHaveBeenCalled(); - expect(mockCreate).not.toHaveBeenCalled(); - expect(mockWriteJSON).not.toHaveBeenCalled(); - expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); - }); - - test('should handle empty prompt', async () => { - // Call the function with an empty prompt - const result = await testUpdateSubtaskById('test-tasks.json', '3.1', ''); - - // Verify the result is null - expect(result).toBeNull(); - - // Verify the error was logged - expect(mockLog).toHaveBeenCalledWith('error', expect.stringContaining('Prompt cannot be empty')); - - // Verify the correct functions were called - expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); - expect(mockCreate).not.toHaveBeenCalled(); - expect(mockWriteJSON).not.toHaveBeenCalled(); - expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); - }); - - test('should use Perplexity AI when research flag is true', async () => { - // Mock Perplexity API response - const mockPerplexityResponse = { - choices: [ - { - message: { - content: 'Research-backed information about the subtask implementation.' - } - } - ] - }; - - mockChatCompletionsCreate.mockResolvedValue(mockPerplexityResponse); - - // Set the Perplexity API key in environment - process.env.PERPLEXITY_API_KEY = 'dummy-key'; - - // Call the function with research flag - const result = await testUpdateSubtaskById('test-tasks.json', '3.1', 'Add research-backed details', true); - - // Verify the subtask was updated with research-backed information - expect(result).toBeDefined(); - expect(result.details).toContain('<info added on'); - expect(result.details).toContain('Research-backed information about the subtask implementation'); - expect(result.details).toContain('</info added on'); - - // Verify the Perplexity API was called - expect(mockChatCompletionsCreate).toHaveBeenCalled(); - expect(mockCreate).not.toHaveBeenCalled(); // Claude should not be called - - // Verify the correct functions were called - expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); - expect(mockWriteJSON).toHaveBeenCalled(); - expect(mockGenerateTaskFiles).toHaveBeenCalled(); - - // Clean up - delete process.env.PERPLEXITY_API_KEY; - }); - - test('should append timestamp correctly in XML-like format', async () => { - // Mock streaming for successful response - const mockStream = { - [Symbol.asyncIterator]: jest.fn().mockImplementation(() => { - return { - next: jest.fn() - .mockResolvedValueOnce({ - done: false, - value: { - type: 'content_block_delta', - delta: { text: 'Additional information about the subtask implementation.' } - } - }) - .mockResolvedValueOnce({ done: true }) - }; - }) - }; - - mockCreate.mockResolvedValue(mockStream); - - // Call the function - const result = await testUpdateSubtaskById('test-tasks.json', '3.1', 'Add details about API endpoints'); - - // Verify the XML-like format with timestamp - expect(result).toBeDefined(); - expect(result.details).toMatch(/<info added on [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z>/); - expect(result.details).toMatch(/<\/info added on [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z>/); - - // Verify the same timestamp is used in both opening and closing tags - const openingMatch = result.details.match(/<info added on ([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z)>/); - const closingMatch = result.details.match(/<\/info added on ([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z)>/); - - expect(openingMatch).toBeTruthy(); - expect(closingMatch).toBeTruthy(); - expect(openingMatch[1]).toBe(closingMatch[1]); - }); + let mockConsoleLog; + let mockConsoleError; + let mockProcess; - let mockTasksData; - const tasksPath = 'test-tasks.json'; - const outputDir = 'test-tasks-output'; // Assuming generateTaskFiles needs this + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); - beforeEach(() => { - // Reset mocks before each test - jest.clearAllMocks(); + // Set up default mock values + mockExistsSync.mockReturnValue(true); + mockWriteJSON.mockImplementation(() => {}); + mockGenerateTaskFiles.mockResolvedValue(undefined); - // Reset mock data (deep copy to avoid test interference) - mockTasksData = JSON.parse(JSON.stringify({ - tasks: [ - { - id: 1, - title: 'Parent Task 1', - status: 'pending', - dependencies: [], - priority: 'medium', - description: 'Parent description', - details: 'Parent details', - testStrategy: 'Parent tests', - subtasks: [ - { - id: 1, - title: 'Subtask 1.1', - description: 'Subtask 1.1 description', - details: 'Initial subtask details.', - status: 'pending', - dependencies: [], - }, - { - id: 2, - title: 'Subtask 1.2', - description: 'Subtask 1.2 description', - details: 'Initial subtask details for 1.2.', - status: 'done', // Completed subtask - dependencies: [], - } - ] - } - ] - })); + // Create a deep copy of sample tasks for tests - use imported ES module instead of require + const sampleTasksDeepCopy = JSON.parse(JSON.stringify(sampleTasks)); - // Default mock behaviors - mockReadJSON.mockReturnValue(mockTasksData); - mockDirname.mockReturnValue(outputDir); // Mock path.dirname needed by generateTaskFiles - mockGenerateTaskFiles.mockResolvedValue(); // Assume generateTaskFiles succeeds - }); + // Ensure the sample tasks has a task with subtasks for testing + // Task 3 should have subtasks + if (sampleTasksDeepCopy.tasks && sampleTasksDeepCopy.tasks.length > 2) { + const task3 = sampleTasksDeepCopy.tasks.find((t) => t.id === 3); + if (task3 && (!task3.subtasks || task3.subtasks.length === 0)) { + task3.subtasks = [ + { + id: 1, + title: 'Create Header Component', + description: 'Create a reusable header component', + status: 'pending' + }, + { + id: 2, + title: 'Create Footer Component', + description: 'Create a reusable footer component', + status: 'pending' + } + ]; + } + } - test('should successfully update subtask using Claude (non-research)', async () => { - const subtaskIdToUpdate = '1.1'; // Valid format - const updatePrompt = 'Add more technical details about API integration.'; // Non-empty prompt - const expectedClaudeResponse = 'Here are the API integration details you requested.'; + mockReadJSON.mockReturnValue(sampleTasksDeepCopy); - // --- Arrange --- - // **Explicitly reset and configure mocks for this test** - jest.clearAllMocks(); // Ensure clean state + // Mock console and process.exit + mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + mockConsoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + mockProcess = jest.spyOn(process, 'exit').mockImplementation(() => {}); + }); - // Configure mocks used *before* readJSON - mockExistsSync.mockReturnValue(true); // Ensure file is found - mockGetAvailableAIModel.mockReturnValue({ // Ensure this returns the correct structure - type: 'claude', - client: { messages: { create: mockCreate } } - }); + afterEach(() => { + // Restore console and process.exit + mockConsoleLog.mockRestore(); + mockConsoleError.mockRestore(); + mockProcess.mockRestore(); + }); - // Configure mocks used *after* readJSON (as before) - mockReadJSON.mockReturnValue(mockTasksData); // Ensure readJSON returns valid data - async function* createMockStream() { - yield { type: 'content_block_delta', delta: { text: expectedClaudeResponse.substring(0, 10) } }; - yield { type: 'content_block_delta', delta: { text: expectedClaudeResponse.substring(10) } }; - yield { type: 'message_stop' }; - } - mockCreate.mockResolvedValue(createMockStream()); - mockDirname.mockReturnValue(outputDir); - mockGenerateTaskFiles.mockResolvedValue(); + test('should update a subtask successfully', async () => { + // Mock streaming for successful response + const mockStream = { + [Symbol.asyncIterator]: jest.fn().mockImplementation(() => { + return { + next: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + type: 'content_block_delta', + delta: { + text: 'Additional information about the subtask implementation.' + } + } + }) + .mockResolvedValueOnce({ done: true }) + }; + }) + }; - // --- Act --- - const updatedSubtask = await taskManager.updateSubtaskById(tasksPath, subtaskIdToUpdate, updatePrompt, false); + mockCreate.mockResolvedValue(mockStream); - // --- Assert --- - // **Add an assertion right at the start to check if readJSON was called** - expect(mockReadJSON).toHaveBeenCalledWith(tasksPath); // <<< Let's see if this passes now + // Call the function + const result = await testUpdateSubtaskById( + 'test-tasks.json', + '3.1', + 'Add details about API endpoints' + ); - // ... (rest of the assertions as before) ... - expect(mockGetAvailableAIModel).toHaveBeenCalledWith({ claudeOverloaded: false, requiresResearch: false }); - expect(mockCreate).toHaveBeenCalledTimes(1); - // ... etc ... - }); + // Verify the subtask was updated + expect(result).toBeDefined(); + expect(result.details).toContain('<info added on'); + expect(result.details).toContain( + 'Additional information about the subtask implementation' + ); + expect(result.details).toContain('</info added on'); - test('should successfully update subtask using Perplexity (research)', async () => { - const subtaskIdToUpdate = '1.1'; - const updatePrompt = 'Research best practices for this subtask.'; - const expectedPerplexityResponse = 'Based on research, here are the best practices...'; - const perplexityModelName = 'mock-perplexity-model'; // Define a mock model name + // Verify the correct functions were called + expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); + expect(mockCreate).toHaveBeenCalled(); + expect(mockWriteJSON).toHaveBeenCalled(); + expect(mockGenerateTaskFiles).toHaveBeenCalled(); - // --- Arrange --- - // Mock environment variable for Perplexity model if needed by CONFIG/logic - process.env.PERPLEXITY_MODEL = perplexityModelName; + // Verify the subtask was updated in the tasks data + const tasksData = mockWriteJSON.mock.calls[0][1]; + const parentTask = tasksData.tasks.find((task) => task.id === 3); + const updatedSubtask = parentTask.subtasks.find((st) => st.id === 1); + expect(updatedSubtask.details).toContain( + 'Additional information about the subtask implementation' + ); + }); - // Mock getAvailableAIModel to return Perplexity client when research is required - mockGetAvailableAIModel.mockReturnValue({ - type: 'perplexity', - client: { chat: { completions: { create: mockChatCompletionsCreate } } } // Match the mocked structure - }); + test('should return null when subtask is already completed', async () => { + // Modify the sample data to have a completed subtask + const tasksData = mockReadJSON(); + const task = tasksData.tasks.find((t) => t.id === 3); + if (task && task.subtasks && task.subtasks.length > 0) { + // Mark the first subtask as completed + task.subtasks[0].status = 'done'; + mockReadJSON.mockReturnValue(tasksData); + } - // Mock Perplexity's response - mockChatCompletionsCreate.mockResolvedValue({ - choices: [{ message: { content: expectedPerplexityResponse } }] - }); + // Call the function with a completed subtask + const result = await testUpdateSubtaskById( + 'test-tasks.json', + '3.1', + 'Update completed subtask' + ); - // --- Act --- - const updatedSubtask = await taskManager.updateSubtaskById(tasksPath, subtaskIdToUpdate, updatePrompt, true); // useResearch = true + // Verify the result is null + expect(result).toBeNull(); - // --- Assert --- - expect(mockReadJSON).toHaveBeenCalledWith(tasksPath); - // Verify getAvailableAIModel was called correctly for research - expect(mockGetAvailableAIModel).toHaveBeenCalledWith({ claudeOverloaded: false, requiresResearch: true }); - expect(mockChatCompletionsCreate).toHaveBeenCalledTimes(1); + // Verify the correct functions were called + expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockWriteJSON).not.toHaveBeenCalled(); + expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); + }); - // Verify Perplexity API call parameters - expect(mockChatCompletionsCreate).toHaveBeenCalledWith(expect.objectContaining({ - model: perplexityModelName, // Check the correct model is used - temperature: 0.7, // From CONFIG mock - max_tokens: 4000, // From CONFIG mock - messages: expect.arrayContaining([ - expect.objectContaining({ role: 'system', content: expect.any(String) }), - expect.objectContaining({ - role: 'user', - content: expect.stringContaining(updatePrompt) // Check prompt is included - }) - ]) - })); + test('should handle subtask not found error', async () => { + // Call the function with a non-existent subtask + const result = await testUpdateSubtaskById( + 'test-tasks.json', + '3.999', + 'Update non-existent subtask' + ); - // Verify subtask data was updated - const writtenData = mockWriteJSON.mock.calls[0][1]; // Get data passed to writeJSON - const parentTask = writtenData.tasks.find(t => t.id === 1); - const targetSubtask = parentTask.subtasks.find(st => st.id === 1); + // Verify the result is null + expect(result).toBeNull(); - expect(targetSubtask.details).toContain(expectedPerplexityResponse); - expect(targetSubtask.details).toMatch(/<info added on .*>/); // Check for timestamp tag - expect(targetSubtask.description).toMatch(/\[Updated: .*]/); // Check description update + // Verify the error was logged + expect(mockLog).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Subtask with ID 3.999 not found') + ); - // Verify writeJSON and generateTaskFiles were called - expect(mockWriteJSON).toHaveBeenCalledWith(tasksPath, writtenData); - expect(mockGenerateTaskFiles).toHaveBeenCalledWith(tasksPath, outputDir); + // Verify the correct functions were called + expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockWriteJSON).not.toHaveBeenCalled(); + expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); + }); - // Verify the function returned the updated subtask - expect(updatedSubtask).toBeDefined(); - expect(updatedSubtask.id).toBe(1); - expect(updatedSubtask.parentTaskId).toBe(1); - expect(updatedSubtask.details).toContain(expectedPerplexityResponse); + test('should handle invalid subtask ID format', async () => { + // Call the function with an invalid subtask ID + const result = await testUpdateSubtaskById( + 'test-tasks.json', + 'invalid-id', + 'Update subtask with invalid ID' + ); - // Clean up env var if set - delete process.env.PERPLEXITY_MODEL; - }); + // Verify the result is null + expect(result).toBeNull(); - test('should fall back to Perplexity if Claude is overloaded', async () => { - const subtaskIdToUpdate = '1.1'; - const updatePrompt = 'Add details, trying Claude first.'; - const expectedPerplexityResponse = 'Perplexity provided these details as fallback.'; - const perplexityModelName = 'mock-perplexity-model-fallback'; + // Verify the error was logged + expect(mockLog).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Invalid subtask ID format') + ); - // --- Arrange --- - // Mock environment variable for Perplexity model - process.env.PERPLEXITY_MODEL = perplexityModelName; + // Verify the correct functions were called + expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockWriteJSON).not.toHaveBeenCalled(); + expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); + }); - // Mock getAvailableAIModel: Return Claude first, then Perplexity - mockGetAvailableAIModel - .mockReturnValueOnce({ // First call: Return Claude - type: 'claude', - client: { messages: { create: mockCreate } } - }) - .mockReturnValueOnce({ // Second call: Return Perplexity (after overload) - type: 'perplexity', - client: { chat: { completions: { create: mockChatCompletionsCreate } } } - }); + test('should handle missing tasks file', async () => { + // Mock file not existing + mockExistsSync.mockReturnValue(false); - // Mock Claude to throw an overload error - const overloadError = new Error('Claude API is overloaded.'); - overloadError.type = 'overloaded_error'; // Match one of the specific checks - mockCreate.mockRejectedValue(overloadError); // Simulate Claude failing + // Call the function + const result = await testUpdateSubtaskById( + 'missing-tasks.json', + '3.1', + 'Update subtask' + ); - // Mock Perplexity's successful response - mockChatCompletionsCreate.mockResolvedValue({ - choices: [{ message: { content: expectedPerplexityResponse } }] - }); + // Verify the result is null + expect(result).toBeNull(); - // --- Act --- - const updatedSubtask = await taskManager.updateSubtaskById(tasksPath, subtaskIdToUpdate, updatePrompt, false); // Start with useResearch = false + // Verify the error was logged + expect(mockLog).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Tasks file not found') + ); - // --- Assert --- - expect(mockReadJSON).toHaveBeenCalledWith(tasksPath); + // Verify the correct functions were called + expect(mockReadJSON).not.toHaveBeenCalled(); + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockWriteJSON).not.toHaveBeenCalled(); + expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); + }); - // Verify getAvailableAIModel calls - expect(mockGetAvailableAIModel).toHaveBeenCalledTimes(2); - expect(mockGetAvailableAIModel).toHaveBeenNthCalledWith(1, { claudeOverloaded: false, requiresResearch: false }); - expect(mockGetAvailableAIModel).toHaveBeenNthCalledWith(2, { claudeOverloaded: true, requiresResearch: false }); // claudeOverloaded should now be true + test('should handle empty prompt', async () => { + // Call the function with an empty prompt + const result = await testUpdateSubtaskById('test-tasks.json', '3.1', ''); - // Verify Claude was attempted and failed - expect(mockCreate).toHaveBeenCalledTimes(1); - // Verify Perplexity was called as fallback - expect(mockChatCompletionsCreate).toHaveBeenCalledTimes(1); + // Verify the result is null + expect(result).toBeNull(); - // Verify Perplexity API call parameters - expect(mockChatCompletionsCreate).toHaveBeenCalledWith(expect.objectContaining({ - model: perplexityModelName, - messages: expect.arrayContaining([ - expect.objectContaining({ - role: 'user', - content: expect.stringContaining(updatePrompt) - }) - ]) - })); + // Verify the error was logged + expect(mockLog).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Prompt cannot be empty') + ); - // Verify subtask data was updated with Perplexity's response - const writtenData = mockWriteJSON.mock.calls[0][1]; - const parentTask = writtenData.tasks.find(t => t.id === 1); - const targetSubtask = parentTask.subtasks.find(st => st.id === 1); + // Verify the correct functions were called + expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockWriteJSON).not.toHaveBeenCalled(); + expect(mockGenerateTaskFiles).not.toHaveBeenCalled(); + }); - expect(targetSubtask.details).toContain(expectedPerplexityResponse); // Should contain fallback response - expect(targetSubtask.details).toMatch(/<info added on .*>/); - expect(targetSubtask.description).toMatch(/\[Updated: .*]/); + test('should use Perplexity AI when research flag is true', async () => { + // Mock Perplexity API response + const mockPerplexityResponse = { + choices: [ + { + message: { + content: + 'Research-backed information about the subtask implementation.' + } + } + ] + }; - // Verify writeJSON and generateTaskFiles were called - expect(mockWriteJSON).toHaveBeenCalledWith(tasksPath, writtenData); - expect(mockGenerateTaskFiles).toHaveBeenCalledWith(tasksPath, outputDir); + mockChatCompletionsCreate.mockResolvedValue(mockPerplexityResponse); - // Verify the function returned the updated subtask - expect(updatedSubtask).toBeDefined(); - expect(updatedSubtask.details).toContain(expectedPerplexityResponse); + // Set the Perplexity API key in environment + process.env.PERPLEXITY_API_KEY = 'dummy-key'; - // Clean up env var if set - delete process.env.PERPLEXITY_MODEL; - }); + // Call the function with research flag + const result = await testUpdateSubtaskById( + 'test-tasks.json', + '3.1', + 'Add research-backed details', + true + ); - // More tests will go here... + // Verify the subtask was updated with research-backed information + expect(result).toBeDefined(); + expect(result.details).toContain('<info added on'); + expect(result.details).toContain( + 'Research-backed information about the subtask implementation' + ); + expect(result.details).toContain('</info added on'); + // Verify the Perplexity API was called + expect(mockChatCompletionsCreate).toHaveBeenCalled(); + expect(mockCreate).not.toHaveBeenCalled(); // Claude should not be called + + // Verify the correct functions were called + expect(mockReadJSON).toHaveBeenCalledWith('test-tasks.json'); + expect(mockWriteJSON).toHaveBeenCalled(); + expect(mockGenerateTaskFiles).toHaveBeenCalled(); + + // Clean up + delete process.env.PERPLEXITY_API_KEY; + }); + + test('should append timestamp correctly in XML-like format', async () => { + // Mock streaming for successful response + const mockStream = { + [Symbol.asyncIterator]: jest.fn().mockImplementation(() => { + return { + next: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + type: 'content_block_delta', + delta: { + text: 'Additional information about the subtask implementation.' + } + } + }) + .mockResolvedValueOnce({ done: true }) + }; + }) + }; + + mockCreate.mockResolvedValue(mockStream); + + // Call the function + const result = await testUpdateSubtaskById( + 'test-tasks.json', + '3.1', + 'Add details about API endpoints' + ); + + // Verify the XML-like format with timestamp + expect(result).toBeDefined(); + expect(result.details).toMatch( + /<info added on [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z>/ + ); + expect(result.details).toMatch( + /<\/info added on [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z>/ + ); + + // Verify the same timestamp is used in both opening and closing tags + const openingMatch = result.details.match( + /<info added on ([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z)>/ + ); + const closingMatch = result.details.match( + /<\/info added on ([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z)>/ + ); + + expect(openingMatch).toBeTruthy(); + expect(closingMatch).toBeTruthy(); + expect(openingMatch[1]).toBe(closingMatch[1]); + }); + + let mockTasksData; + const tasksPath = 'test-tasks.json'; + const outputDir = 'test-tasks-output'; // Assuming generateTaskFiles needs this + + beforeEach(() => { + // Reset mocks before each test + jest.clearAllMocks(); + + // Reset mock data (deep copy to avoid test interference) + mockTasksData = JSON.parse( + JSON.stringify({ + tasks: [ + { + id: 1, + title: 'Parent Task 1', + status: 'pending', + dependencies: [], + priority: 'medium', + description: 'Parent description', + details: 'Parent details', + testStrategy: 'Parent tests', + subtasks: [ + { + id: 1, + title: 'Subtask 1.1', + description: 'Subtask 1.1 description', + details: 'Initial subtask details.', + status: 'pending', + dependencies: [] + }, + { + id: 2, + title: 'Subtask 1.2', + description: 'Subtask 1.2 description', + details: 'Initial subtask details for 1.2.', + status: 'done', // Completed subtask + dependencies: [] + } + ] + } + ] + }) + ); + + // Default mock behaviors + mockReadJSON.mockReturnValue(mockTasksData); + mockDirname.mockReturnValue(outputDir); // Mock path.dirname needed by generateTaskFiles + mockGenerateTaskFiles.mockResolvedValue(); // Assume generateTaskFiles succeeds + }); + + test('should successfully update subtask using Claude (non-research)', async () => { + const subtaskIdToUpdate = '1.1'; // Valid format + const updatePrompt = 'Add more technical details about API integration.'; // Non-empty prompt + const expectedClaudeResponse = + 'Here are the API integration details you requested.'; + + // --- Arrange --- + // **Explicitly reset and configure mocks for this test** + jest.clearAllMocks(); // Ensure clean state + + // Configure mocks used *before* readJSON + mockExistsSync.mockReturnValue(true); // Ensure file is found + mockGetAvailableAIModel.mockReturnValue({ + // Ensure this returns the correct structure + type: 'claude', + client: { messages: { create: mockCreate } } + }); + + // Configure mocks used *after* readJSON (as before) + mockReadJSON.mockReturnValue(mockTasksData); // Ensure readJSON returns valid data + async function* createMockStream() { + yield { + type: 'content_block_delta', + delta: { text: expectedClaudeResponse.substring(0, 10) } + }; + yield { + type: 'content_block_delta', + delta: { text: expectedClaudeResponse.substring(10) } + }; + yield { type: 'message_stop' }; + } + mockCreate.mockResolvedValue(createMockStream()); + mockDirname.mockReturnValue(outputDir); + mockGenerateTaskFiles.mockResolvedValue(); + + // --- Act --- + const updatedSubtask = await taskManager.updateSubtaskById( + tasksPath, + subtaskIdToUpdate, + updatePrompt, + false + ); + + // --- Assert --- + // **Add an assertion right at the start to check if readJSON was called** + expect(mockReadJSON).toHaveBeenCalledWith(tasksPath); // <<< Let's see if this passes now + + // ... (rest of the assertions as before) ... + expect(mockGetAvailableAIModel).toHaveBeenCalledWith({ + claudeOverloaded: false, + requiresResearch: false + }); + expect(mockCreate).toHaveBeenCalledTimes(1); + // ... etc ... + }); + + test('should successfully update subtask using Perplexity (research)', async () => { + const subtaskIdToUpdate = '1.1'; + const updatePrompt = 'Research best practices for this subtask.'; + const expectedPerplexityResponse = + 'Based on research, here are the best practices...'; + const perplexityModelName = 'mock-perplexity-model'; // Define a mock model name + + // --- Arrange --- + // Mock environment variable for Perplexity model if needed by CONFIG/logic + process.env.PERPLEXITY_MODEL = perplexityModelName; + + // Mock getAvailableAIModel to return Perplexity client when research is required + mockGetAvailableAIModel.mockReturnValue({ + type: 'perplexity', + client: { chat: { completions: { create: mockChatCompletionsCreate } } } // Match the mocked structure + }); + + // Mock Perplexity's response + mockChatCompletionsCreate.mockResolvedValue({ + choices: [{ message: { content: expectedPerplexityResponse } }] + }); + + // --- Act --- + const updatedSubtask = await taskManager.updateSubtaskById( + tasksPath, + subtaskIdToUpdate, + updatePrompt, + true + ); // useResearch = true + + // --- Assert --- + expect(mockReadJSON).toHaveBeenCalledWith(tasksPath); + // Verify getAvailableAIModel was called correctly for research + expect(mockGetAvailableAIModel).toHaveBeenCalledWith({ + claudeOverloaded: false, + requiresResearch: true + }); + expect(mockChatCompletionsCreate).toHaveBeenCalledTimes(1); + + // Verify Perplexity API call parameters + expect(mockChatCompletionsCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: perplexityModelName, // Check the correct model is used + temperature: 0.7, // From CONFIG mock + max_tokens: 4000, // From CONFIG mock + messages: expect.arrayContaining([ + expect.objectContaining({ + role: 'system', + content: expect.any(String) + }), + expect.objectContaining({ + role: 'user', + content: expect.stringContaining(updatePrompt) // Check prompt is included + }) + ]) + }) + ); + + // Verify subtask data was updated + const writtenData = mockWriteJSON.mock.calls[0][1]; // Get data passed to writeJSON + const parentTask = writtenData.tasks.find((t) => t.id === 1); + const targetSubtask = parentTask.subtasks.find((st) => st.id === 1); + + expect(targetSubtask.details).toContain(expectedPerplexityResponse); + expect(targetSubtask.details).toMatch(/<info added on .*>/); // Check for timestamp tag + expect(targetSubtask.description).toMatch(/\[Updated: .*]/); // Check description update + + // Verify writeJSON and generateTaskFiles were called + expect(mockWriteJSON).toHaveBeenCalledWith(tasksPath, writtenData); + expect(mockGenerateTaskFiles).toHaveBeenCalledWith(tasksPath, outputDir); + + // Verify the function returned the updated subtask + expect(updatedSubtask).toBeDefined(); + expect(updatedSubtask.id).toBe(1); + expect(updatedSubtask.parentTaskId).toBe(1); + expect(updatedSubtask.details).toContain(expectedPerplexityResponse); + + // Clean up env var if set + delete process.env.PERPLEXITY_MODEL; + }); + + test('should fall back to Perplexity if Claude is overloaded', async () => { + const subtaskIdToUpdate = '1.1'; + const updatePrompt = 'Add details, trying Claude first.'; + const expectedPerplexityResponse = + 'Perplexity provided these details as fallback.'; + const perplexityModelName = 'mock-perplexity-model-fallback'; + + // --- Arrange --- + // Mock environment variable for Perplexity model + process.env.PERPLEXITY_MODEL = perplexityModelName; + + // Mock getAvailableAIModel: Return Claude first, then Perplexity + mockGetAvailableAIModel + .mockReturnValueOnce({ + // First call: Return Claude + type: 'claude', + client: { messages: { create: mockCreate } } + }) + .mockReturnValueOnce({ + // Second call: Return Perplexity (after overload) + type: 'perplexity', + client: { chat: { completions: { create: mockChatCompletionsCreate } } } + }); + + // Mock Claude to throw an overload error + const overloadError = new Error('Claude API is overloaded.'); + overloadError.type = 'overloaded_error'; // Match one of the specific checks + mockCreate.mockRejectedValue(overloadError); // Simulate Claude failing + + // Mock Perplexity's successful response + mockChatCompletionsCreate.mockResolvedValue({ + choices: [{ message: { content: expectedPerplexityResponse } }] + }); + + // --- Act --- + const updatedSubtask = await taskManager.updateSubtaskById( + tasksPath, + subtaskIdToUpdate, + updatePrompt, + false + ); // Start with useResearch = false + + // --- Assert --- + expect(mockReadJSON).toHaveBeenCalledWith(tasksPath); + + // Verify getAvailableAIModel calls + expect(mockGetAvailableAIModel).toHaveBeenCalledTimes(2); + expect(mockGetAvailableAIModel).toHaveBeenNthCalledWith(1, { + claudeOverloaded: false, + requiresResearch: false + }); + expect(mockGetAvailableAIModel).toHaveBeenNthCalledWith(2, { + claudeOverloaded: true, + requiresResearch: false + }); // claudeOverloaded should now be true + + // Verify Claude was attempted and failed + expect(mockCreate).toHaveBeenCalledTimes(1); + // Verify Perplexity was called as fallback + expect(mockChatCompletionsCreate).toHaveBeenCalledTimes(1); + + // Verify Perplexity API call parameters + expect(mockChatCompletionsCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: perplexityModelName, + messages: expect.arrayContaining([ + expect.objectContaining({ + role: 'user', + content: expect.stringContaining(updatePrompt) + }) + ]) + }) + ); + + // Verify subtask data was updated with Perplexity's response + const writtenData = mockWriteJSON.mock.calls[0][1]; + const parentTask = writtenData.tasks.find((t) => t.id === 1); + const targetSubtask = parentTask.subtasks.find((st) => st.id === 1); + + expect(targetSubtask.details).toContain(expectedPerplexityResponse); // Should contain fallback response + expect(targetSubtask.details).toMatch(/<info added on .*>/); + expect(targetSubtask.description).toMatch(/\[Updated: .*]/); + + // Verify writeJSON and generateTaskFiles were called + expect(mockWriteJSON).toHaveBeenCalledWith(tasksPath, writtenData); + expect(mockGenerateTaskFiles).toHaveBeenCalledWith(tasksPath, outputDir); + + // Verify the function returned the updated subtask + expect(updatedSubtask).toBeDefined(); + expect(updatedSubtask.details).toContain(expectedPerplexityResponse); + + // Clean up env var if set + delete process.env.PERPLEXITY_MODEL; + }); + + // More tests will go here... }); diff --git a/tests/unit/ui.test.js b/tests/unit/ui.test.js index d9ee56e2..f7e26d7b 100644 --- a/tests/unit/ui.test.js +++ b/tests/unit/ui.test.js @@ -3,226 +3,228 @@ */ import { jest } from '@jest/globals'; -import { - getStatusWithColor, - formatDependenciesWithStatus, - createProgressBar, - getComplexityWithColor +import { + getStatusWithColor, + formatDependenciesWithStatus, + createProgressBar, + getComplexityWithColor } from '../../scripts/modules/ui.js'; import { sampleTasks } from '../fixtures/sample-tasks.js'; // Mock dependencies jest.mock('chalk', () => { - const origChalkFn = text => text; - const chalk = origChalkFn; - chalk.green = text => text; // Return text as-is for status functions - chalk.yellow = text => text; - chalk.red = text => text; - chalk.cyan = text => text; - chalk.blue = text => text; - chalk.gray = text => text; - chalk.white = text => text; - chalk.bold = text => text; - chalk.dim = text => text; - - // Add hex and other methods - chalk.hex = () => origChalkFn; - chalk.rgb = () => origChalkFn; - - return chalk; + const origChalkFn = (text) => text; + const chalk = origChalkFn; + chalk.green = (text) => text; // Return text as-is for status functions + chalk.yellow = (text) => text; + chalk.red = (text) => text; + chalk.cyan = (text) => text; + chalk.blue = (text) => text; + chalk.gray = (text) => text; + chalk.white = (text) => text; + chalk.bold = (text) => text; + chalk.dim = (text) => text; + + // Add hex and other methods + chalk.hex = () => origChalkFn; + chalk.rgb = () => origChalkFn; + + return chalk; }); jest.mock('figlet', () => ({ - textSync: jest.fn(() => 'Task Master Banner'), + textSync: jest.fn(() => 'Task Master Banner') })); -jest.mock('boxen', () => jest.fn(text => `[boxed: ${text}]`)); +jest.mock('boxen', () => jest.fn((text) => `[boxed: ${text}]`)); -jest.mock('ora', () => jest.fn(() => ({ - start: jest.fn(), - succeed: jest.fn(), - fail: jest.fn(), - stop: jest.fn(), -}))); +jest.mock('ora', () => + jest.fn(() => ({ + start: jest.fn(), + succeed: jest.fn(), + fail: jest.fn(), + stop: jest.fn() + })) +); -jest.mock('cli-table3', () => jest.fn().mockImplementation(() => ({ - push: jest.fn(), - toString: jest.fn(() => 'Table Content'), -}))); +jest.mock('cli-table3', () => + jest.fn().mockImplementation(() => ({ + push: jest.fn(), + toString: jest.fn(() => 'Table Content') + })) +); -jest.mock('gradient-string', () => jest.fn(() => jest.fn(text => text))); +jest.mock('gradient-string', () => jest.fn(() => jest.fn((text) => text))); jest.mock('../../scripts/modules/utils.js', () => ({ - CONFIG: { - projectName: 'Test Project', - projectVersion: '1.0.0', - }, - log: jest.fn(), - findTaskById: jest.fn(), - readJSON: jest.fn(), - readComplexityReport: jest.fn(), - truncate: jest.fn(text => text), + CONFIG: { + projectName: 'Test Project', + projectVersion: '1.0.0' + }, + log: jest.fn(), + findTaskById: jest.fn(), + readJSON: jest.fn(), + readComplexityReport: jest.fn(), + truncate: jest.fn((text) => text) })); jest.mock('../../scripts/modules/task-manager.js', () => ({ - findNextTask: jest.fn(), - analyzeTaskComplexity: jest.fn(), + findNextTask: jest.fn(), + analyzeTaskComplexity: jest.fn() })); describe('UI Module', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); + beforeEach(() => { + jest.clearAllMocks(); + }); - describe('getStatusWithColor function', () => { - test('should return done status with emoji for console output', () => { - const result = getStatusWithColor('done'); - expect(result).toMatch(/done/); - expect(result).toContain('✅'); - }); + describe('getStatusWithColor function', () => { + test('should return done status with emoji for console output', () => { + const result = getStatusWithColor('done'); + expect(result).toMatch(/done/); + expect(result).toContain('✅'); + }); - test('should return pending status with emoji for console output', () => { - const result = getStatusWithColor('pending'); - expect(result).toMatch(/pending/); - expect(result).toContain('⏱️'); - }); + test('should return pending status with emoji for console output', () => { + const result = getStatusWithColor('pending'); + expect(result).toMatch(/pending/); + expect(result).toContain('⏱️'); + }); - test('should return deferred status with emoji for console output', () => { - const result = getStatusWithColor('deferred'); - expect(result).toMatch(/deferred/); - expect(result).toContain('⏱️'); - }); + test('should return deferred status with emoji for console output', () => { + const result = getStatusWithColor('deferred'); + expect(result).toMatch(/deferred/); + expect(result).toContain('⏱️'); + }); - test('should return in-progress status with emoji for console output', () => { - const result = getStatusWithColor('in-progress'); - expect(result).toMatch(/in-progress/); - expect(result).toContain('🔄'); - }); + test('should return in-progress status with emoji for console output', () => { + const result = getStatusWithColor('in-progress'); + expect(result).toMatch(/in-progress/); + expect(result).toContain('🔄'); + }); - test('should return unknown status with emoji for console output', () => { - const result = getStatusWithColor('unknown'); - expect(result).toMatch(/unknown/); - expect(result).toContain('❌'); - }); - - test('should use simple icons when forTable is true', () => { - const doneResult = getStatusWithColor('done', true); - expect(doneResult).toMatch(/done/); - expect(doneResult).toContain('✓'); - - const pendingResult = getStatusWithColor('pending', true); - expect(pendingResult).toMatch(/pending/); - expect(pendingResult).toContain('○'); - - const inProgressResult = getStatusWithColor('in-progress', true); - expect(inProgressResult).toMatch(/in-progress/); - expect(inProgressResult).toContain('►'); - - const deferredResult = getStatusWithColor('deferred', true); - expect(deferredResult).toMatch(/deferred/); - expect(deferredResult).toContain('x'); - }); - }); + test('should return unknown status with emoji for console output', () => { + const result = getStatusWithColor('unknown'); + expect(result).toMatch(/unknown/); + expect(result).toContain('❌'); + }); - describe('formatDependenciesWithStatus function', () => { - test('should format dependencies as plain IDs when forConsole is false (default)', () => { - const dependencies = [1, 2, 3]; - const allTasks = [ - { id: 1, status: 'done' }, - { id: 2, status: 'pending' }, - { id: 3, status: 'deferred' } - ]; + test('should use simple icons when forTable is true', () => { + const doneResult = getStatusWithColor('done', true); + expect(doneResult).toMatch(/done/); + expect(doneResult).toContain('✓'); - const result = formatDependenciesWithStatus(dependencies, allTasks); - - // With recent changes, we expect just plain IDs when forConsole is false - expect(result).toBe('1, 2, 3'); - }); + const pendingResult = getStatusWithColor('pending', true); + expect(pendingResult).toMatch(/pending/); + expect(pendingResult).toContain('○'); - test('should format dependencies with status indicators when forConsole is true', () => { - const dependencies = [1, 2, 3]; - const allTasks = [ - { id: 1, status: 'done' }, - { id: 2, status: 'pending' }, - { id: 3, status: 'deferred' } - ]; - - const result = formatDependenciesWithStatus(dependencies, allTasks, true); - - // We can't test for exact color formatting due to our chalk mocks - // Instead, test that the result contains all the expected IDs - expect(result).toContain('1'); - expect(result).toContain('2'); - expect(result).toContain('3'); - - // Test that it's a comma-separated list - expect(result.split(', ').length).toBe(3); - }); + const inProgressResult = getStatusWithColor('in-progress', true); + expect(inProgressResult).toMatch(/in-progress/); + expect(inProgressResult).toContain('►'); - test('should return "None" for empty dependencies', () => { - const result = formatDependenciesWithStatus([], []); - expect(result).toBe('None'); - }); + const deferredResult = getStatusWithColor('deferred', true); + expect(deferredResult).toMatch(/deferred/); + expect(deferredResult).toContain('x'); + }); + }); - test('should handle missing tasks in the task list', () => { - const dependencies = [1, 999]; - const allTasks = [ - { id: 1, status: 'done' } - ]; + describe('formatDependenciesWithStatus function', () => { + test('should format dependencies as plain IDs when forConsole is false (default)', () => { + const dependencies = [1, 2, 3]; + const allTasks = [ + { id: 1, status: 'done' }, + { id: 2, status: 'pending' }, + { id: 3, status: 'deferred' } + ]; - const result = formatDependenciesWithStatus(dependencies, allTasks); - expect(result).toBe('1, 999 (Not found)'); - }); - }); + const result = formatDependenciesWithStatus(dependencies, allTasks); - describe('createProgressBar function', () => { - test('should create a progress bar with the correct percentage', () => { - const result = createProgressBar(50, 10); - expect(result).toBe('█████░░░░░ 50%'); - }); + // With recent changes, we expect just plain IDs when forConsole is false + expect(result).toBe('1, 2, 3'); + }); - test('should handle 0% progress', () => { - const result = createProgressBar(0, 10); - expect(result).toBe('░░░░░░░░░░ 0%'); - }); + test('should format dependencies with status indicators when forConsole is true', () => { + const dependencies = [1, 2, 3]; + const allTasks = [ + { id: 1, status: 'done' }, + { id: 2, status: 'pending' }, + { id: 3, status: 'deferred' } + ]; - test('should handle 100% progress', () => { - const result = createProgressBar(100, 10); - expect(result).toBe('██████████ 100%'); - }); + const result = formatDependenciesWithStatus(dependencies, allTasks, true); - test('should handle invalid percentages by clamping', () => { - const result1 = createProgressBar(0, 10); // -10 should clamp to 0 - expect(result1).toBe('░░░░░░░░░░ 0%'); - - const result2 = createProgressBar(100, 10); // 150 should clamp to 100 - expect(result2).toBe('██████████ 100%'); - }); - }); + // We can't test for exact color formatting due to our chalk mocks + // Instead, test that the result contains all the expected IDs + expect(result).toContain('1'); + expect(result).toContain('2'); + expect(result).toContain('3'); - describe('getComplexityWithColor function', () => { - test('should return high complexity in red', () => { - const result = getComplexityWithColor(8); - expect(result).toMatch(/8/); - expect(result).toContain('🔴'); - }); + // Test that it's a comma-separated list + expect(result.split(', ').length).toBe(3); + }); - test('should return medium complexity in yellow', () => { - const result = getComplexityWithColor(5); - expect(result).toMatch(/5/); - expect(result).toContain('🟡'); - }); + test('should return "None" for empty dependencies', () => { + const result = formatDependenciesWithStatus([], []); + expect(result).toBe('None'); + }); - test('should return low complexity in green', () => { - const result = getComplexityWithColor(3); - expect(result).toMatch(/3/); - expect(result).toContain('🟢'); - }); + test('should handle missing tasks in the task list', () => { + const dependencies = [1, 999]; + const allTasks = [{ id: 1, status: 'done' }]; - test('should handle non-numeric inputs', () => { - const result = getComplexityWithColor('high'); - expect(result).toMatch(/high/); - expect(result).toContain('🔴'); - }); - }); -}); \ No newline at end of file + const result = formatDependenciesWithStatus(dependencies, allTasks); + expect(result).toBe('1, 999 (Not found)'); + }); + }); + + describe('createProgressBar function', () => { + test('should create a progress bar with the correct percentage', () => { + const result = createProgressBar(50, 10); + expect(result).toBe('█████░░░░░ 50%'); + }); + + test('should handle 0% progress', () => { + const result = createProgressBar(0, 10); + expect(result).toBe('░░░░░░░░░░ 0%'); + }); + + test('should handle 100% progress', () => { + const result = createProgressBar(100, 10); + expect(result).toBe('██████████ 100%'); + }); + + test('should handle invalid percentages by clamping', () => { + const result1 = createProgressBar(0, 10); // -10 should clamp to 0 + expect(result1).toBe('░░░░░░░░░░ 0%'); + + const result2 = createProgressBar(100, 10); // 150 should clamp to 100 + expect(result2).toBe('██████████ 100%'); + }); + }); + + describe('getComplexityWithColor function', () => { + test('should return high complexity in red', () => { + const result = getComplexityWithColor(8); + expect(result).toMatch(/8/); + expect(result).toContain('🔴'); + }); + + test('should return medium complexity in yellow', () => { + const result = getComplexityWithColor(5); + expect(result).toMatch(/5/); + expect(result).toContain('🟡'); + }); + + test('should return low complexity in green', () => { + const result = getComplexityWithColor(3); + expect(result).toMatch(/3/); + expect(result).toContain('🟢'); + }); + + test('should handle non-numeric inputs', () => { + const result = getComplexityWithColor('high'); + expect(result).toMatch(/high/); + expect(result).toContain('🔴'); + }); + }); +}); diff --git a/tests/unit/utils.test.js b/tests/unit/utils.test.js index 59f2261e..cbbfae97 100644 --- a/tests/unit/utils.test.js +++ b/tests/unit/utils.test.js @@ -8,547 +8,600 @@ import path from 'path'; import chalk from 'chalk'; // Import the actual module to test -import { - truncate, - log, - readJSON, - writeJSON, - sanitizePrompt, - readComplexityReport, - findTaskInComplexityReport, - taskExists, - formatTaskId, - findCycles, - CONFIG, - LOG_LEVELS, - findTaskById, - toKebabCase +import { + truncate, + log, + readJSON, + writeJSON, + sanitizePrompt, + readComplexityReport, + findTaskInComplexityReport, + taskExists, + formatTaskId, + findCycles, + CONFIG, + LOG_LEVELS, + findTaskById, + toKebabCase } from '../../scripts/modules/utils.js'; // Skip the import of detectCamelCaseFlags as we'll implement our own version for testing // Mock chalk functions jest.mock('chalk', () => ({ - gray: jest.fn(text => `gray:${text}`), - blue: jest.fn(text => `blue:${text}`), - yellow: jest.fn(text => `yellow:${text}`), - red: jest.fn(text => `red:${text}`), - green: jest.fn(text => `green:${text}`) + gray: jest.fn((text) => `gray:${text}`), + blue: jest.fn((text) => `blue:${text}`), + yellow: jest.fn((text) => `yellow:${text}`), + red: jest.fn((text) => `red:${text}`), + green: jest.fn((text) => `green:${text}`) })); // Test implementation of detectCamelCaseFlags function testDetectCamelCaseFlags(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; + 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; } describe('Utils Module', () => { - // Setup fs mocks for each test - let fsReadFileSyncSpy; - let fsWriteFileSyncSpy; - let fsExistsSyncSpy; - let pathJoinSpy; + // Setup fs mocks for each test + let fsReadFileSyncSpy; + let fsWriteFileSyncSpy; + let fsExistsSyncSpy; + let pathJoinSpy; - beforeEach(() => { - // Setup fs spy functions for each test - fsReadFileSyncSpy = jest.spyOn(fs, 'readFileSync').mockImplementation(); - fsWriteFileSyncSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(); - fsExistsSyncSpy = jest.spyOn(fs, 'existsSync').mockImplementation(); - pathJoinSpy = jest.spyOn(path, 'join').mockImplementation(); - - // Clear all mocks before each test - jest.clearAllMocks(); - }); + beforeEach(() => { + // Setup fs spy functions for each test + fsReadFileSyncSpy = jest.spyOn(fs, 'readFileSync').mockImplementation(); + fsWriteFileSyncSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(); + fsExistsSyncSpy = jest.spyOn(fs, 'existsSync').mockImplementation(); + pathJoinSpy = jest.spyOn(path, 'join').mockImplementation(); - afterEach(() => { - // Restore all mocked functions - fsReadFileSyncSpy.mockRestore(); - fsWriteFileSyncSpy.mockRestore(); - fsExistsSyncSpy.mockRestore(); - pathJoinSpy.mockRestore(); - }); + // Clear all mocks before each test + jest.clearAllMocks(); + }); - describe('truncate function', () => { - test('should return the original string if shorter than maxLength', () => { - const result = truncate('Hello', 10); - expect(result).toBe('Hello'); - }); + afterEach(() => { + // Restore all mocked functions + fsReadFileSyncSpy.mockRestore(); + fsWriteFileSyncSpy.mockRestore(); + fsExistsSyncSpy.mockRestore(); + pathJoinSpy.mockRestore(); + }); - test('should truncate the string and add ellipsis if longer than maxLength', () => { - const result = truncate('This is a long string that needs truncation', 20); - expect(result).toBe('This is a long st...'); - }); + describe('truncate function', () => { + test('should return the original string if shorter than maxLength', () => { + const result = truncate('Hello', 10); + expect(result).toBe('Hello'); + }); - test('should handle empty string', () => { - const result = truncate('', 10); - expect(result).toBe(''); - }); + test('should truncate the string and add ellipsis if longer than maxLength', () => { + const result = truncate( + 'This is a long string that needs truncation', + 20 + ); + expect(result).toBe('This is a long st...'); + }); - test('should return null when input is null', () => { - const result = truncate(null, 10); - expect(result).toBe(null); - }); + test('should handle empty string', () => { + const result = truncate('', 10); + expect(result).toBe(''); + }); - test('should return undefined when input is undefined', () => { - const result = truncate(undefined, 10); - expect(result).toBe(undefined); - }); + test('should return null when input is null', () => { + const result = truncate(null, 10); + expect(result).toBe(null); + }); - test('should handle maxLength of 0 or negative', () => { - // When maxLength is 0, slice(0, -3) returns 'He' - const result1 = truncate('Hello', 0); - expect(result1).toBe('He...'); - - // When maxLength is negative, slice(0, -8) returns nothing - const result2 = truncate('Hello', -5); - expect(result2).toBe('...'); - }); - }); + test('should return undefined when input is undefined', () => { + const result = truncate(undefined, 10); + expect(result).toBe(undefined); + }); - describe('log function', () => { - // Save original console.log - const originalConsoleLog = console.log; - - beforeEach(() => { - // Mock console.log for each test - console.log = jest.fn(); - }); - - afterEach(() => { - // Restore original console.log after each test - console.log = originalConsoleLog; - }); + test('should handle maxLength of 0 or negative', () => { + // When maxLength is 0, slice(0, -3) returns 'He' + const result1 = truncate('Hello', 0); + expect(result1).toBe('He...'); - test('should log messages according to log level', () => { - // Test with info level (1) - CONFIG.logLevel = 'info'; - - log('debug', 'Debug message'); - log('info', 'Info message'); - log('warn', 'Warning message'); - log('error', 'Error message'); - - // Debug should not be logged (level 0 < 1) - expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('Debug message')); - - // Info and above should be logged - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Info message')); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Warning message')); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Error message')); - - // Verify the formatting includes icons - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('ℹ️')); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('⚠️')); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('❌')); - }); + // When maxLength is negative, slice(0, -8) returns nothing + const result2 = truncate('Hello', -5); + expect(result2).toBe('...'); + }); + }); - test('should not log messages below the configured log level', () => { - // Set log level to error (3) - CONFIG.logLevel = 'error'; - - log('debug', 'Debug message'); - log('info', 'Info message'); - log('warn', 'Warning message'); - log('error', 'Error message'); - - // Only error should be logged - expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('Debug message')); - expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('Info message')); - expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('Warning message')); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Error message')); - }); - - test('should join multiple arguments into a single message', () => { - CONFIG.logLevel = 'info'; - log('info', 'Message', 'with', 'multiple', 'parts'); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Message with multiple parts')); - }); - }); + describe('log function', () => { + // Save original console.log + const originalConsoleLog = console.log; - describe('readJSON function', () => { - test('should read and parse a valid JSON file', () => { - const testData = { key: 'value', nested: { prop: true } }; - fsReadFileSyncSpy.mockReturnValue(JSON.stringify(testData)); - - const result = readJSON('test.json'); - - expect(fsReadFileSyncSpy).toHaveBeenCalledWith('test.json', 'utf8'); - expect(result).toEqual(testData); - }); + beforeEach(() => { + // Mock console.log for each test + console.log = jest.fn(); + }); - test('should handle file not found errors', () => { - fsReadFileSyncSpy.mockImplementation(() => { - throw new Error('ENOENT: no such file or directory'); - }); - - // Mock console.error - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - const result = readJSON('nonexistent.json'); - - expect(result).toBeNull(); - - // Restore console.error - consoleSpy.mockRestore(); - }); + afterEach(() => { + // Restore original console.log after each test + console.log = originalConsoleLog; + }); - test('should handle invalid JSON format', () => { - fsReadFileSyncSpy.mockReturnValue('{ invalid json: }'); - - // Mock console.error - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - const result = readJSON('invalid.json'); - - expect(result).toBeNull(); - - // Restore console.error - consoleSpy.mockRestore(); - }); - }); + test('should log messages according to log level', () => { + // Test with info level (1) + CONFIG.logLevel = 'info'; - describe('writeJSON function', () => { - test('should write JSON data to a file', () => { - const testData = { key: 'value', nested: { prop: true } }; - - writeJSON('output.json', testData); - - expect(fsWriteFileSyncSpy).toHaveBeenCalledWith( - 'output.json', - JSON.stringify(testData, null, 2) - ); - }); + log('debug', 'Debug message'); + log('info', 'Info message'); + log('warn', 'Warning message'); + log('error', 'Error message'); - test('should handle file write errors', () => { - const testData = { key: 'value' }; - - fsWriteFileSyncSpy.mockImplementation(() => { - throw new Error('Permission denied'); - }); - - // Mock console.error - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - // Function shouldn't throw, just log error - expect(() => writeJSON('protected.json', testData)).not.toThrow(); - - // Restore console.error - consoleSpy.mockRestore(); - }); - }); + // Debug should not be logged (level 0 < 1) + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('Debug message') + ); - describe('sanitizePrompt function', () => { - test('should escape double quotes in prompts', () => { - const prompt = 'This is a "quoted" prompt with "multiple" quotes'; - const expected = 'This is a \\"quoted\\" prompt with \\"multiple\\" quotes'; - - expect(sanitizePrompt(prompt)).toBe(expected); - }); + // Info and above should be logged + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Info message') + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Warning message') + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Error message') + ); - test('should handle prompts with no special characters', () => { - const prompt = 'This is a regular prompt without quotes'; - - expect(sanitizePrompt(prompt)).toBe(prompt); - }); - - test('should handle empty strings', () => { - expect(sanitizePrompt('')).toBe(''); - }); - }); + // Verify the formatting includes icons + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('ℹ️')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('⚠️')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('❌')); + }); - describe('readComplexityReport function', () => { - test('should read and parse a valid complexity report', () => { - const testReport = { - meta: { generatedAt: new Date().toISOString() }, - complexityAnalysis: [{ taskId: 1, complexityScore: 7 }] - }; - - fsExistsSyncSpy.mockReturnValue(true); - fsReadFileSyncSpy.mockReturnValue(JSON.stringify(testReport)); - pathJoinSpy.mockReturnValue('/path/to/report.json'); - - const result = readComplexityReport(); - - expect(fsExistsSyncSpy).toHaveBeenCalled(); - expect(fsReadFileSyncSpy).toHaveBeenCalledWith('/path/to/report.json', 'utf8'); - expect(result).toEqual(testReport); - }); + test('should not log messages below the configured log level', () => { + // Set log level to error (3) + CONFIG.logLevel = 'error'; - test('should handle missing report file', () => { - fsExistsSyncSpy.mockReturnValue(false); - pathJoinSpy.mockReturnValue('/path/to/report.json'); - - const result = readComplexityReport(); - - expect(result).toBeNull(); - expect(fsReadFileSyncSpy).not.toHaveBeenCalled(); - }); + log('debug', 'Debug message'); + log('info', 'Info message'); + log('warn', 'Warning message'); + log('error', 'Error message'); - test('should handle custom report path', () => { - const testReport = { - meta: { generatedAt: new Date().toISOString() }, - complexityAnalysis: [{ taskId: 1, complexityScore: 7 }] - }; - - fsExistsSyncSpy.mockReturnValue(true); - fsReadFileSyncSpy.mockReturnValue(JSON.stringify(testReport)); - - const customPath = '/custom/path/report.json'; - const result = readComplexityReport(customPath); - - expect(fsExistsSyncSpy).toHaveBeenCalledWith(customPath); - expect(fsReadFileSyncSpy).toHaveBeenCalledWith(customPath, 'utf8'); - expect(result).toEqual(testReport); - }); - }); + // Only error should be logged + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('Debug message') + ); + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('Info message') + ); + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('Warning message') + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Error message') + ); + }); - describe('findTaskInComplexityReport function', () => { - test('should find a task by ID in a valid report', () => { - const testReport = { - complexityAnalysis: [ - { taskId: 1, complexityScore: 7 }, - { taskId: 2, complexityScore: 4 }, - { taskId: 3, complexityScore: 9 } - ] - }; - - const result = findTaskInComplexityReport(testReport, 2); - - expect(result).toEqual({ taskId: 2, complexityScore: 4 }); - }); + test('should join multiple arguments into a single message', () => { + CONFIG.logLevel = 'info'; + log('info', 'Message', 'with', 'multiple', 'parts'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Message with multiple parts') + ); + }); + }); - test('should return null for non-existent task ID', () => { - const testReport = { - complexityAnalysis: [ - { taskId: 1, complexityScore: 7 }, - { taskId: 2, complexityScore: 4 } - ] - }; - - const result = findTaskInComplexityReport(testReport, 99); - - // Fixing the expectation to match actual implementation - // The function might return null or undefined based on implementation - expect(result).toBeFalsy(); - }); + describe('readJSON function', () => { + test('should read and parse a valid JSON file', () => { + const testData = { key: 'value', nested: { prop: true } }; + fsReadFileSyncSpy.mockReturnValue(JSON.stringify(testData)); - test('should handle invalid report structure', () => { - // Test with null report - expect(findTaskInComplexityReport(null, 1)).toBeNull(); - - // Test with missing complexityAnalysis - expect(findTaskInComplexityReport({}, 1)).toBeNull(); - - // Test with non-array complexityAnalysis - expect(findTaskInComplexityReport({ complexityAnalysis: {} }, 1)).toBeNull(); - }); - }); + const result = readJSON('test.json'); - describe('taskExists function', () => { - const sampleTasks = [ - { id: 1, title: 'Task 1' }, - { id: 2, title: 'Task 2' }, - { - id: 3, - title: 'Task with subtasks', - subtasks: [ - { id: 1, title: 'Subtask 1' }, - { id: 2, title: 'Subtask 2' } - ] - } - ]; + expect(fsReadFileSyncSpy).toHaveBeenCalledWith('test.json', 'utf8'); + expect(result).toEqual(testData); + }); - test('should return true for existing task IDs', () => { - expect(taskExists(sampleTasks, 1)).toBe(true); - expect(taskExists(sampleTasks, 2)).toBe(true); - expect(taskExists(sampleTasks, '2')).toBe(true); // String ID should work too - }); + test('should handle file not found errors', () => { + fsReadFileSyncSpy.mockImplementation(() => { + throw new Error('ENOENT: no such file or directory'); + }); - test('should return true for existing subtask IDs', () => { - expect(taskExists(sampleTasks, '3.1')).toBe(true); - expect(taskExists(sampleTasks, '3.2')).toBe(true); - }); + // Mock console.error + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); - test('should return false for non-existent task IDs', () => { - expect(taskExists(sampleTasks, 99)).toBe(false); - expect(taskExists(sampleTasks, '99')).toBe(false); - }); - - test('should return false for non-existent subtask IDs', () => { - expect(taskExists(sampleTasks, '3.99')).toBe(false); - expect(taskExists(sampleTasks, '99.1')).toBe(false); - }); + const result = readJSON('nonexistent.json'); - test('should handle invalid inputs', () => { - expect(taskExists(null, 1)).toBe(false); - expect(taskExists(undefined, 1)).toBe(false); - expect(taskExists([], 1)).toBe(false); - expect(taskExists(sampleTasks, null)).toBe(false); - expect(taskExists(sampleTasks, undefined)).toBe(false); - }); - }); + expect(result).toBeNull(); - describe('formatTaskId function', () => { - test('should format numeric task IDs as strings', () => { - expect(formatTaskId(1)).toBe('1'); - expect(formatTaskId(42)).toBe('42'); - }); + // Restore console.error + consoleSpy.mockRestore(); + }); - test('should preserve string task IDs', () => { - expect(formatTaskId('1')).toBe('1'); - expect(formatTaskId('task-1')).toBe('task-1'); - }); + test('should handle invalid JSON format', () => { + fsReadFileSyncSpy.mockReturnValue('{ invalid json: }'); - test('should preserve dot notation for subtask IDs', () => { - expect(formatTaskId('1.2')).toBe('1.2'); - expect(formatTaskId('42.7')).toBe('42.7'); - }); - - test('should handle edge cases', () => { - // These should return as-is, though your implementation may differ - expect(formatTaskId(null)).toBe(null); - expect(formatTaskId(undefined)).toBe(undefined); - expect(formatTaskId('')).toBe(''); - }); - }); + // Mock console.error + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); - describe('findCycles function', () => { - test('should detect simple cycles in dependency graph', () => { - // A -> B -> A (cycle) - const dependencyMap = new Map([ - ['A', ['B']], - ['B', ['A']] - ]); - - const cycles = findCycles('A', dependencyMap); - - expect(cycles.length).toBeGreaterThan(0); - expect(cycles).toContain('A'); - }); + const result = readJSON('invalid.json'); - test('should detect complex cycles in dependency graph', () => { - // A -> B -> C -> A (cycle) - const dependencyMap = new Map([ - ['A', ['B']], - ['B', ['C']], - ['C', ['A']] - ]); - - const cycles = findCycles('A', dependencyMap); - - expect(cycles.length).toBeGreaterThan(0); - expect(cycles).toContain('A'); - }); + expect(result).toBeNull(); - test('should return empty array for acyclic graphs', () => { - // A -> B -> C (no cycle) - const dependencyMap = new Map([ - ['A', ['B']], - ['B', ['C']], - ['C', []] - ]); - - const cycles = findCycles('A', dependencyMap); - - expect(cycles.length).toBe(0); - }); + // Restore console.error + consoleSpy.mockRestore(); + }); + }); - test('should handle empty dependency maps', () => { - const dependencyMap = new Map(); - - const cycles = findCycles('A', dependencyMap); - - expect(cycles.length).toBe(0); - }); - - test('should handle nodes with no dependencies', () => { - const dependencyMap = new Map([ - ['A', []], - ['B', []], - ['C', []] - ]); - - const cycles = findCycles('A', dependencyMap); - - expect(cycles.length).toBe(0); - }); - - test('should identify the breaking edge in a cycle', () => { - // A -> B -> C -> D -> B (cycle) - const dependencyMap = new Map([ - ['A', ['B']], - ['B', ['C']], - ['C', ['D']], - ['D', ['B']] - ]); - - const cycles = findCycles('A', dependencyMap); - - expect(cycles).toContain('B'); - }); - }); + describe('writeJSON function', () => { + test('should write JSON data to a file', () => { + const testData = { key: 'value', nested: { prop: true } }; + + writeJSON('output.json', testData); + + expect(fsWriteFileSyncSpy).toHaveBeenCalledWith( + 'output.json', + JSON.stringify(testData, null, 2) + ); + }); + + test('should handle file write errors', () => { + const testData = { key: 'value' }; + + fsWriteFileSyncSpy.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + // Mock console.error + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // Function shouldn't throw, just log error + expect(() => writeJSON('protected.json', testData)).not.toThrow(); + + // Restore console.error + consoleSpy.mockRestore(); + }); + }); + + describe('sanitizePrompt function', () => { + test('should escape double quotes in prompts', () => { + const prompt = 'This is a "quoted" prompt with "multiple" quotes'; + const expected = + 'This is a \\"quoted\\" prompt with \\"multiple\\" quotes'; + + expect(sanitizePrompt(prompt)).toBe(expected); + }); + + test('should handle prompts with no special characters', () => { + const prompt = 'This is a regular prompt without quotes'; + + expect(sanitizePrompt(prompt)).toBe(prompt); + }); + + test('should handle empty strings', () => { + expect(sanitizePrompt('')).toBe(''); + }); + }); + + describe('readComplexityReport function', () => { + test('should read and parse a valid complexity report', () => { + const testReport = { + meta: { generatedAt: new Date().toISOString() }, + complexityAnalysis: [{ taskId: 1, complexityScore: 7 }] + }; + + fsExistsSyncSpy.mockReturnValue(true); + fsReadFileSyncSpy.mockReturnValue(JSON.stringify(testReport)); + pathJoinSpy.mockReturnValue('/path/to/report.json'); + + const result = readComplexityReport(); + + expect(fsExistsSyncSpy).toHaveBeenCalled(); + expect(fsReadFileSyncSpy).toHaveBeenCalledWith( + '/path/to/report.json', + 'utf8' + ); + expect(result).toEqual(testReport); + }); + + test('should handle missing report file', () => { + fsExistsSyncSpy.mockReturnValue(false); + pathJoinSpy.mockReturnValue('/path/to/report.json'); + + const result = readComplexityReport(); + + expect(result).toBeNull(); + expect(fsReadFileSyncSpy).not.toHaveBeenCalled(); + }); + + test('should handle custom report path', () => { + const testReport = { + meta: { generatedAt: new Date().toISOString() }, + complexityAnalysis: [{ taskId: 1, complexityScore: 7 }] + }; + + fsExistsSyncSpy.mockReturnValue(true); + fsReadFileSyncSpy.mockReturnValue(JSON.stringify(testReport)); + + const customPath = '/custom/path/report.json'; + const result = readComplexityReport(customPath); + + expect(fsExistsSyncSpy).toHaveBeenCalledWith(customPath); + expect(fsReadFileSyncSpy).toHaveBeenCalledWith(customPath, 'utf8'); + expect(result).toEqual(testReport); + }); + }); + + describe('findTaskInComplexityReport function', () => { + test('should find a task by ID in a valid report', () => { + const testReport = { + complexityAnalysis: [ + { taskId: 1, complexityScore: 7 }, + { taskId: 2, complexityScore: 4 }, + { taskId: 3, complexityScore: 9 } + ] + }; + + const result = findTaskInComplexityReport(testReport, 2); + + expect(result).toEqual({ taskId: 2, complexityScore: 4 }); + }); + + test('should return null for non-existent task ID', () => { + const testReport = { + complexityAnalysis: [ + { taskId: 1, complexityScore: 7 }, + { taskId: 2, complexityScore: 4 } + ] + }; + + const result = findTaskInComplexityReport(testReport, 99); + + // Fixing the expectation to match actual implementation + // The function might return null or undefined based on implementation + expect(result).toBeFalsy(); + }); + + test('should handle invalid report structure', () => { + // Test with null report + expect(findTaskInComplexityReport(null, 1)).toBeNull(); + + // Test with missing complexityAnalysis + expect(findTaskInComplexityReport({}, 1)).toBeNull(); + + // Test with non-array complexityAnalysis + expect( + findTaskInComplexityReport({ complexityAnalysis: {} }, 1) + ).toBeNull(); + }); + }); + + describe('taskExists function', () => { + const sampleTasks = [ + { id: 1, title: 'Task 1' }, + { id: 2, title: 'Task 2' }, + { + id: 3, + title: 'Task with subtasks', + subtasks: [ + { id: 1, title: 'Subtask 1' }, + { id: 2, title: 'Subtask 2' } + ] + } + ]; + + test('should return true for existing task IDs', () => { + expect(taskExists(sampleTasks, 1)).toBe(true); + expect(taskExists(sampleTasks, 2)).toBe(true); + expect(taskExists(sampleTasks, '2')).toBe(true); // String ID should work too + }); + + test('should return true for existing subtask IDs', () => { + expect(taskExists(sampleTasks, '3.1')).toBe(true); + expect(taskExists(sampleTasks, '3.2')).toBe(true); + }); + + test('should return false for non-existent task IDs', () => { + expect(taskExists(sampleTasks, 99)).toBe(false); + expect(taskExists(sampleTasks, '99')).toBe(false); + }); + + test('should return false for non-existent subtask IDs', () => { + expect(taskExists(sampleTasks, '3.99')).toBe(false); + expect(taskExists(sampleTasks, '99.1')).toBe(false); + }); + + test('should handle invalid inputs', () => { + expect(taskExists(null, 1)).toBe(false); + expect(taskExists(undefined, 1)).toBe(false); + expect(taskExists([], 1)).toBe(false); + expect(taskExists(sampleTasks, null)).toBe(false); + expect(taskExists(sampleTasks, undefined)).toBe(false); + }); + }); + + describe('formatTaskId function', () => { + test('should format numeric task IDs as strings', () => { + expect(formatTaskId(1)).toBe('1'); + expect(formatTaskId(42)).toBe('42'); + }); + + test('should preserve string task IDs', () => { + expect(formatTaskId('1')).toBe('1'); + expect(formatTaskId('task-1')).toBe('task-1'); + }); + + test('should preserve dot notation for subtask IDs', () => { + expect(formatTaskId('1.2')).toBe('1.2'); + expect(formatTaskId('42.7')).toBe('42.7'); + }); + + test('should handle edge cases', () => { + // These should return as-is, though your implementation may differ + expect(formatTaskId(null)).toBe(null); + expect(formatTaskId(undefined)).toBe(undefined); + expect(formatTaskId('')).toBe(''); + }); + }); + + describe('findCycles function', () => { + test('should detect simple cycles in dependency graph', () => { + // A -> B -> A (cycle) + const dependencyMap = new Map([ + ['A', ['B']], + ['B', ['A']] + ]); + + const cycles = findCycles('A', dependencyMap); + + expect(cycles.length).toBeGreaterThan(0); + expect(cycles).toContain('A'); + }); + + test('should detect complex cycles in dependency graph', () => { + // A -> B -> C -> A (cycle) + const dependencyMap = new Map([ + ['A', ['B']], + ['B', ['C']], + ['C', ['A']] + ]); + + const cycles = findCycles('A', dependencyMap); + + expect(cycles.length).toBeGreaterThan(0); + expect(cycles).toContain('A'); + }); + + test('should return empty array for acyclic graphs', () => { + // A -> B -> C (no cycle) + const dependencyMap = new Map([ + ['A', ['B']], + ['B', ['C']], + ['C', []] + ]); + + const cycles = findCycles('A', dependencyMap); + + expect(cycles.length).toBe(0); + }); + + test('should handle empty dependency maps', () => { + const dependencyMap = new Map(); + + const cycles = findCycles('A', dependencyMap); + + expect(cycles.length).toBe(0); + }); + + test('should handle nodes with no dependencies', () => { + const dependencyMap = new Map([ + ['A', []], + ['B', []], + ['C', []] + ]); + + const cycles = findCycles('A', dependencyMap); + + expect(cycles.length).toBe(0); + }); + + test('should identify the breaking edge in a cycle', () => { + // A -> B -> C -> D -> B (cycle) + const dependencyMap = new Map([ + ['A', ['B']], + ['B', ['C']], + ['C', ['D']], + ['D', ['B']] + ]); + + const cycles = findCycles('A', dependencyMap); + + expect(cycles).toContain('B'); + }); + }); }); describe('CLI Flag Format Validation', () => { - test('toKebabCase should convert camelCase to kebab-case', () => { - expect(toKebabCase('promptText')).toBe('prompt-text'); - expect(toKebabCase('userID')).toBe('user-id'); - expect(toKebabCase('numTasks')).toBe('num-tasks'); - expect(toKebabCase('alreadyKebabCase')).toBe('already-kebab-case'); - }); - - test('detectCamelCaseFlags should identify camelCase flags', () => { - const args = ['node', 'task-master', 'add-task', '--promptText=test', '--userID=123']; - const flags = testDetectCamelCaseFlags(args); - - expect(flags).toHaveLength(2); - expect(flags).toContainEqual({ - original: 'promptText', - kebabCase: 'prompt-text' - }); - expect(flags).toContainEqual({ - original: 'userID', - kebabCase: 'user-id' - }); - }); - - test('detectCamelCaseFlags should not flag kebab-case flags', () => { - const args = ['node', 'task-master', 'add-task', '--prompt-text=test', '--user-id=123']; - const flags = testDetectCamelCaseFlags(args); - - expect(flags).toHaveLength(0); - }); - - test('detectCamelCaseFlags should respect single-word flags', () => { - const args = ['node', 'task-master', 'add-task', '--prompt=test', '--file=test.json', '--priority=high', '--promptText=test']; - const flags = testDetectCamelCaseFlags(args); - - // Should only flag promptText, not the single-word flags - expect(flags).toHaveLength(1); - expect(flags).toContainEqual({ - original: 'promptText', - kebabCase: 'prompt-text' - }); - }); -}); \ No newline at end of file + test('toKebabCase should convert camelCase to kebab-case', () => { + expect(toKebabCase('promptText')).toBe('prompt-text'); + expect(toKebabCase('userID')).toBe('user-id'); + expect(toKebabCase('numTasks')).toBe('num-tasks'); + expect(toKebabCase('alreadyKebabCase')).toBe('already-kebab-case'); + }); + + test('detectCamelCaseFlags should identify camelCase flags', () => { + const args = [ + 'node', + 'task-master', + 'add-task', + '--promptText=test', + '--userID=123' + ]; + const flags = testDetectCamelCaseFlags(args); + + expect(flags).toHaveLength(2); + expect(flags).toContainEqual({ + original: 'promptText', + kebabCase: 'prompt-text' + }); + expect(flags).toContainEqual({ + original: 'userID', + kebabCase: 'user-id' + }); + }); + + test('detectCamelCaseFlags should not flag kebab-case flags', () => { + const args = [ + 'node', + 'task-master', + 'add-task', + '--prompt-text=test', + '--user-id=123' + ]; + const flags = testDetectCamelCaseFlags(args); + + expect(flags).toHaveLength(0); + }); + + test('detectCamelCaseFlags should respect single-word flags', () => { + const args = [ + 'node', + 'task-master', + 'add-task', + '--prompt=test', + '--file=test.json', + '--priority=high', + '--promptText=test' + ]; + const flags = testDetectCamelCaseFlags(args); + + // Should only flag promptText, not the single-word flags + expect(flags).toHaveLength(1); + expect(flags).toContainEqual({ + original: 'promptText', + kebabCase: 'prompt-text' + }); + }); +});