From d95aaf5316405bcce7797fc240fef775d06e46ef Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Wed, 9 Apr 2025 00:25:27 +0200 Subject: [PATCH] chore: run `npm run format` --- .cursor/mcp.json | 36 +- .github/workflows/release.yml | 2 +- .prettierrc | 18 +- README-task-master.md | 1 + README.md | 32 +- assets/scripts_README.md | 64 +- bin/task-master-init.js | 8 +- bin/task-master.js | 536 +- context/MCP_INTEGRATION.md | 258 +- context/mcp-protocol-schema-03262025.json | 4037 ++++--- docs/ai-client-utils-example.md | 363 +- docs/tutorial.md | 32 +- entries.json | 41 - index.js | 190 +- jest.config.js | 110 +- mcp-server/server.js | 38 +- .../core/__tests__/context-manager.test.js | 154 +- mcp-server/src/core/context-manager.js | 277 +- .../core/direct-functions/add-dependency.js | 143 +- .../src/core/direct-functions/add-subtask.js | 221 +- .../src/core/direct-functions/add-task.js | 313 +- .../analyze-task-complexity.js | 278 +- .../src/core/direct-functions/cache-stats.js | 36 +- .../core/direct-functions/clear-subtasks.js | 188 +- .../direct-functions/complexity-report.js | 218 +- .../core/direct-functions/expand-all-tasks.js | 198 +- .../src/core/direct-functions/expand-task.js | 474 +- .../core/direct-functions/fix-dependencies.js | 99 +- .../direct-functions/generate-task-files.js | 149 +- .../src/core/direct-functions/list-tasks.js | 161 +- .../src/core/direct-functions/next-task.js | 214 +- .../src/core/direct-functions/parse-prd.js | 290 +- .../direct-functions/remove-dependency.js | 141 +- .../core/direct-functions/remove-subtask.js | 161 +- .../src/core/direct-functions/remove-task.js | 179 +- .../core/direct-functions/set-task-status.js | 206 +- .../src/core/direct-functions/show-task.js | 235 +- .../direct-functions/update-subtask-by-id.js | 331 +- .../direct-functions/update-task-by-id.js | 323 +- .../src/core/direct-functions/update-tasks.js | 317 +- .../direct-functions/validate-dependencies.js | 99 +- mcp-server/src/core/task-master-core.js | 106 +- mcp-server/src/core/utils/ai-client-utils.js | 277 +- mcp-server/src/core/utils/async-manager.js | 428 +- mcp-server/src/core/utils/env-utils.js | 74 +- mcp-server/src/core/utils/path-utils.js | 409 +- mcp-server/src/index.js | 120 +- mcp-server/src/logger.js | 141 +- mcp-server/src/tools/add-dependency.js | 120 +- mcp-server/src/tools/add-subtask.js | 135 +- mcp-server/src/tools/add-task.js | 108 +- mcp-server/src/tools/analyze.js | 133 +- mcp-server/src/tools/clear-subtasks.js | 119 +- mcp-server/src/tools/complexity-report.js | 117 +- mcp-server/src/tools/expand-all.js | 124 +- mcp-server/src/tools/expand-task.js | 143 +- mcp-server/src/tools/fix-dependencies.js | 101 +- mcp-server/src/tools/generate.js | 113 +- mcp-server/src/tools/get-operation-status.js | 63 +- mcp-server/src/tools/get-task.js | 152 +- mcp-server/src/tools/get-tasks.js | 116 +- mcp-server/src/tools/index.js | 110 +- mcp-server/src/tools/initialize-project.js | 141 +- mcp-server/src/tools/next-task.js | 110 +- mcp-server/src/tools/parse-prd.js | 127 +- mcp-server/src/tools/remove-dependency.js | 109 +- mcp-server/src/tools/remove-subtask.js | 121 +- mcp-server/src/tools/remove-task.js | 128 +- mcp-server/src/tools/set-task-status.js | 127 +- mcp-server/src/tools/update-subtask.js | 116 +- mcp-server/src/tools/update-task.js | 116 +- mcp-server/src/tools/update.js | 120 +- mcp-server/src/tools/utils.js | 669 +- mcp-server/src/tools/validate-dependencies.js | 104 +- mcp-test.js | 108 +- output.json | 10 +- package.json | 190 +- scripts/README.md | 70 +- scripts/dev.js | 6 +- scripts/init.js | 1636 +-- scripts/modules/ai-services.js | 2013 ++-- scripts/modules/commands.js | 2735 +++-- scripts/modules/dependency-manager.js | 2368 +++-- scripts/modules/index.js | 2 +- scripts/modules/task-manager.js | 9256 +++++++++-------- scripts/modules/ui.js | 2767 ++--- scripts/modules/utils.js | 500 +- scripts/prepare-package.js | 296 +- scripts/task-complexity-report.json | 404 +- scripts/test-claude-errors.js | 200 +- scripts/test-claude.js | 249 +- test-version-check-full.js | 114 +- test-version-check.js | 25 +- tests/README.md | 2 +- tests/fixture/test-tasks.json | 26 +- tests/fixtures/sample-claude-response.js | 86 +- tests/fixtures/sample-tasks.js | 162 +- .../mcp-server/direct-functions.test.js | 1193 ++- tests/setup.js | 20 +- tests/unit/ai-client-utils.test.js | 556 +- tests/unit/ai-services.test.js | 498 +- tests/unit/commands.test.js | 1109 +- tests/unit/dependency-manager.test.js | 1401 +-- tests/unit/init.test.js | 735 +- tests/unit/kebab-case-validation.test.js | 224 +- tests/unit/task-finder.test.js | 72 +- tests/unit/task-manager.test.js | 5457 +++++----- tests/unit/ui.test.js | 398 +- tests/unit/utils.test.js | 1045 +- 109 files changed, 28144 insertions(+), 24157 deletions(-) delete mode 100644 entries.json diff --git a/.cursor/mcp.json b/.cursor/mcp.json index f9a2d82d..9e952651 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,20 +1,18 @@ { - "mcpServers": { - "taskmaster-ai": { - "command": "node", - "args": [ - "./mcp-server/server.js" - ], - "env": { - "ANTHROPIC_API_KEY": "YOUR_ANTHROPIC_API_KEY_HERE", - "PERPLEXITY_API_KEY": "YOUR_PERPLEXITY_API_KEY_HERE", - "MODEL": "claude-3-7-sonnet-20250219", - "PERPLEXITY_MODEL": "sonar-pro", - "MAX_TOKENS": 128000, - "TEMPERATURE": 0.2, - "DEFAULT_SUBTASKS": 5, - "DEFAULT_PRIORITY": "medium" - } - } - } -} \ No newline at end of file + "mcpServers": { + "taskmaster-ai": { + "command": "node", + "args": ["./mcp-server/server.js"], + "env": { + "ANTHROPIC_API_KEY": "YOUR_ANTHROPIC_API_KEY_HERE", + "PERPLEXITY_API_KEY": "YOUR_PERPLEXITY_API_KEY_HERE", + "MODEL": "claude-3-7-sonnet-20250219", + "PERPLEXITY_MODEL": "sonar-pro", + "MAX_TOKENS": 128000, + "TEMPERATURE": 0.2, + "DEFAULT_SUBTASKS": 5, + "DEFAULT_PRIORITY": "medium" + } + } + } +} 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/.prettierrc b/.prettierrc index 764c3030..936afdfb 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,11 +1,11 @@ { - "printWidth": 80, - "tabWidth": 2, - "useTabs": true, - "semi": true, - "singleQuote": true, - "trailingComma": "none", - "bracketSpacing": true, - "arrowParens": "always", - "endOfLine": "lf" + "printWidth": 80, + "tabWidth": 2, + "useTabs": true, + "semi": true, + "singleQuote": true, + "trailingComma": "none", + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" } diff --git a/README-task-master.md b/README-task-master.md index 4f3e3154..61da5036 100644 --- a/README-task-master.md +++ b/README-task-master.md @@ -58,6 +58,7 @@ This will prompt you for project details and set up a new project with the neces ### Important Notes 1. **ES Modules Configuration:** + - This project uses ES Modules (ESM) instead of CommonJS. - This is set via `"type": "module"` in your package.json. - Use `import/export` syntax instead of `require()`. diff --git a/README.md b/README.md index d6551635..6df8b17d 100644 --- a/README.md +++ b/README.md @@ -26,22 +26,22 @@ MCP (Model Control Protocol) provides the easiest way to get started with Task M ```json { - "mcpServers": { - "taskmaster-ai": { - "command": "npx", - "args": ["-y", "task-master-ai", "mcp-server"], - "env": { - "ANTHROPIC_API_KEY": "YOUR_ANTHROPIC_API_KEY_HERE", - "PERPLEXITY_API_KEY": "YOUR_PERPLEXITY_API_KEY_HERE", - "MODEL": "claude-3-7-sonnet-20250219", - "PERPLEXITY_MODEL": "sonar-pro", - "MAX_TOKENS": 128000, - "TEMPERATURE": 0.2, - "DEFAULT_SUBTASKS": 5, - "DEFAULT_PRIORITY": "medium" - } - } - } + "mcpServers": { + "taskmaster-ai": { + "command": "npx", + "args": ["-y", "task-master-ai", "mcp-server"], + "env": { + "ANTHROPIC_API_KEY": "YOUR_ANTHROPIC_API_KEY_HERE", + "PERPLEXITY_API_KEY": "YOUR_PERPLEXITY_API_KEY_HERE", + "MODEL": "claude-3-7-sonnet-20250219", + "PERPLEXITY_MODEL": "sonar-pro", + "MAX_TOKENS": 128000, + "TEMPERATURE": 0.2, + "DEFAULT_SUBTASKS": 5, + "DEFAULT_PRIORITY": "medium" + } + } + } } ``` 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/context/MCP_INTEGRATION.md b/context/MCP_INTEGRATION.md index e1212841..7cf2b023 100644 --- a/context/MCP_INTEGRATION.md +++ b/context/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/context/mcp-protocol-schema-03262025.json b/context/mcp-protocol-schema-03262025.json index e7f730e5..0cf54b38 100644 --- a/context/mcp-protocol-schema-03262025.json +++ b/context/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/docs/ai-client-utils-example.md b/docs/ai-client-utils-example.md index aa8ea8be..cb87968b 100644 --- a/docs/ai-client-utils-example.md +++ b/docs/ai-client-utils-example.md @@ -6,57 +6,55 @@ This document provides examples of how to use the new AI client utilities with A ```javascript // In your direct function implementation: -import { - getAnthropicClientForMCP, - getModelConfig, - handleClaudeError +import { + getAnthropicClientForMCP, + getModelConfig, + handleClaudeError } from '../utils/ai-client-utils.js'; export async function someAiOperationDirect(args, log, context) { - try { - // Initialize Anthropic client with session from context - const client = getAnthropicClientForMCP(context.session, log); - - // Get model configuration with defaults or session overrides - const modelConfig = getModelConfig(context.session); - - // Make API call with proper error handling - try { - const response = await client.messages.create({ - model: modelConfig.model, - max_tokens: modelConfig.maxTokens, - temperature: modelConfig.temperature, - messages: [ - { role: 'user', content: 'Your prompt here' } - ] - }); - - return { - success: true, - data: response - }; - } catch (apiError) { - // Use helper to get user-friendly error message - const friendlyMessage = handleClaudeError(apiError); - - return { - success: false, - error: { - code: 'AI_API_ERROR', - message: friendlyMessage - } - }; - } - } catch (error) { - // Handle client initialization errors - return { - success: false, - error: { - code: 'AI_CLIENT_ERROR', - message: error.message - } - }; - } + try { + // Initialize Anthropic client with session from context + const client = getAnthropicClientForMCP(context.session, log); + + // Get model configuration with defaults or session overrides + const modelConfig = getModelConfig(context.session); + + // Make API call with proper error handling + try { + const response = await client.messages.create({ + model: modelConfig.model, + max_tokens: modelConfig.maxTokens, + temperature: modelConfig.temperature, + messages: [{ role: 'user', content: 'Your prompt here' }] + }); + + return { + success: true, + data: response + }; + } catch (apiError) { + // Use helper to get user-friendly error message + const friendlyMessage = handleClaudeError(apiError); + + return { + success: false, + error: { + code: 'AI_API_ERROR', + message: friendlyMessage + } + }; + } + } catch (error) { + // Handle client initialization errors + return { + success: false, + error: { + code: 'AI_CLIENT_ERROR', + message: error.message + } + }; + } } ``` @@ -64,86 +62,85 @@ export async function someAiOperationDirect(args, log, context) { ```javascript // In your MCP tool implementation: -import { AsyncOperationManager, StatusCodes } from '../../utils/async-operation-manager.js'; +import { + AsyncOperationManager, + StatusCodes +} from '../../utils/async-operation-manager.js'; import { someAiOperationDirect } from '../../core/direct-functions/some-ai-operation.js'; export async function someAiOperation(args, context) { - const { session, mcpLog } = context; - const log = mcpLog || console; - - try { - // Create operation description - const operationDescription = `AI operation: ${args.someParam}`; - - // Start async operation - const operation = AsyncOperationManager.createOperation( - operationDescription, - async (reportProgress) => { - try { - // Initial progress report - reportProgress({ - progress: 0, - status: 'Starting AI operation...' - }); - - // Call direct function with session and progress reporting - const result = await someAiOperationDirect( - args, - log, - { - reportProgress, - mcpLog: log, - session - } - ); - - // Final progress update - reportProgress({ - progress: 100, - status: result.success ? 'Operation completed' : 'Operation failed', - result: result.data, - error: result.error - }); - - return result; - } catch (error) { - // Handle errors in the operation - reportProgress({ - progress: 100, - status: 'Operation failed', - error: { - message: error.message, - code: error.code || 'OPERATION_FAILED' - } - }); - throw error; - } - } - ); - - // Return immediate response with operation ID - return { - status: StatusCodes.ACCEPTED, - body: { - success: true, - message: 'Operation started', - operationId: operation.id - } - }; - } catch (error) { - // Handle errors in the MCP tool - log.error(`Error in someAiOperation: ${error.message}`); - return { - status: StatusCodes.INTERNAL_SERVER_ERROR, - body: { - success: false, - error: { - code: 'OPERATION_FAILED', - message: error.message - } - } - }; - } + const { session, mcpLog } = context; + const log = mcpLog || console; + + try { + // Create operation description + const operationDescription = `AI operation: ${args.someParam}`; + + // Start async operation + const operation = AsyncOperationManager.createOperation( + operationDescription, + async (reportProgress) => { + try { + // Initial progress report + reportProgress({ + progress: 0, + status: 'Starting AI operation...' + }); + + // Call direct function with session and progress reporting + const result = await someAiOperationDirect(args, log, { + reportProgress, + mcpLog: log, + session + }); + + // Final progress update + reportProgress({ + progress: 100, + status: result.success ? 'Operation completed' : 'Operation failed', + result: result.data, + error: result.error + }); + + return result; + } catch (error) { + // Handle errors in the operation + reportProgress({ + progress: 100, + status: 'Operation failed', + error: { + message: error.message, + code: error.code || 'OPERATION_FAILED' + } + }); + throw error; + } + } + ); + + // Return immediate response with operation ID + return { + status: StatusCodes.ACCEPTED, + body: { + success: true, + message: 'Operation started', + operationId: operation.id + } + }; + } catch (error) { + // Handle errors in the MCP tool + log.error(`Error in someAiOperation: ${error.message}`); + return { + status: StatusCodes.INTERNAL_SERVER_ERROR, + body: { + success: false, + error: { + code: 'OPERATION_FAILED', + message: error.message + } + } + }; + } } ``` @@ -151,58 +148,56 @@ export async function someAiOperation(args, context) { ```javascript // In your direct function: -import { - getPerplexityClientForMCP, - getBestAvailableAIModel +import { + getPerplexityClientForMCP, + getBestAvailableAIModel } from '../utils/ai-client-utils.js'; export async function researchOperationDirect(args, log, context) { - try { - // Get the best AI model for this operation based on needs - const { type, client } = await getBestAvailableAIModel( - context.session, - { requiresResearch: true }, - log - ); - - // Report which model we're using - if (context.reportProgress) { - await context.reportProgress({ - progress: 10, - status: `Using ${type} model for research...` - }); - } - - // Make API call based on the model type - if (type === 'perplexity') { - // Call Perplexity - const response = await client.chat.completions.create({ - model: context.session?.env?.PERPLEXITY_MODEL || 'sonar-medium-online', - messages: [ - { role: 'user', content: args.researchQuery } - ], - temperature: 0.1 - }); - - return { - success: true, - data: response.choices[0].message.content - }; - } else { - // Call Claude as fallback - // (Implementation depends on specific needs) - // ... - } - } catch (error) { - // Handle errors - return { - success: false, - error: { - code: 'RESEARCH_ERROR', - message: error.message - } - }; - } + try { + // Get the best AI model for this operation based on needs + const { type, client } = await getBestAvailableAIModel( + context.session, + { requiresResearch: true }, + log + ); + + // Report which model we're using + if (context.reportProgress) { + await context.reportProgress({ + progress: 10, + status: `Using ${type} model for research...` + }); + } + + // Make API call based on the model type + if (type === 'perplexity') { + // Call Perplexity + const response = await client.chat.completions.create({ + model: context.session?.env?.PERPLEXITY_MODEL || 'sonar-medium-online', + messages: [{ role: 'user', content: args.researchQuery }], + temperature: 0.1 + }); + + return { + success: true, + data: response.choices[0].message.content + }; + } else { + // Call Claude as fallback + // (Implementation depends on specific needs) + // ... + } + } catch (error) { + // Handle errors + return { + success: false, + error: { + code: 'RESEARCH_ERROR', + message: error.message + } + }; + } } ``` @@ -214,9 +209,9 @@ import { getModelConfig } from '../utils/ai-client-utils.js'; // Using custom defaults for a specific operation const operationDefaults = { - model: 'claude-3-haiku-20240307', // Faster, smaller model - maxTokens: 1000, // Lower token limit - temperature: 0.2 // Lower temperature for more deterministic output + model: 'claude-3-haiku-20240307', // Faster, smaller model + maxTokens: 1000, // Lower token limit + temperature: 0.2 // Lower temperature for more deterministic output }; // Get model config with operation-specific defaults @@ -224,30 +219,34 @@ const modelConfig = getModelConfig(context.session, operationDefaults); // Now use modelConfig in your API calls const response = await client.messages.create({ - model: modelConfig.model, - max_tokens: modelConfig.maxTokens, - temperature: modelConfig.temperature, - // Other parameters... + model: modelConfig.model, + max_tokens: modelConfig.maxTokens, + temperature: modelConfig.temperature + // Other parameters... }); ``` ## Best Practices 1. **Error Handling**: + - Always use try/catch blocks around both client initialization and API calls - Use `handleClaudeError` to provide user-friendly error messages - Return standardized error objects with code and message 2. **Progress Reporting**: + - Report progress at key points (starting, processing, completing) - Include meaningful status messages - Include error details in progress reports when failures occur 3. **Session Handling**: + - Always pass the session from the context to the AI client getters - Use `getModelConfig` to respect user settings from session 4. **Model Selection**: + - Use `getBestAvailableAIModel` when you need to select between different models - Set `requiresResearch: true` when you need Perplexity capabilities @@ -255,4 +254,4 @@ const response = await client.messages.create({ - Create descriptive operation names - Handle all errors within the operation function - Return standardized results from direct functions - - Return immediate responses with operation IDs \ No newline at end of file + - Return immediate responses with operation IDs diff --git a/docs/tutorial.md b/docs/tutorial.md index ba08a099..e0e7079c 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -14,22 +14,22 @@ MCP (Model Control Protocol) provides the easiest way to get started with Task M ```json { - "mcpServers": { - "taskmaster-ai": { - "command": "npx", - "args": ["-y", "task-master-ai", "mcp-server"], - "env": { - "ANTHROPIC_API_KEY": "YOUR_ANTHROPIC_API_KEY_HERE", - "PERPLEXITY_API_KEY": "YOUR_PERPLEXITY_API_KEY_HERE", - "MODEL": "claude-3-7-sonnet-20250219", - "PERPLEXITY_MODEL": "sonar-pro", - "MAX_TOKENS": 128000, - "TEMPERATURE": 0.2, - "DEFAULT_SUBTASKS": 5, - "DEFAULT_PRIORITY": "medium" - } - } - } + "mcpServers": { + "taskmaster-ai": { + "command": "npx", + "args": ["-y", "task-master-ai", "mcp-server"], + "env": { + "ANTHROPIC_API_KEY": "YOUR_ANTHROPIC_API_KEY_HERE", + "PERPLEXITY_API_KEY": "YOUR_PERPLEXITY_API_KEY_HERE", + "MODEL": "claude-3-7-sonnet-20250219", + "PERPLEXITY_MODEL": "sonar-pro", + "MAX_TOKENS": 128000, + "TEMPERATURE": 0.2, + "DEFAULT_SUBTASKS": 5, + "DEFAULT_PRIORITY": "medium" + } + } + } } ``` diff --git a/entries.json b/entries.json deleted file mode 100644 index b544b39f..00000000 --- a/entries.json +++ /dev/null @@ -1,41 +0,0 @@ -import os -import json - -# Path to Cursor's history folder -history_path = os.path.expanduser('~/Library/Application Support/Cursor/User/History') - -# File to search for -target_file = 'tasks/tasks.json' - -# Function to search through all entries.json files -def search_entries_for_file(history_path, target_file): - matching_folders = [] - for folder in os.listdir(history_path): - folder_path = os.path.join(history_path, folder) - if not os.path.isdir(folder_path): - continue - - # Look for entries.json - entries_file = os.path.join(folder_path, 'entries.json') - if not os.path.exists(entries_file): - continue - - # Parse entries.json to find the resource key - with open(entries_file, 'r') as f: - data = json.load(f) - resource = data.get('resource', None) - if resource and target_file in resource: - matching_folders.append(folder_path) - - return matching_folders - -# Search for the target file -matching_folders = search_entries_for_file(history_path, target_file) - -# Output the matching folders -if matching_folders: - print(f"Found {target_file} in the following folders:") - for folder in matching_folders: - print(folder) -else: - print(f"No matches found for {target_file}.") 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/direct-functions/add-dependency.js b/mcp-server/src/core/direct-functions/add-dependency.js index aa995391..9a34a0ae 100644 --- a/mcp-server/src/core/direct-functions/add-dependency.js +++ b/mcp-server/src/core/direct-functions/add-dependency.js @@ -5,11 +5,14 @@ import { addDependency } from '../../../../scripts/modules/dependency-manager.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; /** * Direct function wrapper for addDependency with error handling. - * + * * @param {Object} args - Command arguments * @param {string|number} args.id - Task ID to add dependency to * @param {string|number} args.dependsOn - Task ID that will become a dependency @@ -19,67 +22,75 @@ import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules * @returns {Promise} - Result object with success status and data/error information */ export async function addDependencyDirect(args, log) { - try { - log.info(`Adding dependency with args: ${JSON.stringify(args)}`); - - // Validate required parameters - if (!args.id) { - return { - success: false, - error: { - code: 'INPUT_VALIDATION_ERROR', - message: 'Task ID (id) is required' - } - }; - } - - if (!args.dependsOn) { - return { - success: false, - error: { - code: 'INPUT_VALIDATION_ERROR', - message: 'Dependency ID (dependsOn) is required' - } - }; - } - - // Find the tasks.json path - const tasksPath = findTasksJsonPath(args, log); - - // Format IDs for the core function - const taskId = args.id.includes && args.id.includes('.') ? args.id : parseInt(args.id, 10); - const dependencyId = args.dependsOn.includes && args.dependsOn.includes('.') ? args.dependsOn : parseInt(args.dependsOn, 10); - - log.info(`Adding dependency: task ${taskId} will depend on ${dependencyId}`); - - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - // Call the core function - await addDependency(tasksPath, taskId, dependencyId); - - // Restore normal logging - disableSilentMode(); - - return { - success: true, - data: { - message: `Successfully added dependency: Task ${taskId} now depends on ${dependencyId}`, - taskId: taskId, - dependencyId: dependencyId - } - }; - } catch (error) { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - - log.error(`Error in addDependencyDirect: ${error.message}`); - return { - success: false, - error: { - code: 'CORE_FUNCTION_ERROR', - message: error.message - } - }; - } -} \ No newline at end of file + try { + log.info(`Adding dependency with args: ${JSON.stringify(args)}`); + + // Validate required parameters + if (!args.id) { + return { + success: false, + error: { + code: 'INPUT_VALIDATION_ERROR', + message: 'Task ID (id) is required' + } + }; + } + + if (!args.dependsOn) { + return { + success: false, + error: { + code: 'INPUT_VALIDATION_ERROR', + message: 'Dependency ID (dependsOn) is required' + } + }; + } + + // Find the tasks.json path + const tasksPath = findTasksJsonPath(args, log); + + // Format IDs for the core function + const taskId = + args.id.includes && args.id.includes('.') + ? args.id + : parseInt(args.id, 10); + const dependencyId = + args.dependsOn.includes && args.dependsOn.includes('.') + ? args.dependsOn + : parseInt(args.dependsOn, 10); + + log.info( + `Adding dependency: task ${taskId} will depend on ${dependencyId}` + ); + + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); + + // Call the core function + await addDependency(tasksPath, taskId, dependencyId); + + // Restore normal logging + disableSilentMode(); + + return { + success: true, + data: { + message: `Successfully added dependency: Task ${taskId} now depends on ${dependencyId}`, + taskId: taskId, + dependencyId: dependencyId + } + }; + } catch (error) { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + + log.error(`Error in addDependencyDirect: ${error.message}`); + return { + success: false, + error: { + code: 'CORE_FUNCTION_ERROR', + message: error.message + } + }; + } +} diff --git a/mcp-server/src/core/direct-functions/add-subtask.js b/mcp-server/src/core/direct-functions/add-subtask.js index c0c041c1..67b2283e 100644 --- a/mcp-server/src/core/direct-functions/add-subtask.js +++ b/mcp-server/src/core/direct-functions/add-subtask.js @@ -4,7 +4,10 @@ import { addSubtask } from '../../../../scripts/modules/task-manager.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; /** * Add a subtask to an existing task @@ -23,106 +26,118 @@ import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules * @returns {Promise<{success: boolean, data?: Object, error?: string}>} */ export async function addSubtaskDirect(args, log) { - try { - log.info(`Adding subtask with args: ${JSON.stringify(args)}`); - - if (!args.id) { - return { - success: false, - error: { - code: 'INPUT_VALIDATION_ERROR', - message: 'Parent task ID is required' - } - }; - } - - // Either taskId or title must be provided - if (!args.taskId && !args.title) { - return { - success: false, - error: { - code: 'INPUT_VALIDATION_ERROR', - message: 'Either taskId or title must be provided' - } - }; - } + try { + log.info(`Adding subtask with args: ${JSON.stringify(args)}`); - // Find the tasks.json path - const tasksPath = findTasksJsonPath(args, log); - - // Parse dependencies if provided - let dependencies = []; - if (args.dependencies) { - dependencies = args.dependencies.split(',').map(id => { - // Handle both regular IDs and dot notation - return id.includes('.') ? id.trim() : parseInt(id.trim(), 10); - }); - } - - // Convert existingTaskId to a number if provided - const existingTaskId = args.taskId ? parseInt(args.taskId, 10) : null; - - // Convert parent ID to a number - const parentId = parseInt(args.id, 10); - - // Determine if we should generate files - const generateFiles = !args.skipGenerate; - - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - // Case 1: Convert existing task to subtask - if (existingTaskId) { - log.info(`Converting task ${existingTaskId} to a subtask of ${parentId}`); - const result = await addSubtask(tasksPath, parentId, existingTaskId, null, generateFiles); - - // Restore normal logging - disableSilentMode(); - - return { - success: true, - data: { - message: `Task ${existingTaskId} successfully converted to a subtask of task ${parentId}`, - subtask: result - } - }; - } - // Case 2: Create new subtask - else { - log.info(`Creating new subtask for parent task ${parentId}`); - - const newSubtaskData = { - title: args.title, - description: args.description || '', - details: args.details || '', - status: args.status || 'pending', - dependencies: dependencies - }; - - const result = await addSubtask(tasksPath, parentId, null, newSubtaskData, generateFiles); - - // Restore normal logging - disableSilentMode(); - - return { - success: true, - data: { - message: `New subtask ${parentId}.${result.id} successfully created`, - subtask: result - } - }; - } - } catch (error) { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - - log.error(`Error in addSubtaskDirect: ${error.message}`); - return { - success: false, - error: { - code: 'CORE_FUNCTION_ERROR', - message: error.message - } - }; - } -} \ No newline at end of file + if (!args.id) { + return { + success: false, + error: { + code: 'INPUT_VALIDATION_ERROR', + message: 'Parent task ID is required' + } + }; + } + + // Either taskId or title must be provided + if (!args.taskId && !args.title) { + return { + success: false, + error: { + code: 'INPUT_VALIDATION_ERROR', + message: 'Either taskId or title must be provided' + } + }; + } + + // Find the tasks.json path + const tasksPath = findTasksJsonPath(args, log); + + // Parse dependencies if provided + let dependencies = []; + if (args.dependencies) { + dependencies = args.dependencies.split(',').map((id) => { + // Handle both regular IDs and dot notation + return id.includes('.') ? id.trim() : parseInt(id.trim(), 10); + }); + } + + // Convert existingTaskId to a number if provided + const existingTaskId = args.taskId ? parseInt(args.taskId, 10) : null; + + // Convert parent ID to a number + const parentId = parseInt(args.id, 10); + + // Determine if we should generate files + const generateFiles = !args.skipGenerate; + + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); + + // Case 1: Convert existing task to subtask + if (existingTaskId) { + log.info(`Converting task ${existingTaskId} to a subtask of ${parentId}`); + const result = await addSubtask( + tasksPath, + parentId, + existingTaskId, + null, + generateFiles + ); + + // Restore normal logging + disableSilentMode(); + + return { + success: true, + data: { + message: `Task ${existingTaskId} successfully converted to a subtask of task ${parentId}`, + subtask: result + } + }; + } + // Case 2: Create new subtask + else { + log.info(`Creating new subtask for parent task ${parentId}`); + + const newSubtaskData = { + title: args.title, + description: args.description || '', + details: args.details || '', + status: args.status || 'pending', + dependencies: dependencies + }; + + const result = await addSubtask( + tasksPath, + parentId, + null, + newSubtaskData, + generateFiles + ); + + // Restore normal logging + disableSilentMode(); + + return { + success: true, + data: { + message: `New subtask ${parentId}.${result.id} successfully created`, + subtask: result + } + }; + } + } catch (error) { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + + log.error(`Error in addSubtaskDirect: ${error.message}`); + return { + success: false, + error: { + code: 'CORE_FUNCTION_ERROR', + message: error.message + } + }; + } +} diff --git a/mcp-server/src/core/direct-functions/add-task.js b/mcp-server/src/core/direct-functions/add-task.js index c8c67c12..5ef48aa9 100644 --- a/mcp-server/src/core/direct-functions/add-task.js +++ b/mcp-server/src/core/direct-functions/add-task.js @@ -5,9 +5,19 @@ import { addTask } from '../../../../scripts/modules/task-manager.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; -import { getAnthropicClientForMCP, getModelConfig } from '../utils/ai-client-utils.js'; -import { _buildAddTaskPrompt, parseTaskJsonResponse, _handleAnthropicStream } from '../../../../scripts/modules/ai-services.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; +import { + getAnthropicClientForMCP, + getModelConfig +} from '../utils/ai-client-utils.js'; +import { + _buildAddTaskPrompt, + parseTaskJsonResponse, + _handleAnthropicStream +} from '../../../../scripts/modules/ai-services.js'; /** * Direct function wrapper for adding a new task with error handling. @@ -24,153 +34,162 @@ import { _buildAddTaskPrompt, parseTaskJsonResponse, _handleAnthropicStream } fr * @returns {Promise} - Result object { success: boolean, data?: any, error?: { code: string, message: string } } */ export async function addTaskDirect(args, log, context = {}) { - try { - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - // Find the tasks.json path - const tasksPath = findTasksJsonPath(args, log); - - // Check required parameters - if (!args.prompt) { - log.error('Missing required parameter: prompt'); - disableSilentMode(); - return { - success: false, - error: { - code: 'MISSING_PARAMETER', - message: 'The prompt parameter is required for adding a task' - } - }; - } - - // Extract and prepare parameters - const prompt = args.prompt; - const dependencies = Array.isArray(args.dependencies) - ? args.dependencies - : (args.dependencies ? String(args.dependencies).split(',').map(id => parseInt(id.trim(), 10)) : []); - const priority = args.priority || 'medium'; - - log.info(`Adding new task with prompt: "${prompt}", dependencies: [${dependencies.join(', ')}], priority: ${priority}`); - - // Extract context parameters for advanced functionality - // Commenting out reportProgress extraction - // const { reportProgress, session } = context; - const { session } = context; // Keep session + try { + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); - // Initialize AI client with session environment - let localAnthropic; - try { - localAnthropic = getAnthropicClientForMCP(session, log); - } catch (error) { - log.error(`Failed to initialize Anthropic client: ${error.message}`); - disableSilentMode(); - return { - success: false, - error: { - code: 'AI_CLIENT_ERROR', - message: `Cannot initialize AI client: ${error.message}` - } - }; - } + // Find the tasks.json path + const tasksPath = findTasksJsonPath(args, log); - // Get model configuration from session - const modelConfig = getModelConfig(session); + // Check required parameters + if (!args.prompt) { + log.error('Missing required parameter: prompt'); + disableSilentMode(); + return { + success: false, + error: { + code: 'MISSING_PARAMETER', + message: 'The prompt parameter is required for adding a task' + } + }; + } - // Read existing tasks to provide context - let tasksData; - try { - const fs = await import('fs'); - tasksData = JSON.parse(fs.readFileSync(tasksPath, 'utf8')); - } catch (error) { - log.warn(`Could not read existing tasks for context: ${error.message}`); - tasksData = { tasks: [] }; - } + // Extract and prepare parameters + const prompt = args.prompt; + const dependencies = Array.isArray(args.dependencies) + ? args.dependencies + : args.dependencies + ? String(args.dependencies) + .split(',') + .map((id) => parseInt(id.trim(), 10)) + : []; + const priority = args.priority || 'medium'; - // Build prompts for AI - const { systemPrompt, userPrompt } = _buildAddTaskPrompt(prompt, tasksData.tasks); + log.info( + `Adding new task with prompt: "${prompt}", dependencies: [${dependencies.join(', ')}], priority: ${priority}` + ); - // Make the AI call using the streaming helper - let responseText; - try { - responseText = await _handleAnthropicStream( - localAnthropic, - { - model: modelConfig.model, - max_tokens: modelConfig.maxTokens, - temperature: modelConfig.temperature, - messages: [{ role: "user", content: userPrompt }], - system: systemPrompt - }, - { - // reportProgress: context.reportProgress, // Commented out to prevent Cursor stroking out - mcpLog: log - } - ); - } catch (error) { - log.error(`AI processing failed: ${error.message}`); - disableSilentMode(); - return { - success: false, - error: { - code: 'AI_PROCESSING_ERROR', - message: `Failed to generate task with AI: ${error.message}` - } - }; - } + // Extract context parameters for advanced functionality + // Commenting out reportProgress extraction + // const { reportProgress, session } = context; + const { session } = context; // Keep session - // Parse the AI response - let taskDataFromAI; - try { - taskDataFromAI = parseTaskJsonResponse(responseText); - } catch (error) { - log.error(`Failed to parse AI response: ${error.message}`); - disableSilentMode(); - return { - success: false, - error: { - code: 'RESPONSE_PARSING_ERROR', - message: `Failed to parse AI response: ${error.message}` - } - }; - } - - // Call the addTask function with 'json' outputFormat to prevent console output when called via MCP - const newTaskId = await addTask( - tasksPath, - prompt, - dependencies, - priority, - { - // reportProgress, // Commented out - mcpLog: log, - session, - taskDataFromAI // Pass the parsed AI result - }, - 'json' - ); - - // Restore normal logging - disableSilentMode(); - - return { - success: true, - data: { - taskId: newTaskId, - message: `Successfully added new task #${newTaskId}` - } - }; - } catch (error) { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - - log.error(`Error in addTaskDirect: ${error.message}`); - return { - success: false, - error: { - code: 'ADD_TASK_ERROR', - message: error.message - } - }; - } -} \ No newline at end of file + // Initialize AI client with session environment + let localAnthropic; + try { + localAnthropic = getAnthropicClientForMCP(session, log); + } catch (error) { + log.error(`Failed to initialize Anthropic client: ${error.message}`); + disableSilentMode(); + return { + success: false, + error: { + code: 'AI_CLIENT_ERROR', + message: `Cannot initialize AI client: ${error.message}` + } + }; + } + + // Get model configuration from session + const modelConfig = getModelConfig(session); + + // Read existing tasks to provide context + let tasksData; + try { + const fs = await import('fs'); + tasksData = JSON.parse(fs.readFileSync(tasksPath, 'utf8')); + } catch (error) { + log.warn(`Could not read existing tasks for context: ${error.message}`); + tasksData = { tasks: [] }; + } + + // Build prompts for AI + const { systemPrompt, userPrompt } = _buildAddTaskPrompt( + prompt, + tasksData.tasks + ); + + // Make the AI call using the streaming helper + let responseText; + try { + responseText = await _handleAnthropicStream( + localAnthropic, + { + model: modelConfig.model, + max_tokens: modelConfig.maxTokens, + temperature: modelConfig.temperature, + messages: [{ role: 'user', content: userPrompt }], + system: systemPrompt + }, + { + // reportProgress: context.reportProgress, // Commented out to prevent Cursor stroking out + mcpLog: log + } + ); + } catch (error) { + log.error(`AI processing failed: ${error.message}`); + disableSilentMode(); + return { + success: false, + error: { + code: 'AI_PROCESSING_ERROR', + message: `Failed to generate task with AI: ${error.message}` + } + }; + } + + // Parse the AI response + let taskDataFromAI; + try { + taskDataFromAI = parseTaskJsonResponse(responseText); + } catch (error) { + log.error(`Failed to parse AI response: ${error.message}`); + disableSilentMode(); + return { + success: false, + error: { + code: 'RESPONSE_PARSING_ERROR', + message: `Failed to parse AI response: ${error.message}` + } + }; + } + + // Call the addTask function with 'json' outputFormat to prevent console output when called via MCP + const newTaskId = await addTask( + tasksPath, + prompt, + dependencies, + priority, + { + // reportProgress, // Commented out + mcpLog: log, + session, + taskDataFromAI // Pass the parsed AI result + }, + 'json' + ); + + // Restore normal logging + disableSilentMode(); + + return { + success: true, + data: { + taskId: newTaskId, + message: `Successfully added new task #${newTaskId}` + } + }; + } catch (error) { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + + log.error(`Error in addTaskDirect: ${error.message}`); + return { + success: false, + error: { + code: 'ADD_TASK_ERROR', + message: error.message + } + }; + } +} diff --git a/mcp-server/src/core/direct-functions/analyze-task-complexity.js b/mcp-server/src/core/direct-functions/analyze-task-complexity.js index 84132f7d..1afdd2d0 100644 --- a/mcp-server/src/core/direct-functions/analyze-task-complexity.js +++ b/mcp-server/src/core/direct-functions/analyze-task-complexity.js @@ -4,7 +4,12 @@ import { analyzeTaskComplexity } from '../../../../scripts/modules/task-manager.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { enableSilentMode, disableSilentMode, isSilentMode, readJSON } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode, + isSilentMode, + readJSON +} from '../../../../scripts/modules/utils.js'; import fs from 'fs'; import path from 'path'; @@ -22,135 +27,142 @@ import path from 'path'; * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} */ export async function analyzeTaskComplexityDirect(args, log, context = {}) { - const { session } = context; // Only extract session, not reportProgress - - try { - log.info(`Analyzing task complexity with args: ${JSON.stringify(args)}`); - - // Find the tasks.json path - const tasksPath = findTasksJsonPath(args, log); - - // Determine output path - let outputPath = args.output || 'scripts/task-complexity-report.json'; - if (!path.isAbsolute(outputPath) && args.projectRoot) { - outputPath = path.join(args.projectRoot, outputPath); - } - - log.info(`Analyzing task complexity from: ${tasksPath}`); - log.info(`Output report will be saved to: ${outputPath}`); - - if (args.research) { - log.info('Using Perplexity AI for research-backed complexity analysis'); - } - - // Create options object for analyzeTaskComplexity - const options = { - file: tasksPath, - output: outputPath, - model: args.model, - threshold: args.threshold, - research: args.research === true - }; - - // Enable silent mode to prevent console logs from interfering with JSON response - const wasSilent = isSilentMode(); - if (!wasSilent) { - enableSilentMode(); - } - - // Create a logWrapper that matches the expected mcpLog interface as specified in utilities.mdc - const logWrapper = { - info: (message, ...args) => log.info(message, ...args), - warn: (message, ...args) => log.warn(message, ...args), - error: (message, ...args) => log.error(message, ...args), - debug: (message, ...args) => log.debug && log.debug(message, ...args), - success: (message, ...args) => log.info(message, ...args) // Map success to info - }; - - try { - // Call the core function with session and logWrapper as mcpLog - await analyzeTaskComplexity(options, { - session, - mcpLog: logWrapper // Use the wrapper instead of passing log directly - }); - } catch (error) { - log.error(`Error in analyzeTaskComplexity: ${error.message}`); - return { - success: false, - error: { - code: 'ANALYZE_ERROR', - message: `Error running complexity analysis: ${error.message}` - } - }; - } finally { - // Always restore normal logging in finally block, but only if we enabled it - if (!wasSilent) { - disableSilentMode(); - } - } - - // Verify the report file was created - if (!fs.existsSync(outputPath)) { - return { - success: false, - error: { - code: 'ANALYZE_ERROR', - message: 'Analysis completed but no report file was created' - } - }; - } - - // Read the report file - let report; - try { - report = JSON.parse(fs.readFileSync(outputPath, 'utf8')); - - // Important: Handle different report formats - // The core function might return an array or an object with a complexityAnalysis property - const analysisArray = Array.isArray(report) ? report : - (report.complexityAnalysis || []); - - // Count tasks by complexity - const highComplexityTasks = analysisArray.filter(t => t.complexityScore >= 8).length; - const mediumComplexityTasks = analysisArray.filter(t => t.complexityScore >= 5 && t.complexityScore < 8).length; - const lowComplexityTasks = analysisArray.filter(t => t.complexityScore < 5).length; - - return { - success: true, - data: { - message: `Task complexity analysis complete. Report saved to ${outputPath}`, - reportPath: outputPath, - reportSummary: { - taskCount: analysisArray.length, - highComplexityTasks, - mediumComplexityTasks, - lowComplexityTasks - } - } - }; - } catch (parseError) { - log.error(`Error parsing report file: ${parseError.message}`); - return { - success: false, - error: { - code: 'REPORT_PARSE_ERROR', - message: `Error parsing complexity report: ${parseError.message}` - } - }; - } - } catch (error) { - // Make sure to restore normal logging even if there's an error - if (isSilentMode()) { - disableSilentMode(); - } - - log.error(`Error in analyzeTaskComplexityDirect: ${error.message}`); - return { - success: false, - error: { - code: 'CORE_FUNCTION_ERROR', - message: error.message - } - }; - } -} \ No newline at end of file + const { session } = context; // Only extract session, not reportProgress + + try { + log.info(`Analyzing task complexity with args: ${JSON.stringify(args)}`); + + // Find the tasks.json path + const tasksPath = findTasksJsonPath(args, log); + + // Determine output path + let outputPath = args.output || 'scripts/task-complexity-report.json'; + if (!path.isAbsolute(outputPath) && args.projectRoot) { + outputPath = path.join(args.projectRoot, outputPath); + } + + log.info(`Analyzing task complexity from: ${tasksPath}`); + log.info(`Output report will be saved to: ${outputPath}`); + + if (args.research) { + log.info('Using Perplexity AI for research-backed complexity analysis'); + } + + // Create options object for analyzeTaskComplexity + const options = { + file: tasksPath, + output: outputPath, + model: args.model, + threshold: args.threshold, + research: args.research === true + }; + + // Enable silent mode to prevent console logs from interfering with JSON response + const wasSilent = isSilentMode(); + if (!wasSilent) { + enableSilentMode(); + } + + // Create a logWrapper that matches the expected mcpLog interface as specified in utilities.mdc + const logWrapper = { + info: (message, ...args) => log.info(message, ...args), + warn: (message, ...args) => log.warn(message, ...args), + error: (message, ...args) => log.error(message, ...args), + debug: (message, ...args) => log.debug && log.debug(message, ...args), + success: (message, ...args) => log.info(message, ...args) // Map success to info + }; + + try { + // Call the core function with session and logWrapper as mcpLog + await analyzeTaskComplexity(options, { + session, + mcpLog: logWrapper // Use the wrapper instead of passing log directly + }); + } catch (error) { + log.error(`Error in analyzeTaskComplexity: ${error.message}`); + return { + success: false, + error: { + code: 'ANALYZE_ERROR', + message: `Error running complexity analysis: ${error.message}` + } + }; + } finally { + // Always restore normal logging in finally block, but only if we enabled it + if (!wasSilent) { + disableSilentMode(); + } + } + + // Verify the report file was created + if (!fs.existsSync(outputPath)) { + return { + success: false, + error: { + code: 'ANALYZE_ERROR', + message: 'Analysis completed but no report file was created' + } + }; + } + + // Read the report file + let report; + try { + report = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + + // Important: Handle different report formats + // The core function might return an array or an object with a complexityAnalysis property + const analysisArray = Array.isArray(report) + ? report + : report.complexityAnalysis || []; + + // Count tasks by complexity + const highComplexityTasks = analysisArray.filter( + (t) => t.complexityScore >= 8 + ).length; + const mediumComplexityTasks = analysisArray.filter( + (t) => t.complexityScore >= 5 && t.complexityScore < 8 + ).length; + const lowComplexityTasks = analysisArray.filter( + (t) => t.complexityScore < 5 + ).length; + + return { + success: true, + data: { + message: `Task complexity analysis complete. Report saved to ${outputPath}`, + reportPath: outputPath, + reportSummary: { + taskCount: analysisArray.length, + highComplexityTasks, + mediumComplexityTasks, + lowComplexityTasks + } + } + }; + } catch (parseError) { + log.error(`Error parsing report file: ${parseError.message}`); + return { + success: false, + error: { + code: 'REPORT_PARSE_ERROR', + message: `Error parsing complexity report: ${parseError.message}` + } + }; + } + } catch (error) { + // Make sure to restore normal logging even if there's an error + if (isSilentMode()) { + disableSilentMode(); + } + + log.error(`Error in analyzeTaskComplexityDirect: ${error.message}`); + return { + success: false, + error: { + code: 'CORE_FUNCTION_ERROR', + message: error.message + } + }; + } +} diff --git a/mcp-server/src/core/direct-functions/cache-stats.js b/mcp-server/src/core/direct-functions/cache-stats.js index f334dba8..15eb10e3 100644 --- a/mcp-server/src/core/direct-functions/cache-stats.js +++ b/mcp-server/src/core/direct-functions/cache-stats.js @@ -12,21 +12,21 @@ import { contextManager } from '../context-manager.js'; * @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' - } - }; - } -} \ No newline at end of file + 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' + } + }; + } +} diff --git a/mcp-server/src/core/direct-functions/clear-subtasks.js b/mcp-server/src/core/direct-functions/clear-subtasks.js index 7e761c85..7e3987b1 100644 --- a/mcp-server/src/core/direct-functions/clear-subtasks.js +++ b/mcp-server/src/core/direct-functions/clear-subtasks.js @@ -4,7 +4,10 @@ import { clearSubtasks } from '../../../../scripts/modules/task-manager.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; import fs from 'fs'; /** @@ -18,95 +21,96 @@ import fs from 'fs'; * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} */ export async function clearSubtasksDirect(args, log) { - try { - log.info(`Clearing subtasks with args: ${JSON.stringify(args)}`); - - // Either id or all must be provided - if (!args.id && !args.all) { - return { - success: false, - error: { - code: 'INPUT_VALIDATION_ERROR', - message: 'Either task IDs with id parameter or all parameter must be provided' - } - }; - } + try { + log.info(`Clearing subtasks with args: ${JSON.stringify(args)}`); - // Find the tasks.json path - const tasksPath = findTasksJsonPath(args, log); - - // Check if tasks.json exists - if (!fs.existsSync(tasksPath)) { - return { - success: false, - error: { - code: 'FILE_NOT_FOUND_ERROR', - message: `Tasks file not found at ${tasksPath}` - } - }; - } - - let taskIds; - - // If all is specified, get all task IDs - if (args.all) { - log.info('Clearing subtasks from all tasks'); - const data = JSON.parse(fs.readFileSync(tasksPath, 'utf8')); - if (!data || !data.tasks || data.tasks.length === 0) { - return { - success: false, - error: { - code: 'INPUT_VALIDATION_ERROR', - message: 'No valid tasks found in the tasks file' - } - }; - } - taskIds = data.tasks.map(t => t.id).join(','); - } else { - // Use the provided task IDs - taskIds = args.id; - } - - log.info(`Clearing subtasks from tasks: ${taskIds}`); - - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - // Call the core function - clearSubtasks(tasksPath, taskIds); - - // Restore normal logging - disableSilentMode(); - - // Read the updated data to provide a summary - const updatedData = JSON.parse(fs.readFileSync(tasksPath, 'utf8')); - const taskIdArray = taskIds.split(',').map(id => parseInt(id.trim(), 10)); - - // Build a summary of what was done - const clearedTasksCount = taskIdArray.length; - const taskSummary = taskIdArray.map(id => { - const task = updatedData.tasks.find(t => t.id === id); - return task ? { id, title: task.title } : { id, title: 'Task not found' }; - }); - - return { - success: true, - data: { - message: `Successfully cleared subtasks from ${clearedTasksCount} task(s)`, - tasksCleared: taskSummary - } - }; - } catch (error) { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - - log.error(`Error in clearSubtasksDirect: ${error.message}`); - return { - success: false, - error: { - code: 'CORE_FUNCTION_ERROR', - message: error.message - } - }; - } -} \ No newline at end of file + // Either id or all must be provided + if (!args.id && !args.all) { + return { + success: false, + error: { + code: 'INPUT_VALIDATION_ERROR', + message: + 'Either task IDs with id parameter or all parameter must be provided' + } + }; + } + + // Find the tasks.json path + const tasksPath = findTasksJsonPath(args, log); + + // Check if tasks.json exists + if (!fs.existsSync(tasksPath)) { + return { + success: false, + error: { + code: 'FILE_NOT_FOUND_ERROR', + message: `Tasks file not found at ${tasksPath}` + } + }; + } + + let taskIds; + + // If all is specified, get all task IDs + if (args.all) { + log.info('Clearing subtasks from all tasks'); + const data = JSON.parse(fs.readFileSync(tasksPath, 'utf8')); + if (!data || !data.tasks || data.tasks.length === 0) { + return { + success: false, + error: { + code: 'INPUT_VALIDATION_ERROR', + message: 'No valid tasks found in the tasks file' + } + }; + } + taskIds = data.tasks.map((t) => t.id).join(','); + } else { + // Use the provided task IDs + taskIds = args.id; + } + + log.info(`Clearing subtasks from tasks: ${taskIds}`); + + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); + + // Call the core function + clearSubtasks(tasksPath, taskIds); + + // Restore normal logging + disableSilentMode(); + + // Read the updated data to provide a summary + const updatedData = JSON.parse(fs.readFileSync(tasksPath, 'utf8')); + const taskIdArray = taskIds.split(',').map((id) => parseInt(id.trim(), 10)); + + // Build a summary of what was done + const clearedTasksCount = taskIdArray.length; + const taskSummary = taskIdArray.map((id) => { + const task = updatedData.tasks.find((t) => t.id === id); + return task ? { id, title: task.title } : { id, title: 'Task not found' }; + }); + + return { + success: true, + data: { + message: `Successfully cleared subtasks from ${clearedTasksCount} task(s)`, + tasksCleared: taskSummary + } + }; + } catch (error) { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + + log.error(`Error in clearSubtasksDirect: ${error.message}`); + return { + success: false, + error: { + code: 'CORE_FUNCTION_ERROR', + message: error.message + } + }; + } +} diff --git a/mcp-server/src/core/direct-functions/complexity-report.js b/mcp-server/src/core/direct-functions/complexity-report.js index dcf8f7b2..9461a113 100644 --- a/mcp-server/src/core/direct-functions/complexity-report.js +++ b/mcp-server/src/core/direct-functions/complexity-report.js @@ -3,119 +3,131 @@ * Direct function implementation for displaying complexity analysis report */ -import { readComplexityReport, enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; +import { + readComplexityReport, + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; import { getCachedOrExecute } from '../../tools/utils.js'; import path from 'path'; /** * Direct function wrapper for displaying the complexity report with error handling and caching. - * + * * @param {Object} args - Command arguments containing file path option * @param {Object} log - Logger object * @returns {Promise} - Result object with success status and data/error information */ export async function complexityReportDirect(args, log) { - try { - log.info(`Getting complexity report with args: ${JSON.stringify(args)}`); - - // Get tasks file path to determine project root for the default report location - let tasksPath; - try { - tasksPath = findTasksJsonPath(args, log); - } catch (error) { - log.warn(`Tasks file not found, using current directory: ${error.message}`); - // Continue with default or specified report path - } + try { + log.info(`Getting complexity report with args: ${JSON.stringify(args)}`); - // Get report file path from args or use default - const reportPath = args.file || path.join(process.cwd(), 'scripts', 'task-complexity-report.json'); - - log.info(`Looking for complexity report at: ${reportPath}`); - - // Generate cache key based on report path - const cacheKey = `complexityReport:${reportPath}`; - - // Define the core action function to read the report - const coreActionFn = async () => { - try { - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - const report = readComplexityReport(reportPath); - - // Restore normal logging - disableSilentMode(); - - if (!report) { - log.warn(`No complexity report found at ${reportPath}`); - return { - success: false, - error: { - code: 'FILE_NOT_FOUND_ERROR', - message: `No complexity report found at ${reportPath}. Run 'analyze-complexity' first.` - } - }; - } - - return { - success: true, - data: { - report, - reportPath - } - }; - } catch (error) { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - - log.error(`Error reading complexity report: ${error.message}`); - return { - success: false, - error: { - code: 'READ_ERROR', - message: error.message - } - }; - } - }; + // Get tasks file path to determine project root for the default report location + let tasksPath; + try { + tasksPath = findTasksJsonPath(args, log); + } catch (error) { + log.warn( + `Tasks file not found, using current directory: ${error.message}` + ); + // Continue with default or specified report path + } - // Use the caching utility - try { - const result = await getCachedOrExecute({ - cacheKey, - actionFn: coreActionFn, - log - }); - log.info(`complexityReportDirect completed. From cache: ${result.fromCache}`); - return result; // Returns { success, data/error, fromCache } - } catch (error) { - // Catch unexpected errors from getCachedOrExecute itself - // Ensure silent mode is disabled - disableSilentMode(); - - log.error(`Unexpected error during getCachedOrExecute for complexityReport: ${error.message}`); - return { - success: false, - error: { - code: 'UNEXPECTED_ERROR', - message: error.message - }, - fromCache: false - }; - } - } catch (error) { - // Ensure silent mode is disabled if an outer error occurs - disableSilentMode(); - - log.error(`Error in complexityReportDirect: ${error.message}`); - return { - success: false, - error: { - code: 'UNEXPECTED_ERROR', - message: error.message - }, - fromCache: false - }; - } -} \ No newline at end of file + // Get report file path from args or use default + const reportPath = + args.file || + path.join(process.cwd(), 'scripts', 'task-complexity-report.json'); + + log.info(`Looking for complexity report at: ${reportPath}`); + + // Generate cache key based on report path + const cacheKey = `complexityReport:${reportPath}`; + + // Define the core action function to read the report + const coreActionFn = async () => { + try { + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); + + const report = readComplexityReport(reportPath); + + // Restore normal logging + disableSilentMode(); + + if (!report) { + log.warn(`No complexity report found at ${reportPath}`); + return { + success: false, + error: { + code: 'FILE_NOT_FOUND_ERROR', + message: `No complexity report found at ${reportPath}. Run 'analyze-complexity' first.` + } + }; + } + + return { + success: true, + data: { + report, + reportPath + } + }; + } catch (error) { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + + log.error(`Error reading complexity report: ${error.message}`); + return { + success: false, + error: { + code: 'READ_ERROR', + message: error.message + } + }; + } + }; + + // Use the caching utility + try { + const result = await getCachedOrExecute({ + cacheKey, + actionFn: coreActionFn, + log + }); + log.info( + `complexityReportDirect completed. From cache: ${result.fromCache}` + ); + return result; // Returns { success, data/error, fromCache } + } catch (error) { + // Catch unexpected errors from getCachedOrExecute itself + // Ensure silent mode is disabled + disableSilentMode(); + + log.error( + `Unexpected error during getCachedOrExecute for complexityReport: ${error.message}` + ); + return { + success: false, + error: { + code: 'UNEXPECTED_ERROR', + message: error.message + }, + fromCache: false + }; + } + } catch (error) { + // Ensure silent mode is disabled if an outer error occurs + disableSilentMode(); + + log.error(`Error in complexityReportDirect: ${error.message}`); + return { + success: false, + error: { + code: 'UNEXPECTED_ERROR', + message: error.message + }, + fromCache: false + }; + } +} diff --git a/mcp-server/src/core/direct-functions/expand-all-tasks.js b/mcp-server/src/core/direct-functions/expand-all-tasks.js index 148ea055..ac9574de 100644 --- a/mcp-server/src/core/direct-functions/expand-all-tasks.js +++ b/mcp-server/src/core/direct-functions/expand-all-tasks.js @@ -3,7 +3,11 @@ */ import { expandAllTasks } from '../../../../scripts/modules/task-manager.js'; -import { enableSilentMode, disableSilentMode, isSilentMode } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode, + isSilentMode +} from '../../../../scripts/modules/utils.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; import { getAnthropicClientForMCP } from '../utils/ai-client-utils.js'; import path from 'path'; @@ -23,98 +27,100 @@ import fs from 'fs'; * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} */ export async function expandAllTasksDirect(args, log, context = {}) { - const { session } = context; // Only extract session, not reportProgress - - try { - log.info(`Expanding all tasks with args: ${JSON.stringify(args)}`); - - // Enable silent mode early to prevent any console output - enableSilentMode(); - - try { - // Find the tasks.json path - const tasksPath = findTasksJsonPath(args, log); - - // Parse parameters - const numSubtasks = args.num ? parseInt(args.num, 10) : undefined; - const useResearch = args.research === true; - const additionalContext = args.prompt || ''; - const forceFlag = args.force === true; - - log.info(`Expanding all tasks with ${numSubtasks || 'default'} subtasks each...`); - - if (useResearch) { - log.info('Using Perplexity AI for research-backed subtask generation'); - - // Initialize AI client for research-backed expansion - try { - await getAnthropicClientForMCP(session, log); - } catch (error) { - // Ensure silent mode is disabled before returning error - disableSilentMode(); - - log.error(`Failed to initialize AI client: ${error.message}`); - return { - success: false, - error: { - code: 'AI_CLIENT_ERROR', - message: `Cannot initialize AI client: ${error.message}` - } - }; - } - } - - if (additionalContext) { - log.info(`Additional context: "${additionalContext}"`); - } - if (forceFlag) { - log.info('Force regeneration of subtasks is enabled'); - } - - // Call the core function with session context for AI operations - // and outputFormat as 'json' to prevent UI elements - const result = await expandAllTasks( - tasksPath, - numSubtasks, - useResearch, - additionalContext, - forceFlag, - { mcpLog: log, session }, - 'json' // Use JSON output format to prevent UI elements - ); - - // The expandAllTasks function now returns a result object - return { - success: true, - data: { - message: "Successfully expanded all pending tasks with subtasks", - details: { - numSubtasks: numSubtasks, - research: useResearch, - prompt: additionalContext, - force: forceFlag, - tasksExpanded: result.expandedCount, - totalEligibleTasks: result.tasksToExpand - } - } - }; - } finally { - // Restore normal logging in finally block to ensure it runs even if there's an error - disableSilentMode(); - } - } catch (error) { - // Ensure silent mode is disabled if an error occurs - if (isSilentMode()) { - disableSilentMode(); - } - - log.error(`Error in expandAllTasksDirect: ${error.message}`); - return { - success: false, - error: { - code: 'CORE_FUNCTION_ERROR', - message: error.message - } - }; - } -} \ No newline at end of file + const { session } = context; // Only extract session, not reportProgress + + try { + log.info(`Expanding all tasks with args: ${JSON.stringify(args)}`); + + // Enable silent mode early to prevent any console output + enableSilentMode(); + + try { + // Find the tasks.json path + const tasksPath = findTasksJsonPath(args, log); + + // Parse parameters + const numSubtasks = args.num ? parseInt(args.num, 10) : undefined; + const useResearch = args.research === true; + const additionalContext = args.prompt || ''; + const forceFlag = args.force === true; + + log.info( + `Expanding all tasks with ${numSubtasks || 'default'} subtasks each...` + ); + + if (useResearch) { + log.info('Using Perplexity AI for research-backed subtask generation'); + + // Initialize AI client for research-backed expansion + try { + await getAnthropicClientForMCP(session, log); + } catch (error) { + // Ensure silent mode is disabled before returning error + disableSilentMode(); + + log.error(`Failed to initialize AI client: ${error.message}`); + return { + success: false, + error: { + code: 'AI_CLIENT_ERROR', + message: `Cannot initialize AI client: ${error.message}` + } + }; + } + } + + if (additionalContext) { + log.info(`Additional context: "${additionalContext}"`); + } + if (forceFlag) { + log.info('Force regeneration of subtasks is enabled'); + } + + // Call the core function with session context for AI operations + // and outputFormat as 'json' to prevent UI elements + const result = await expandAllTasks( + tasksPath, + numSubtasks, + useResearch, + additionalContext, + forceFlag, + { mcpLog: log, session }, + 'json' // Use JSON output format to prevent UI elements + ); + + // The expandAllTasks function now returns a result object + return { + success: true, + data: { + message: 'Successfully expanded all pending tasks with subtasks', + details: { + numSubtasks: numSubtasks, + research: useResearch, + prompt: additionalContext, + force: forceFlag, + tasksExpanded: result.expandedCount, + totalEligibleTasks: result.tasksToExpand + } + } + }; + } finally { + // Restore normal logging in finally block to ensure it runs even if there's an error + disableSilentMode(); + } + } catch (error) { + // Ensure silent mode is disabled if an error occurs + if (isSilentMode()) { + disableSilentMode(); + } + + log.error(`Error in expandAllTasksDirect: ${error.message}`); + return { + success: false, + error: { + code: 'CORE_FUNCTION_ERROR', + message: error.message + } + }; + } +} diff --git a/mcp-server/src/core/direct-functions/expand-task.js b/mcp-server/src/core/direct-functions/expand-task.js index 88972c62..1efa9db9 100644 --- a/mcp-server/src/core/direct-functions/expand-task.js +++ b/mcp-server/src/core/direct-functions/expand-task.js @@ -4,9 +4,18 @@ */ import { expandTask } from '../../../../scripts/modules/task-manager.js'; -import { readJSON, writeJSON, enableSilentMode, disableSilentMode, isSilentMode } from '../../../../scripts/modules/utils.js'; +import { + readJSON, + writeJSON, + enableSilentMode, + disableSilentMode, + isSilentMode +} from '../../../../scripts/modules/utils.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { getAnthropicClientForMCP, getModelConfig } from '../utils/ai-client-utils.js'; +import { + getAnthropicClientForMCP, + getModelConfig +} from '../utils/ai-client-utils.js'; import path from 'path'; import fs from 'fs'; @@ -19,231 +28,248 @@ import fs from 'fs'; * @returns {Promise} - Task expansion result { success: boolean, data?: any, error?: { code: string, message: string }, fromCache: boolean } */ export async function expandTaskDirect(args, log, context = {}) { - const { session } = context; - - // Log session root data for debugging - log.info(`Session data in expandTaskDirect: ${JSON.stringify({ - hasSession: !!session, - sessionKeys: session ? Object.keys(session) : [], - roots: session?.roots, - rootsStr: JSON.stringify(session?.roots) - })}`); - - let tasksPath; - try { - // If a direct file path is provided, use it directly - if (args.file && fs.existsSync(args.file)) { - log.info(`[expandTaskDirect] Using explicitly provided tasks file: ${args.file}`); - tasksPath = args.file; - } else { - // Find the tasks path through standard logic - log.info(`[expandTaskDirect] No direct file path provided or file not found at ${args.file}, searching using findTasksJsonPath`); - tasksPath = findTasksJsonPath(args, log); - } - } catch (error) { - log.error(`[expandTaskDirect] Error during tasksPath determination: ${error.message}`); - - // Include session roots information in error - const sessionRootsInfo = session ? - `\nSession.roots: ${JSON.stringify(session.roots)}\n` + - `Current Working Directory: ${process.cwd()}\n` + - `Args.projectRoot: ${args.projectRoot}\n` + - `Args.file: ${args.file}\n` : - '\nSession object not available'; - - return { - success: false, - error: { - code: 'FILE_NOT_FOUND_ERROR', - message: `Error determining tasksPath: ${error.message}${sessionRootsInfo}` - }, - fromCache: false - }; - } + const { session } = context; - log.info(`[expandTaskDirect] Determined tasksPath: ${tasksPath}`); + // Log session root data for debugging + log.info( + `Session data in expandTaskDirect: ${JSON.stringify({ + hasSession: !!session, + sessionKeys: session ? Object.keys(session) : [], + roots: session?.roots, + rootsStr: JSON.stringify(session?.roots) + })}` + ); - // Validate task ID - const taskId = args.id ? parseInt(args.id, 10) : null; - if (!taskId) { - log.error('Task ID is required'); - return { - success: false, - error: { - code: 'INPUT_VALIDATION_ERROR', - message: 'Task ID is required' - }, - fromCache: false - }; - } + let tasksPath; + try { + // If a direct file path is provided, use it directly + if (args.file && fs.existsSync(args.file)) { + log.info( + `[expandTaskDirect] Using explicitly provided tasks file: ${args.file}` + ); + tasksPath = args.file; + } else { + // Find the tasks path through standard logic + log.info( + `[expandTaskDirect] No direct file path provided or file not found at ${args.file}, searching using findTasksJsonPath` + ); + tasksPath = findTasksJsonPath(args, log); + } + } catch (error) { + log.error( + `[expandTaskDirect] Error during tasksPath determination: ${error.message}` + ); - // Process other parameters - const numSubtasks = args.num ? parseInt(args.num, 10) : undefined; - const useResearch = args.research === true; - const additionalContext = args.prompt || ''; + // Include session roots information in error + const sessionRootsInfo = session + ? `\nSession.roots: ${JSON.stringify(session.roots)}\n` + + `Current Working Directory: ${process.cwd()}\n` + + `Args.projectRoot: ${args.projectRoot}\n` + + `Args.file: ${args.file}\n` + : '\nSession object not available'; - // Initialize AI client if needed (for expandTask function) - try { - // This ensures the AI client is available by checking it - if (useResearch) { - log.info('Verifying AI client for research-backed expansion'); - await getAnthropicClientForMCP(session, log); - } - } catch (error) { - log.error(`Failed to initialize AI client: ${error.message}`); - return { - success: false, - error: { - code: 'AI_CLIENT_ERROR', - message: `Cannot initialize AI client: ${error.message}` - }, - fromCache: false - }; - } + return { + success: false, + error: { + code: 'FILE_NOT_FOUND_ERROR', + message: `Error determining tasksPath: ${error.message}${sessionRootsInfo}` + }, + fromCache: false + }; + } - try { - log.info(`[expandTaskDirect] Expanding task ${taskId} into ${numSubtasks || 'default'} subtasks. Research: ${useResearch}`); - - // Read tasks data - log.info(`[expandTaskDirect] Attempting to read JSON from: ${tasksPath}`); - const data = readJSON(tasksPath); - log.info(`[expandTaskDirect] Result of readJSON: ${data ? 'Data read successfully' : 'readJSON returned null or undefined'}`); + log.info(`[expandTaskDirect] Determined tasksPath: ${tasksPath}`); - if (!data || !data.tasks) { - log.error(`[expandTaskDirect] readJSON failed or returned invalid data for path: ${tasksPath}`); - return { - success: false, - error: { - code: 'INVALID_TASKS_FILE', - message: `No valid tasks found in ${tasksPath}. readJSON returned: ${JSON.stringify(data)}` - }, - fromCache: false - }; - } - - // Find the specific task - log.info(`[expandTaskDirect] Searching for task ID ${taskId} in data`); - const task = data.tasks.find(t => t.id === taskId); - log.info(`[expandTaskDirect] Task found: ${task ? 'Yes' : 'No'}`); - - if (!task) { - return { - success: false, - error: { - code: 'TASK_NOT_FOUND', - message: `Task with ID ${taskId} not found` - }, - fromCache: false - }; - } - - // Check if task is completed - if (task.status === 'done' || task.status === 'completed') { - return { - success: false, - error: { - code: 'TASK_COMPLETED', - message: `Task ${taskId} is already marked as ${task.status} and cannot be expanded` - }, - fromCache: false - }; - } - - // Check for existing subtasks - const hasExistingSubtasks = task.subtasks && task.subtasks.length > 0; - - // If the task already has subtasks, just return it (matching core behavior) - if (hasExistingSubtasks) { - log.info(`Task ${taskId} already has ${task.subtasks.length} subtasks`); - return { - success: true, - data: { - task, - subtasksAdded: 0, - hasExistingSubtasks - }, - fromCache: false - }; - } - - // Keep a copy of the task before modification - const originalTask = JSON.parse(JSON.stringify(task)); - - // Tracking subtasks count before expansion - const subtasksCountBefore = task.subtasks ? task.subtasks.length : 0; - - // Create a backup of the tasks.json file - const backupPath = path.join(path.dirname(tasksPath), 'tasks.json.bak'); - fs.copyFileSync(tasksPath, backupPath); - - // Directly modify the data instead of calling the CLI function - if (!task.subtasks) { - task.subtasks = []; - } - - // Save tasks.json with potentially empty subtasks array - writeJSON(tasksPath, data); - - // Process the request - try { - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - // Call expandTask with session context to ensure AI client is properly initialized - const result = await expandTask( - tasksPath, - taskId, - numSubtasks, - useResearch, - additionalContext, - { mcpLog: log, session } // Only pass mcpLog and session, NOT reportProgress - ); - - // Restore normal logging - disableSilentMode(); - - // Read the updated data - const updatedData = readJSON(tasksPath); - const updatedTask = updatedData.tasks.find(t => t.id === taskId); - - // Calculate how many subtasks were added - const subtasksAdded = updatedTask.subtasks ? - updatedTask.subtasks.length - subtasksCountBefore : 0; - - // Return the result - log.info(`Successfully expanded task ${taskId} with ${subtasksAdded} new subtasks`); - return { - success: true, - data: { - task: updatedTask, - subtasksAdded, - hasExistingSubtasks - }, - fromCache: false - }; - } catch (error) { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - - log.error(`Error expanding task: ${error.message}`); - return { - success: false, - error: { - code: 'CORE_FUNCTION_ERROR', - message: error.message || 'Failed to expand task' - }, - fromCache: false - }; - } - } catch (error) { - log.error(`Error expanding task: ${error.message}`); - return { - success: false, - error: { - code: 'CORE_FUNCTION_ERROR', - message: error.message || 'Failed to expand task' - }, - fromCache: false - }; - } -} \ No newline at end of file + // Validate task ID + const taskId = args.id ? parseInt(args.id, 10) : null; + if (!taskId) { + log.error('Task ID is required'); + return { + success: false, + error: { + code: 'INPUT_VALIDATION_ERROR', + message: 'Task ID is required' + }, + fromCache: false + }; + } + + // Process other parameters + const numSubtasks = args.num ? parseInt(args.num, 10) : undefined; + const useResearch = args.research === true; + const additionalContext = args.prompt || ''; + + // Initialize AI client if needed (for expandTask function) + try { + // This ensures the AI client is available by checking it + if (useResearch) { + log.info('Verifying AI client for research-backed expansion'); + await getAnthropicClientForMCP(session, log); + } + } catch (error) { + log.error(`Failed to initialize AI client: ${error.message}`); + return { + success: false, + error: { + code: 'AI_CLIENT_ERROR', + message: `Cannot initialize AI client: ${error.message}` + }, + fromCache: false + }; + } + + try { + log.info( + `[expandTaskDirect] Expanding task ${taskId} into ${numSubtasks || 'default'} subtasks. Research: ${useResearch}` + ); + + // Read tasks data + log.info(`[expandTaskDirect] Attempting to read JSON from: ${tasksPath}`); + const data = readJSON(tasksPath); + log.info( + `[expandTaskDirect] Result of readJSON: ${data ? 'Data read successfully' : 'readJSON returned null or undefined'}` + ); + + if (!data || !data.tasks) { + log.error( + `[expandTaskDirect] readJSON failed or returned invalid data for path: ${tasksPath}` + ); + return { + success: false, + error: { + code: 'INVALID_TASKS_FILE', + message: `No valid tasks found in ${tasksPath}. readJSON returned: ${JSON.stringify(data)}` + }, + fromCache: false + }; + } + + // Find the specific task + log.info(`[expandTaskDirect] Searching for task ID ${taskId} in data`); + const task = data.tasks.find((t) => t.id === taskId); + log.info(`[expandTaskDirect] Task found: ${task ? 'Yes' : 'No'}`); + + if (!task) { + return { + success: false, + error: { + code: 'TASK_NOT_FOUND', + message: `Task with ID ${taskId} not found` + }, + fromCache: false + }; + } + + // Check if task is completed + if (task.status === 'done' || task.status === 'completed') { + return { + success: false, + error: { + code: 'TASK_COMPLETED', + message: `Task ${taskId} is already marked as ${task.status} and cannot be expanded` + }, + fromCache: false + }; + } + + // Check for existing subtasks + const hasExistingSubtasks = task.subtasks && task.subtasks.length > 0; + + // If the task already has subtasks, just return it (matching core behavior) + if (hasExistingSubtasks) { + log.info(`Task ${taskId} already has ${task.subtasks.length} subtasks`); + return { + success: true, + data: { + task, + subtasksAdded: 0, + hasExistingSubtasks + }, + fromCache: false + }; + } + + // Keep a copy of the task before modification + const originalTask = JSON.parse(JSON.stringify(task)); + + // Tracking subtasks count before expansion + const subtasksCountBefore = task.subtasks ? task.subtasks.length : 0; + + // Create a backup of the tasks.json file + const backupPath = path.join(path.dirname(tasksPath), 'tasks.json.bak'); + fs.copyFileSync(tasksPath, backupPath); + + // Directly modify the data instead of calling the CLI function + if (!task.subtasks) { + task.subtasks = []; + } + + // Save tasks.json with potentially empty subtasks array + writeJSON(tasksPath, data); + + // Process the request + try { + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); + + // Call expandTask with session context to ensure AI client is properly initialized + const result = await expandTask( + tasksPath, + taskId, + numSubtasks, + useResearch, + additionalContext, + { mcpLog: log, session } // Only pass mcpLog and session, NOT reportProgress + ); + + // Restore normal logging + disableSilentMode(); + + // Read the updated data + const updatedData = readJSON(tasksPath); + const updatedTask = updatedData.tasks.find((t) => t.id === taskId); + + // Calculate how many subtasks were added + const subtasksAdded = updatedTask.subtasks + ? updatedTask.subtasks.length - subtasksCountBefore + : 0; + + // Return the result + log.info( + `Successfully expanded task ${taskId} with ${subtasksAdded} new subtasks` + ); + return { + success: true, + data: { + task: updatedTask, + subtasksAdded, + hasExistingSubtasks + }, + fromCache: false + }; + } catch (error) { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + + log.error(`Error expanding task: ${error.message}`); + return { + success: false, + error: { + code: 'CORE_FUNCTION_ERROR', + message: error.message || 'Failed to expand task' + }, + fromCache: false + }; + } + } catch (error) { + log.error(`Error expanding task: ${error.message}`); + return { + success: false, + error: { + code: 'CORE_FUNCTION_ERROR', + message: error.message || 'Failed to expand task' + }, + fromCache: false + }; + } +} diff --git a/mcp-server/src/core/direct-functions/fix-dependencies.js b/mcp-server/src/core/direct-functions/fix-dependencies.js index 592a2b88..8dc61833 100644 --- a/mcp-server/src/core/direct-functions/fix-dependencies.js +++ b/mcp-server/src/core/direct-functions/fix-dependencies.js @@ -4,7 +4,10 @@ import { fixDependenciesCommand } from '../../../../scripts/modules/dependency-manager.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; import fs from 'fs'; /** @@ -16,50 +19,50 @@ import fs from 'fs'; * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} */ export async function fixDependenciesDirect(args, log) { - try { - log.info(`Fixing invalid dependencies in tasks...`); - - // Find the tasks.json path - const tasksPath = findTasksJsonPath(args, log); - - // Verify the file exists - if (!fs.existsSync(tasksPath)) { - return { - success: false, - error: { - code: 'FILE_NOT_FOUND', - message: `Tasks file not found at ${tasksPath}` - } - }; - } - - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - // Call the original command function - await fixDependenciesCommand(tasksPath); - - // Restore normal logging - disableSilentMode(); - - return { - success: true, - data: { - message: 'Dependencies fixed successfully', - tasksPath - } - }; - } catch (error) { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - - log.error(`Error fixing dependencies: ${error.message}`); - return { - success: false, - error: { - code: 'FIX_DEPENDENCIES_ERROR', - message: error.message - } - }; - } -} \ No newline at end of file + try { + log.info(`Fixing invalid dependencies in tasks...`); + + // Find the tasks.json path + const tasksPath = findTasksJsonPath(args, log); + + // Verify the file exists + if (!fs.existsSync(tasksPath)) { + return { + success: false, + error: { + code: 'FILE_NOT_FOUND', + message: `Tasks file not found at ${tasksPath}` + } + }; + } + + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); + + // Call the original command function + await fixDependenciesCommand(tasksPath); + + // Restore normal logging + disableSilentMode(); + + return { + success: true, + data: { + message: 'Dependencies fixed successfully', + tasksPath + } + }; + } catch (error) { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + + log.error(`Error fixing dependencies: ${error.message}`); + return { + success: false, + error: { + code: 'FIX_DEPENDENCIES_ERROR', + message: error.message + } + }; + } +} diff --git a/mcp-server/src/core/direct-functions/generate-task-files.js b/mcp-server/src/core/direct-functions/generate-task-files.js index a686c509..d84956ab 100644 --- a/mcp-server/src/core/direct-functions/generate-task-files.js +++ b/mcp-server/src/core/direct-functions/generate-task-files.js @@ -4,84 +4,91 @@ */ import { generateTaskFiles } from '../../../../scripts/modules/task-manager.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; import path from 'path'; /** * Direct function wrapper for generateTaskFiles with error handling. - * + * * @param {Object} args - Command arguments containing file and output path options. * @param {Object} log - Logger object. * @returns {Promise} - Result object with success status and data/error information. */ export async function generateTaskFilesDirect(args, log) { - try { - log.info(`Generating task files with args: ${JSON.stringify(args)}`); - - // Get tasks file path - let tasksPath; - try { - tasksPath = findTasksJsonPath(args, log); - } catch (error) { - log.error(`Error finding tasks file: ${error.message}`); - return { - success: false, - error: { code: 'TASKS_FILE_ERROR', message: error.message }, - fromCache: false - }; - } - - // Get output directory (defaults to the same directory as the tasks file) - let outputDir = args.output; - if (!outputDir) { - outputDir = path.dirname(tasksPath); - } - - log.info(`Generating task files from ${tasksPath} to ${outputDir}`); - - // Execute core generateTaskFiles function in a separate try/catch - try { - // Enable silent mode to prevent logs from being written to stdout - enableSilentMode(); - - // The function is synchronous despite being awaited elsewhere - generateTaskFiles(tasksPath, outputDir); - - // Restore normal logging after task generation - disableSilentMode(); - } catch (genError) { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - - log.error(`Error in generateTaskFiles: ${genError.message}`); - return { - success: false, - error: { code: 'GENERATE_FILES_ERROR', message: genError.message }, - fromCache: false - }; - } - - // Return success with file paths - return { - success: true, - data: { - message: `Successfully generated task files`, - tasksPath, - outputDir, - taskFiles: 'Individual task files have been generated in the output directory' - }, - fromCache: false // This operation always modifies state and should never be cached - }; - } catch (error) { - // Make sure to restore normal logging if an outer error occurs - disableSilentMode(); - - log.error(`Error generating task files: ${error.message}`); - return { - success: false, - error: { code: 'GENERATE_TASKS_ERROR', message: error.message || 'Unknown error generating task files' }, - fromCache: false - }; - } -} \ No newline at end of file + try { + log.info(`Generating task files with args: ${JSON.stringify(args)}`); + + // Get tasks file path + let tasksPath; + try { + tasksPath = findTasksJsonPath(args, log); + } catch (error) { + log.error(`Error finding tasks file: ${error.message}`); + return { + success: false, + error: { code: 'TASKS_FILE_ERROR', message: error.message }, + fromCache: false + }; + } + + // Get output directory (defaults to the same directory as the tasks file) + let outputDir = args.output; + if (!outputDir) { + outputDir = path.dirname(tasksPath); + } + + log.info(`Generating task files from ${tasksPath} to ${outputDir}`); + + // Execute core generateTaskFiles function in a separate try/catch + try { + // Enable silent mode to prevent logs from being written to stdout + enableSilentMode(); + + // The function is synchronous despite being awaited elsewhere + generateTaskFiles(tasksPath, outputDir); + + // Restore normal logging after task generation + disableSilentMode(); + } catch (genError) { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + + log.error(`Error in generateTaskFiles: ${genError.message}`); + return { + success: false, + error: { code: 'GENERATE_FILES_ERROR', message: genError.message }, + fromCache: false + }; + } + + // Return success with file paths + return { + success: true, + data: { + message: `Successfully generated task files`, + tasksPath, + outputDir, + taskFiles: + 'Individual task files have been generated in the output directory' + }, + fromCache: false // This operation always modifies state and should never be cached + }; + } catch (error) { + // Make sure to restore normal logging if an outer error occurs + disableSilentMode(); + + log.error(`Error generating task files: ${error.message}`); + return { + success: false, + error: { + code: 'GENERATE_TASKS_ERROR', + message: error.message || 'Unknown error generating task files' + }, + fromCache: false + }; + } +} diff --git a/mcp-server/src/core/direct-functions/list-tasks.js b/mcp-server/src/core/direct-functions/list-tasks.js index b54b2738..c6f5e050 100644 --- a/mcp-server/src/core/direct-functions/list-tasks.js +++ b/mcp-server/src/core/direct-functions/list-tasks.js @@ -6,7 +6,10 @@ import { listTasks } from '../../../../scripts/modules/task-manager.js'; import { getCachedOrExecute } from '../../tools/utils.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; /** * Direct function wrapper for listTasks with error handling and caching. @@ -16,68 +19,102 @@ import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules * @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 { - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - 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`); - - // Restore normal logging - disableSilentMode(); - - return { success: true, data: resultData }; + // Define the action function to be executed on cache miss + const coreListTasksAction = async () => { + try { + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); - } catch (error) { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - - log.error(`Core listTasks function failed: ${error.message}`); - return { success: false, error: { code: 'LIST_TASKS_CORE_ERROR', message: error.message || 'Failed to list tasks' } }; - } - }; + log.info( + `Executing core listTasks function for path: ${tasksPath}, filter: ${statusFilter}, subtasks: ${withSubtasks}` + ); + const resultData = listTasks( + tasksPath, + statusFilter, + withSubtasks, + 'json' + ); - // 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 }; - } -} \ No newline at end of file + 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` + ); + + // Restore normal logging + disableSilentMode(); + + return { success: true, data: resultData }; + } catch (error) { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + + 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 + }; + } +} diff --git a/mcp-server/src/core/direct-functions/next-task.js b/mcp-server/src/core/direct-functions/next-task.js index eabeddd4..3286ed69 100644 --- a/mcp-server/src/core/direct-functions/next-task.js +++ b/mcp-server/src/core/direct-functions/next-task.js @@ -7,7 +7,10 @@ import { findNextTask } from '../../../../scripts/modules/task-manager.js'; import { readJSON } from '../../../../scripts/modules/utils.js'; import { getCachedOrExecute } from '../../tools/utils.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; /** * Direct function wrapper for finding the next task to work on with error handling and caching. @@ -17,106 +20,113 @@ import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules * @returns {Promise} - Next task result { success: boolean, data?: any, error?: { code: string, message: string }, fromCache: boolean } */ export async function nextTaskDirect(args, log) { - let tasksPath; - try { - // Find the tasks path first - needed for cache key and execution - tasksPath = findTasksJsonPath(args, log); - } catch (error) { - log.error(`Tasks file not found: ${error.message}`); - return { - success: false, - error: { - code: 'FILE_NOT_FOUND_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) { + log.error(`Tasks file not found: ${error.message}`); + return { + success: false, + error: { + code: 'FILE_NOT_FOUND_ERROR', + message: error.message + }, + fromCache: false + }; + } - // Generate cache key using task path - const cacheKey = `nextTask:${tasksPath}`; - - // Define the action function to be executed on cache miss - const coreNextTaskAction = async () => { - try { - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - log.info(`Finding next task from ${tasksPath}`); - - // Read tasks data - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - return { - success: false, - error: { - code: 'INVALID_TASKS_FILE', - message: `No valid tasks found in ${tasksPath}` - } - }; - } - - // Find the next task - const nextTask = findNextTask(data.tasks); - - if (!nextTask) { - log.info('No eligible next task found. All tasks are either completed or have unsatisfied dependencies'); - return { - success: true, - data: { - message: 'No eligible next task found. All tasks are either completed or have unsatisfied dependencies', - nextTask: null, - allTasks: data.tasks - } - }; - } - - // Restore normal logging - disableSilentMode(); - - // Return the next task data with the full tasks array for reference - log.info(`Successfully found next task ${nextTask.id}: ${nextTask.title}`); - return { - success: true, - data: { - nextTask, - allTasks: data.tasks - } - }; - } catch (error) { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - - log.error(`Error finding next task: ${error.message}`); - return { - success: false, - error: { - code: 'CORE_FUNCTION_ERROR', - message: error.message || 'Failed to find next task' - } - }; - } - }; + // Generate cache key using task path + const cacheKey = `nextTask:${tasksPath}`; - // Use the caching utility - try { - const result = await getCachedOrExecute({ - cacheKey, - actionFn: coreNextTaskAction, - log - }); - log.info(`nextTaskDirect completed. From cache: ${result.fromCache}`); - return result; // Returns { success, data/error, fromCache } - } catch (error) { - // Catch unexpected errors from getCachedOrExecute itself - log.error(`Unexpected error during getCachedOrExecute for nextTask: ${error.message}`); - return { - success: false, - error: { - code: 'UNEXPECTED_ERROR', - message: error.message - }, - fromCache: false - }; - } -} \ No newline at end of file + // Define the action function to be executed on cache miss + const coreNextTaskAction = async () => { + try { + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); + + log.info(`Finding next task from ${tasksPath}`); + + // Read tasks data + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + return { + success: false, + error: { + code: 'INVALID_TASKS_FILE', + message: `No valid tasks found in ${tasksPath}` + } + }; + } + + // Find the next task + const nextTask = findNextTask(data.tasks); + + if (!nextTask) { + log.info( + 'No eligible next task found. All tasks are either completed or have unsatisfied dependencies' + ); + return { + success: true, + data: { + message: + 'No eligible next task found. All tasks are either completed or have unsatisfied dependencies', + nextTask: null, + allTasks: data.tasks + } + }; + } + + // Restore normal logging + disableSilentMode(); + + // Return the next task data with the full tasks array for reference + log.info( + `Successfully found next task ${nextTask.id}: ${nextTask.title}` + ); + return { + success: true, + data: { + nextTask, + allTasks: data.tasks + } + }; + } catch (error) { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + + log.error(`Error finding next task: ${error.message}`); + return { + success: false, + error: { + code: 'CORE_FUNCTION_ERROR', + message: error.message || 'Failed to find next task' + } + }; + } + }; + + // Use the caching utility + try { + const result = await getCachedOrExecute({ + cacheKey, + actionFn: coreNextTaskAction, + log + }); + log.info(`nextTaskDirect completed. From cache: ${result.fromCache}`); + return result; // Returns { success, data/error, fromCache } + } catch (error) { + // Catch unexpected errors from getCachedOrExecute itself + log.error( + `Unexpected error during getCachedOrExecute for nextTask: ${error.message}` + ); + return { + success: false, + error: { + code: 'UNEXPECTED_ERROR', + message: error.message + }, + fromCache: false + }; + } +} diff --git a/mcp-server/src/core/direct-functions/parse-prd.js b/mcp-server/src/core/direct-functions/parse-prd.js index fcc4b671..2b76bf37 100644 --- a/mcp-server/src/core/direct-functions/parse-prd.js +++ b/mcp-server/src/core/direct-functions/parse-prd.js @@ -7,144 +7,172 @@ import path from 'path'; import fs from 'fs'; import { parsePRD } from '../../../../scripts/modules/task-manager.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; -import { getAnthropicClientForMCP, getModelConfig } from '../utils/ai-client-utils.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; +import { + getAnthropicClientForMCP, + getModelConfig +} from '../utils/ai-client-utils.js'; /** * Direct function wrapper for parsing PRD documents and generating tasks. - * + * * @param {Object} args - Command arguments containing input, numTasks or tasks, and output options. * @param {Object} log - Logger object. * @param {Object} context - Context object containing session data. * @returns {Promise} - Result object with success status and data/error information. */ export async function parsePRDDirect(args, log, context = {}) { - const { session } = context; // Only extract session, not reportProgress - - try { - log.info(`Parsing PRD document with args: ${JSON.stringify(args)}`); - - // Initialize AI client for PRD parsing - let aiClient; - try { - aiClient = getAnthropicClientForMCP(session, log); - } catch (error) { - log.error(`Failed to initialize AI client: ${error.message}`); - return { - success: false, - error: { - code: 'AI_CLIENT_ERROR', - message: `Cannot initialize AI client: ${error.message}` - }, - fromCache: false - }; - } - - // Parameter validation and path resolution - if (!args.input) { - const errorMessage = 'No input file specified. Please provide an input PRD document path.'; - log.error(errorMessage); - return { - success: false, - error: { code: 'MISSING_INPUT_FILE', message: errorMessage }, - fromCache: false - }; - } - - // Resolve input path (relative to project root if provided) - const projectRoot = args.projectRoot || process.cwd(); - const inputPath = path.isAbsolute(args.input) ? args.input : path.resolve(projectRoot, args.input); - - // Determine output path - let outputPath; - if (args.output) { - outputPath = path.isAbsolute(args.output) ? args.output : path.resolve(projectRoot, args.output); - } else { - // Default to tasks/tasks.json in the project root - outputPath = path.resolve(projectRoot, 'tasks', 'tasks.json'); - } - - // Verify input file exists - if (!fs.existsSync(inputPath)) { - const errorMessage = `Input file not found: ${inputPath}`; - log.error(errorMessage); - return { - success: false, - error: { code: 'INPUT_FILE_NOT_FOUND', message: errorMessage }, - fromCache: false - }; - } - - // Parse number of tasks - handle both string and number values - let numTasks = 10; // Default - if (args.numTasks) { - numTasks = typeof args.numTasks === 'string' ? parseInt(args.numTasks, 10) : args.numTasks; - if (isNaN(numTasks)) { - numTasks = 10; // Fallback to default if parsing fails - log.warn(`Invalid numTasks value: ${args.numTasks}. Using default: 10`); - } - } - - log.info(`Preparing to parse PRD from ${inputPath} and output to ${outputPath} with ${numTasks} tasks`); - - // Create the logger wrapper for proper logging in the core function - const logWrapper = { - info: (message, ...args) => log.info(message, ...args), - warn: (message, ...args) => log.warn(message, ...args), - error: (message, ...args) => log.error(message, ...args), - debug: (message, ...args) => log.debug && log.debug(message, ...args), - success: (message, ...args) => log.info(message, ...args) // Map success to info - }; + const { session } = context; // Only extract session, not reportProgress - // Get model config from session - const modelConfig = getModelConfig(session); - - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - try { - // Execute core parsePRD function with AI client - await parsePRD(inputPath, outputPath, numTasks, { - mcpLog: logWrapper, - session - }, aiClient, modelConfig); - - // Since parsePRD doesn't return a value but writes to a file, we'll read the result - // to return it to the caller - if (fs.existsSync(outputPath)) { - const tasksData = JSON.parse(fs.readFileSync(outputPath, 'utf8')); - log.info(`Successfully parsed PRD and generated ${tasksData.tasks?.length || 0} tasks`); - - return { - success: true, - data: { - message: `Successfully generated ${tasksData.tasks?.length || 0} tasks from PRD`, - taskCount: tasksData.tasks?.length || 0, - outputPath - }, - fromCache: false // This operation always modifies state and should never be cached - }; - } else { - const errorMessage = `Tasks file was not created at ${outputPath}`; - log.error(errorMessage); - return { - success: false, - error: { code: 'OUTPUT_FILE_NOT_CREATED', message: errorMessage }, - fromCache: false - }; - } - } finally { - // Always restore normal logging - disableSilentMode(); - } - } catch (error) { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - - log.error(`Error parsing PRD: ${error.message}`); - return { - success: false, - error: { code: 'PARSE_PRD_ERROR', message: error.message || 'Unknown error parsing PRD' }, - fromCache: false - }; - } -} \ No newline at end of file + try { + log.info(`Parsing PRD document with args: ${JSON.stringify(args)}`); + + // Initialize AI client for PRD parsing + let aiClient; + try { + aiClient = getAnthropicClientForMCP(session, log); + } catch (error) { + log.error(`Failed to initialize AI client: ${error.message}`); + return { + success: false, + error: { + code: 'AI_CLIENT_ERROR', + message: `Cannot initialize AI client: ${error.message}` + }, + fromCache: false + }; + } + + // Parameter validation and path resolution + if (!args.input) { + const errorMessage = + 'No input file specified. Please provide an input PRD document path.'; + log.error(errorMessage); + return { + success: false, + error: { code: 'MISSING_INPUT_FILE', message: errorMessage }, + fromCache: false + }; + } + + // Resolve input path (relative to project root if provided) + const projectRoot = args.projectRoot || process.cwd(); + const inputPath = path.isAbsolute(args.input) + ? args.input + : path.resolve(projectRoot, args.input); + + // Determine output path + let outputPath; + if (args.output) { + outputPath = path.isAbsolute(args.output) + ? args.output + : path.resolve(projectRoot, args.output); + } else { + // Default to tasks/tasks.json in the project root + outputPath = path.resolve(projectRoot, 'tasks', 'tasks.json'); + } + + // Verify input file exists + if (!fs.existsSync(inputPath)) { + const errorMessage = `Input file not found: ${inputPath}`; + log.error(errorMessage); + return { + success: false, + error: { code: 'INPUT_FILE_NOT_FOUND', message: errorMessage }, + fromCache: false + }; + } + + // Parse number of tasks - handle both string and number values + let numTasks = 10; // Default + if (args.numTasks) { + numTasks = + typeof args.numTasks === 'string' + ? parseInt(args.numTasks, 10) + : args.numTasks; + if (isNaN(numTasks)) { + numTasks = 10; // Fallback to default if parsing fails + log.warn(`Invalid numTasks value: ${args.numTasks}. Using default: 10`); + } + } + + log.info( + `Preparing to parse PRD from ${inputPath} and output to ${outputPath} with ${numTasks} tasks` + ); + + // Create the logger wrapper for proper logging in the core function + const logWrapper = { + info: (message, ...args) => log.info(message, ...args), + warn: (message, ...args) => log.warn(message, ...args), + error: (message, ...args) => log.error(message, ...args), + debug: (message, ...args) => log.debug && log.debug(message, ...args), + success: (message, ...args) => log.info(message, ...args) // Map success to info + }; + + // Get model config from session + const modelConfig = getModelConfig(session); + + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); + try { + // Execute core parsePRD function with AI client + await parsePRD( + inputPath, + outputPath, + numTasks, + { + mcpLog: logWrapper, + session + }, + aiClient, + modelConfig + ); + + // Since parsePRD doesn't return a value but writes to a file, we'll read the result + // to return it to the caller + if (fs.existsSync(outputPath)) { + const tasksData = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + log.info( + `Successfully parsed PRD and generated ${tasksData.tasks?.length || 0} tasks` + ); + + return { + success: true, + data: { + message: `Successfully generated ${tasksData.tasks?.length || 0} tasks from PRD`, + taskCount: tasksData.tasks?.length || 0, + outputPath + }, + fromCache: false // This operation always modifies state and should never be cached + }; + } else { + const errorMessage = `Tasks file was not created at ${outputPath}`; + log.error(errorMessage); + return { + success: false, + error: { code: 'OUTPUT_FILE_NOT_CREATED', message: errorMessage }, + fromCache: false + }; + } + } finally { + // Always restore normal logging + disableSilentMode(); + } + } catch (error) { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + + log.error(`Error parsing PRD: ${error.message}`); + return { + success: false, + error: { + code: 'PARSE_PRD_ERROR', + message: error.message || 'Unknown error parsing PRD' + }, + fromCache: false + }; + } +} diff --git a/mcp-server/src/core/direct-functions/remove-dependency.js b/mcp-server/src/core/direct-functions/remove-dependency.js index 62d9f4c1..59ed7f3f 100644 --- a/mcp-server/src/core/direct-functions/remove-dependency.js +++ b/mcp-server/src/core/direct-functions/remove-dependency.js @@ -4,7 +4,10 @@ import { removeDependency } from '../../../../scripts/modules/dependency-manager.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; /** * Remove a dependency from a task @@ -17,67 +20,75 @@ import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} */ export async function removeDependencyDirect(args, log) { - try { - log.info(`Removing dependency with args: ${JSON.stringify(args)}`); - - // Validate required parameters - if (!args.id) { - return { - success: false, - error: { - code: 'INPUT_VALIDATION_ERROR', - message: 'Task ID (id) is required' - } - }; - } - - if (!args.dependsOn) { - return { - success: false, - error: { - code: 'INPUT_VALIDATION_ERROR', - message: 'Dependency ID (dependsOn) is required' - } - }; - } - - // Find the tasks.json path - const tasksPath = findTasksJsonPath(args, log); - - // Format IDs for the core function - const taskId = args.id.includes && args.id.includes('.') ? args.id : parseInt(args.id, 10); - const dependencyId = args.dependsOn.includes && args.dependsOn.includes('.') ? args.dependsOn : parseInt(args.dependsOn, 10); - - log.info(`Removing dependency: task ${taskId} no longer depends on ${dependencyId}`); - - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - // Call the core function - await removeDependency(tasksPath, taskId, dependencyId); - - // Restore normal logging - disableSilentMode(); - - return { - success: true, - data: { - message: `Successfully removed dependency: Task ${taskId} no longer depends on ${dependencyId}`, - taskId: taskId, - dependencyId: dependencyId - } - }; - } catch (error) { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - - log.error(`Error in removeDependencyDirect: ${error.message}`); - return { - success: false, - error: { - code: 'CORE_FUNCTION_ERROR', - message: error.message - } - }; - } -} \ No newline at end of file + try { + log.info(`Removing dependency with args: ${JSON.stringify(args)}`); + + // Validate required parameters + if (!args.id) { + return { + success: false, + error: { + code: 'INPUT_VALIDATION_ERROR', + message: 'Task ID (id) is required' + } + }; + } + + if (!args.dependsOn) { + return { + success: false, + error: { + code: 'INPUT_VALIDATION_ERROR', + message: 'Dependency ID (dependsOn) is required' + } + }; + } + + // Find the tasks.json path + const tasksPath = findTasksJsonPath(args, log); + + // Format IDs for the core function + const taskId = + args.id.includes && args.id.includes('.') + ? args.id + : parseInt(args.id, 10); + const dependencyId = + args.dependsOn.includes && args.dependsOn.includes('.') + ? args.dependsOn + : parseInt(args.dependsOn, 10); + + log.info( + `Removing dependency: task ${taskId} no longer depends on ${dependencyId}` + ); + + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); + + // Call the core function + await removeDependency(tasksPath, taskId, dependencyId); + + // Restore normal logging + disableSilentMode(); + + return { + success: true, + data: { + message: `Successfully removed dependency: Task ${taskId} no longer depends on ${dependencyId}`, + taskId: taskId, + dependencyId: dependencyId + } + }; + } catch (error) { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + + log.error(`Error in removeDependencyDirect: ${error.message}`); + return { + success: false, + error: { + code: 'CORE_FUNCTION_ERROR', + message: error.message + } + }; + } +} diff --git a/mcp-server/src/core/direct-functions/remove-subtask.js b/mcp-server/src/core/direct-functions/remove-subtask.js index 9fbc3d5f..143a985f 100644 --- a/mcp-server/src/core/direct-functions/remove-subtask.js +++ b/mcp-server/src/core/direct-functions/remove-subtask.js @@ -4,7 +4,10 @@ import { removeSubtask } from '../../../../scripts/modules/task-manager.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; /** * Remove a subtask from its parent task @@ -18,78 +21,86 @@ import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} */ export async function removeSubtaskDirect(args, log) { - try { - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - log.info(`Removing subtask with args: ${JSON.stringify(args)}`); - - if (!args.id) { - return { - success: false, - error: { - code: 'INPUT_VALIDATION_ERROR', - message: 'Subtask ID is required and must be in format "parentId.subtaskId"' - } - }; - } - - // Validate subtask ID format - if (!args.id.includes('.')) { - return { - success: false, - error: { - code: 'INPUT_VALIDATION_ERROR', - message: `Invalid subtask ID format: ${args.id}. Expected format: "parentId.subtaskId"` - } - }; - } + try { + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); - // Find the tasks.json path - const tasksPath = findTasksJsonPath(args, log); - - // Convert convertToTask to a boolean - const convertToTask = args.convert === true; - - // Determine if we should generate files - const generateFiles = !args.skipGenerate; - - log.info(`Removing subtask ${args.id} (convertToTask: ${convertToTask}, generateFiles: ${generateFiles})`); - - const result = await removeSubtask(tasksPath, args.id, convertToTask, generateFiles); - - // Restore normal logging - disableSilentMode(); - - if (convertToTask && result) { - // Return info about the converted task - return { - success: true, - data: { - message: `Subtask ${args.id} successfully converted to task #${result.id}`, - task: result - } - }; - } else { - // Return simple success message for deletion - return { - success: true, - data: { - message: `Subtask ${args.id} successfully removed` - } - }; - } - } catch (error) { - // Ensure silent mode is disabled even if an outer error occurs - disableSilentMode(); - - log.error(`Error in removeSubtaskDirect: ${error.message}`); - return { - success: false, - error: { - code: 'CORE_FUNCTION_ERROR', - message: error.message - } - }; - } -} \ No newline at end of file + log.info(`Removing subtask with args: ${JSON.stringify(args)}`); + + if (!args.id) { + return { + success: false, + error: { + code: 'INPUT_VALIDATION_ERROR', + message: + 'Subtask ID is required and must be in format "parentId.subtaskId"' + } + }; + } + + // Validate subtask ID format + if (!args.id.includes('.')) { + return { + success: false, + error: { + code: 'INPUT_VALIDATION_ERROR', + message: `Invalid subtask ID format: ${args.id}. Expected format: "parentId.subtaskId"` + } + }; + } + + // Find the tasks.json path + const tasksPath = findTasksJsonPath(args, log); + + // Convert convertToTask to a boolean + const convertToTask = args.convert === true; + + // Determine if we should generate files + const generateFiles = !args.skipGenerate; + + log.info( + `Removing subtask ${args.id} (convertToTask: ${convertToTask}, generateFiles: ${generateFiles})` + ); + + const result = await removeSubtask( + tasksPath, + args.id, + convertToTask, + generateFiles + ); + + // Restore normal logging + disableSilentMode(); + + if (convertToTask && result) { + // Return info about the converted task + return { + success: true, + data: { + message: `Subtask ${args.id} successfully converted to task #${result.id}`, + task: result + } + }; + } else { + // Return simple success message for deletion + return { + success: true, + data: { + message: `Subtask ${args.id} successfully removed` + } + }; + } + } catch (error) { + // Ensure silent mode is disabled even if an outer error occurs + disableSilentMode(); + + log.error(`Error in removeSubtaskDirect: ${error.message}`); + return { + success: false, + error: { + code: 'CORE_FUNCTION_ERROR', + message: error.message + } + }; + } +} diff --git a/mcp-server/src/core/direct-functions/remove-task.js b/mcp-server/src/core/direct-functions/remove-task.js index 2cc240c4..0e98ac92 100644 --- a/mcp-server/src/core/direct-functions/remove-task.js +++ b/mcp-server/src/core/direct-functions/remove-task.js @@ -4,7 +4,10 @@ */ import { removeTask } from '../../../../scripts/modules/task-manager.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; /** @@ -15,90 +18,90 @@ import { findTasksJsonPath } from '../utils/path-utils.js'; * @returns {Promise} - Remove task result { success: boolean, data?: any, error?: { code: string, message: string }, fromCache: false } */ export async function removeTaskDirect(args, log) { - try { - // Find the tasks path first - let tasksPath; - try { - tasksPath = findTasksJsonPath(args, log); - } catch (error) { - log.error(`Tasks file not found: ${error.message}`); - return { - success: false, - error: { - code: 'FILE_NOT_FOUND_ERROR', - message: error.message - }, - fromCache: false - }; - } - - // Validate task ID parameter - const taskId = args.id; - if (!taskId) { - log.error('Task ID is required'); - return { - success: false, - error: { - code: 'INPUT_VALIDATION_ERROR', - message: 'Task ID is required' - }, - fromCache: false - }; - } - - // Skip confirmation in the direct function since it's handled by the client - log.info(`Removing task with ID: ${taskId} from ${tasksPath}`); - - try { - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - // Call the core removeTask function - const result = await removeTask(tasksPath, taskId); - - // Restore normal logging - disableSilentMode(); - - log.info(`Successfully removed task: ${taskId}`); - - // Return the result - return { - success: true, - data: { - message: result.message, - taskId: taskId, - tasksPath: tasksPath, - removedTask: result.removedTask - }, - fromCache: false - }; - } catch (error) { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - - log.error(`Error removing task: ${error.message}`); - return { - success: false, - error: { - code: error.code || 'REMOVE_TASK_ERROR', - message: error.message || 'Failed to remove task' - }, - fromCache: false - }; - } - } catch (error) { - // Ensure silent mode is disabled even if an outer error occurs - disableSilentMode(); - - // Catch any unexpected errors - log.error(`Unexpected error in removeTaskDirect: ${error.message}`); - return { - success: false, - error: { - code: 'UNEXPECTED_ERROR', - message: error.message - }, - fromCache: false - }; - } -} \ No newline at end of file + try { + // Find the tasks path first + let tasksPath; + try { + tasksPath = findTasksJsonPath(args, log); + } catch (error) { + log.error(`Tasks file not found: ${error.message}`); + return { + success: false, + error: { + code: 'FILE_NOT_FOUND_ERROR', + message: error.message + }, + fromCache: false + }; + } + + // Validate task ID parameter + const taskId = args.id; + if (!taskId) { + log.error('Task ID is required'); + return { + success: false, + error: { + code: 'INPUT_VALIDATION_ERROR', + message: 'Task ID is required' + }, + fromCache: false + }; + } + + // Skip confirmation in the direct function since it's handled by the client + log.info(`Removing task with ID: ${taskId} from ${tasksPath}`); + + try { + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); + + // Call the core removeTask function + const result = await removeTask(tasksPath, taskId); + + // Restore normal logging + disableSilentMode(); + + log.info(`Successfully removed task: ${taskId}`); + + // Return the result + return { + success: true, + data: { + message: result.message, + taskId: taskId, + tasksPath: tasksPath, + removedTask: result.removedTask + }, + fromCache: false + }; + } catch (error) { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + + log.error(`Error removing task: ${error.message}`); + return { + success: false, + error: { + code: error.code || 'REMOVE_TASK_ERROR', + message: error.message || 'Failed to remove task' + }, + fromCache: false + }; + } + } catch (error) { + // Ensure silent mode is disabled even if an outer error occurs + disableSilentMode(); + + // Catch any unexpected errors + log.error(`Unexpected error in removeTaskDirect: ${error.message}`); + return { + success: false, + error: { + code: 'UNEXPECTED_ERROR', + message: error.message + }, + fromCache: false + }; + } +} diff --git a/mcp-server/src/core/direct-functions/set-task-status.js b/mcp-server/src/core/direct-functions/set-task-status.js index bcb08608..9f52c115 100644 --- a/mcp-server/src/core/direct-functions/set-task-status.js +++ b/mcp-server/src/core/direct-functions/set-task-status.js @@ -5,108 +5,120 @@ import { setTaskStatus } from '../../../../scripts/modules/task-manager.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { enableSilentMode, disableSilentMode, isSilentMode } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode, + isSilentMode +} from '../../../../scripts/modules/utils.js'; /** * Direct function wrapper for setTaskStatus with error handling. - * + * * @param {Object} args - Command arguments containing id, status and file path options. * @param {Object} log - Logger object. * @returns {Promise} - Result object with success status and data/error information. */ export async function setTaskStatusDirect(args, log) { - try { - log.info(`Setting task status with args: ${JSON.stringify(args)}`); - - // Check required parameters - if (!args.id) { - const errorMessage = 'No task ID specified. Please provide a task ID to update.'; - log.error(errorMessage); - return { - success: false, - error: { code: 'MISSING_TASK_ID', message: errorMessage }, - fromCache: false - }; - } - - if (!args.status) { - const errorMessage = 'No status specified. Please provide a new status value.'; - log.error(errorMessage); - return { - success: false, - error: { code: 'MISSING_STATUS', message: errorMessage }, - fromCache: false - }; - } - - // Get tasks file path - let tasksPath; - try { - // The enhanced findTasksJsonPath will now search in parent directories if needed - tasksPath = findTasksJsonPath(args, log); - log.info(`Found tasks file at: ${tasksPath}`); - } catch (error) { - log.error(`Error finding tasks file: ${error.message}`); - return { - success: false, - error: { - code: 'TASKS_FILE_ERROR', - message: `${error.message}\n\nPlease ensure you are in a Task Master project directory or use the --project-root parameter to specify the path to your project.` - }, - fromCache: false - }; - } - - // Execute core setTaskStatus function - const taskId = args.id; - const newStatus = args.status; - - log.info(`Setting task ${taskId} status to "${newStatus}"`); - - // Call the core function with proper silent mode handling - let result; - enableSilentMode(); // Enable silent mode before calling core function - try { - // Call the core function - await setTaskStatus(tasksPath, taskId, newStatus, { mcpLog: log }); - - log.info(`Successfully set task ${taskId} status to ${newStatus}`); - - // Return success data - result = { - success: true, - data: { - message: `Successfully updated task ${taskId} status to "${newStatus}"`, - taskId, - status: newStatus, - tasksPath - }, - fromCache: false // This operation always modifies state and should never be cached - }; - } catch (error) { - log.error(`Error setting task status: ${error.message}`); - result = { - success: false, - error: { code: 'SET_STATUS_ERROR', message: error.message || 'Unknown error setting task status' }, - fromCache: false - }; - } finally { - // ALWAYS restore normal logging in finally block - disableSilentMode(); - } - - return result; - } catch (error) { - // Ensure silent mode is disabled if there was an uncaught error in the outer try block - if (isSilentMode()) { - disableSilentMode(); - } - - log.error(`Error setting task status: ${error.message}`); - return { - success: false, - error: { code: 'SET_STATUS_ERROR', message: error.message || 'Unknown error setting task status' }, - fromCache: false - }; - } -} \ No newline at end of file + try { + log.info(`Setting task status with args: ${JSON.stringify(args)}`); + + // Check required parameters + if (!args.id) { + const errorMessage = + 'No task ID specified. Please provide a task ID to update.'; + log.error(errorMessage); + return { + success: false, + error: { code: 'MISSING_TASK_ID', message: errorMessage }, + fromCache: false + }; + } + + if (!args.status) { + const errorMessage = + 'No status specified. Please provide a new status value.'; + log.error(errorMessage); + return { + success: false, + error: { code: 'MISSING_STATUS', message: errorMessage }, + fromCache: false + }; + } + + // Get tasks file path + let tasksPath; + try { + // The enhanced findTasksJsonPath will now search in parent directories if needed + tasksPath = findTasksJsonPath(args, log); + log.info(`Found tasks file at: ${tasksPath}`); + } catch (error) { + log.error(`Error finding tasks file: ${error.message}`); + return { + success: false, + error: { + code: 'TASKS_FILE_ERROR', + message: `${error.message}\n\nPlease ensure you are in a Task Master project directory or use the --project-root parameter to specify the path to your project.` + }, + fromCache: false + }; + } + + // Execute core setTaskStatus function + const taskId = args.id; + const newStatus = args.status; + + log.info(`Setting task ${taskId} status to "${newStatus}"`); + + // Call the core function with proper silent mode handling + let result; + enableSilentMode(); // Enable silent mode before calling core function + try { + // Call the core function + await setTaskStatus(tasksPath, taskId, newStatus, { mcpLog: log }); + + log.info(`Successfully set task ${taskId} status to ${newStatus}`); + + // Return success data + result = { + success: true, + data: { + message: `Successfully updated task ${taskId} status to "${newStatus}"`, + taskId, + status: newStatus, + tasksPath + }, + fromCache: false // This operation always modifies state and should never be cached + }; + } catch (error) { + log.error(`Error setting task status: ${error.message}`); + result = { + success: false, + error: { + code: 'SET_STATUS_ERROR', + message: error.message || 'Unknown error setting task status' + }, + fromCache: false + }; + } finally { + // ALWAYS restore normal logging in finally block + disableSilentMode(); + } + + return result; + } catch (error) { + // Ensure silent mode is disabled if there was an uncaught error in the outer try block + if (isSilentMode()) { + disableSilentMode(); + } + + log.error(`Error setting task status: ${error.message}`); + return { + success: false, + error: { + code: 'SET_STATUS_ERROR', + message: error.message || 'Unknown error setting task status' + }, + fromCache: false + }; + } +} diff --git a/mcp-server/src/core/direct-functions/show-task.js b/mcp-server/src/core/direct-functions/show-task.js index 3ced2122..adbeb391 100644 --- a/mcp-server/src/core/direct-functions/show-task.js +++ b/mcp-server/src/core/direct-functions/show-task.js @@ -7,7 +7,10 @@ import { findTaskById } from '../../../../scripts/modules/utils.js'; import { readJSON } from '../../../../scripts/modules/utils.js'; import { getCachedOrExecute } from '../../tools/utils.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; /** * Direct function wrapper for showing task details with error handling and caching. @@ -17,120 +20,122 @@ import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules * @returns {Promise} - Task details result { success: boolean, data?: any, error?: { code: string, message: string }, fromCache: boolean } */ export async function showTaskDirect(args, log) { - let tasksPath; - try { - // Find the tasks path first - needed for cache key and execution - tasksPath = findTasksJsonPath(args, log); - } catch (error) { - log.error(`Tasks file not found: ${error.message}`); - return { - success: false, - error: { - code: 'FILE_NOT_FOUND_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) { + log.error(`Tasks file not found: ${error.message}`); + return { + success: false, + error: { + code: 'FILE_NOT_FOUND_ERROR', + message: error.message + }, + fromCache: false + }; + } - // Validate task ID - const taskId = args.id; - if (!taskId) { - log.error('Task ID is required'); - return { - success: false, - error: { - code: 'INPUT_VALIDATION_ERROR', - message: 'Task ID is required' - }, - fromCache: false - }; - } + // Validate task ID + const taskId = args.id; + if (!taskId) { + log.error('Task ID is required'); + return { + success: false, + error: { + code: 'INPUT_VALIDATION_ERROR', + message: 'Task ID is required' + }, + fromCache: false + }; + } - // Generate cache key using task path and ID - const cacheKey = `showTask:${tasksPath}:${taskId}`; - - // Define the action function to be executed on cache miss - const coreShowTaskAction = async () => { - try { - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - log.info(`Retrieving task details for ID: ${taskId} from ${tasksPath}`); - - // Read tasks data - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - return { - success: false, - error: { - code: 'INVALID_TASKS_FILE', - message: `No valid tasks found in ${tasksPath}` - } - }; - } - - // Find the specific task - const task = findTaskById(data.tasks, taskId); - - if (!task) { - return { - success: false, - error: { - code: 'TASK_NOT_FOUND', - message: `Task with ID ${taskId} not found` - } - }; - } - - // Restore normal logging - disableSilentMode(); - - // Return the task data with the full tasks array for reference - // (needed for formatDependenciesWithStatus function in UI) - log.info(`Successfully found task ${taskId}`); - return { - success: true, - data: { - task, - allTasks: data.tasks - } - }; - } catch (error) { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - - log.error(`Error showing task: ${error.message}`); - return { - success: false, - error: { - code: 'CORE_FUNCTION_ERROR', - message: error.message || 'Failed to show task details' - } - }; - } - }; + // Generate cache key using task path and ID + const cacheKey = `showTask:${tasksPath}:${taskId}`; - // Use the caching utility - try { - const result = await getCachedOrExecute({ - cacheKey, - actionFn: coreShowTaskAction, - log - }); - log.info(`showTaskDirect completed. From cache: ${result.fromCache}`); - return result; // Returns { success, data/error, fromCache } - } catch (error) { - // Catch unexpected errors from getCachedOrExecute itself - disableSilentMode(); - log.error(`Unexpected error during getCachedOrExecute for showTask: ${error.message}`); - return { - success: false, - error: { - code: 'UNEXPECTED_ERROR', - message: error.message - }, - fromCache: false - }; - } -} \ No newline at end of file + // Define the action function to be executed on cache miss + const coreShowTaskAction = async () => { + try { + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); + + log.info(`Retrieving task details for ID: ${taskId} from ${tasksPath}`); + + // Read tasks data + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + return { + success: false, + error: { + code: 'INVALID_TASKS_FILE', + message: `No valid tasks found in ${tasksPath}` + } + }; + } + + // Find the specific task + const task = findTaskById(data.tasks, taskId); + + if (!task) { + return { + success: false, + error: { + code: 'TASK_NOT_FOUND', + message: `Task with ID ${taskId} not found` + } + }; + } + + // Restore normal logging + disableSilentMode(); + + // Return the task data with the full tasks array for reference + // (needed for formatDependenciesWithStatus function in UI) + log.info(`Successfully found task ${taskId}`); + return { + success: true, + data: { + task, + allTasks: data.tasks + } + }; + } catch (error) { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + + log.error(`Error showing task: ${error.message}`); + return { + success: false, + error: { + code: 'CORE_FUNCTION_ERROR', + message: error.message || 'Failed to show task details' + } + }; + } + }; + + // Use the caching utility + try { + const result = await getCachedOrExecute({ + cacheKey, + actionFn: coreShowTaskAction, + log + }); + log.info(`showTaskDirect completed. From cache: ${result.fromCache}`); + return result; // Returns { success, data/error, fromCache } + } catch (error) { + // Catch unexpected errors from getCachedOrExecute itself + disableSilentMode(); + log.error( + `Unexpected error during getCachedOrExecute for showTask: ${error.message}` + ); + return { + success: false, + error: { + code: 'UNEXPECTED_ERROR', + message: error.message + }, + fromCache: false + }; + } +} diff --git a/mcp-server/src/core/direct-functions/update-subtask-by-id.js b/mcp-server/src/core/direct-functions/update-subtask-by-id.js index 8c964e78..f8a235ce 100644 --- a/mcp-server/src/core/direct-functions/update-subtask-by-id.js +++ b/mcp-server/src/core/direct-functions/update-subtask-by-id.js @@ -4,167 +4,190 @@ */ import { updateSubtaskById } from '../../../../scripts/modules/task-manager.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { getAnthropicClientForMCP, getPerplexityClientForMCP } from '../utils/ai-client-utils.js'; +import { + getAnthropicClientForMCP, + getPerplexityClientForMCP +} from '../utils/ai-client-utils.js'; /** * Direct function wrapper for updateSubtaskById with error handling. - * + * * @param {Object} args - Command arguments containing id, prompt, useResearch and file path options. * @param {Object} log - Logger object. * @param {Object} context - Context object containing session data. * @returns {Promise} - Result object with success status and data/error information. */ export async function updateSubtaskByIdDirect(args, log, context = {}) { - const { session } = context; // Only extract session, not reportProgress - - try { - log.info(`Updating subtask with args: ${JSON.stringify(args)}`); - - // Check required parameters - if (!args.id) { - const errorMessage = 'No subtask ID specified. Please provide a subtask ID to update.'; - log.error(errorMessage); - return { - success: false, - error: { code: 'MISSING_SUBTASK_ID', message: errorMessage }, - fromCache: false - }; - } - - if (!args.prompt) { - const errorMessage = 'No prompt specified. Please provide a prompt with information to add to the subtask.'; - log.error(errorMessage); - return { - success: false, - error: { code: 'MISSING_PROMPT', message: errorMessage }, - fromCache: false - }; - } - - // Validate subtask ID format - const subtaskId = args.id; - if (typeof subtaskId !== 'string' && typeof subtaskId !== 'number') { - const errorMessage = `Invalid subtask ID type: ${typeof subtaskId}. Subtask ID must be a string or number.`; - log.error(errorMessage); - return { - success: false, - error: { code: 'INVALID_SUBTASK_ID_TYPE', message: errorMessage }, - fromCache: false - }; - } - - const subtaskIdStr = String(subtaskId); - if (!subtaskIdStr.includes('.')) { - const errorMessage = `Invalid subtask ID format: ${subtaskIdStr}. Subtask ID must be in format "parentId.subtaskId" (e.g., "5.2").`; - log.error(errorMessage); - return { - success: false, - error: { code: 'INVALID_SUBTASK_ID_FORMAT', message: errorMessage }, - fromCache: false - }; - } - - // Get tasks file path - let tasksPath; - try { - tasksPath = findTasksJsonPath(args, log); - } catch (error) { - log.error(`Error finding tasks file: ${error.message}`); - return { - success: false, - error: { code: 'TASKS_FILE_ERROR', message: error.message }, - fromCache: false - }; - } - - // Get research flag - const useResearch = args.research === true; - - log.info(`Updating subtask with ID ${subtaskIdStr} with prompt "${args.prompt}" and research: ${useResearch}`); - - // Initialize the appropriate AI client based on research flag - try { - if (useResearch) { - // Initialize Perplexity client - await getPerplexityClientForMCP(session); - } else { - // Initialize Anthropic client - await getAnthropicClientForMCP(session); - } - } catch (error) { - log.error(`AI client initialization error: ${error.message}`); - return { - success: false, - error: { code: 'AI_CLIENT_ERROR', message: error.message || 'Failed to initialize AI client' }, - fromCache: false - }; - } - - try { - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - // Create a logger wrapper object to handle logging without breaking the mcpLog[level] calls - // This ensures outputFormat is set to 'json' while still supporting proper logging - const logWrapper = { - info: (message) => log.info(message), - warn: (message) => log.warn(message), - error: (message) => log.error(message), - debug: (message) => log.debug && log.debug(message), - success: (message) => log.info(message) // Map success to info if needed - }; - - // Execute core updateSubtaskById function - // Pass both session and logWrapper as mcpLog to ensure outputFormat is 'json' - const updatedSubtask = await updateSubtaskById(tasksPath, subtaskIdStr, args.prompt, useResearch, { - session, - mcpLog: logWrapper - }); - - // Restore normal logging - disableSilentMode(); - - // Handle the case where the subtask couldn't be updated (e.g., already marked as done) - if (!updatedSubtask) { - return { - success: false, - error: { - code: 'SUBTASK_UPDATE_FAILED', - message: 'Failed to update subtask. It may be marked as completed, or another error occurred.' - }, - fromCache: false - }; - } - - // Return the updated subtask information - return { - success: true, - data: { - message: `Successfully updated subtask with ID ${subtaskIdStr}`, - subtaskId: subtaskIdStr, - parentId: subtaskIdStr.split('.')[0], - subtask: updatedSubtask, - tasksPath, - useResearch - }, - fromCache: false // This operation always modifies state and should never be cached - }; - } catch (error) { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - throw error; // Rethrow to be caught by outer catch block - } - } catch (error) { - // Ensure silent mode is disabled - disableSilentMode(); - - log.error(`Error updating subtask by ID: ${error.message}`); - return { - success: false, - error: { code: 'UPDATE_SUBTASK_ERROR', message: error.message || 'Unknown error updating subtask' }, - fromCache: false - }; - } -} \ No newline at end of file + const { session } = context; // Only extract session, not reportProgress + + try { + log.info(`Updating subtask with args: ${JSON.stringify(args)}`); + + // Check required parameters + if (!args.id) { + const errorMessage = + 'No subtask ID specified. Please provide a subtask ID to update.'; + log.error(errorMessage); + return { + success: false, + error: { code: 'MISSING_SUBTASK_ID', message: errorMessage }, + fromCache: false + }; + } + + if (!args.prompt) { + const errorMessage = + 'No prompt specified. Please provide a prompt with information to add to the subtask.'; + log.error(errorMessage); + return { + success: false, + error: { code: 'MISSING_PROMPT', message: errorMessage }, + fromCache: false + }; + } + + // Validate subtask ID format + const subtaskId = args.id; + if (typeof subtaskId !== 'string' && typeof subtaskId !== 'number') { + const errorMessage = `Invalid subtask ID type: ${typeof subtaskId}. Subtask ID must be a string or number.`; + log.error(errorMessage); + return { + success: false, + error: { code: 'INVALID_SUBTASK_ID_TYPE', message: errorMessage }, + fromCache: false + }; + } + + const subtaskIdStr = String(subtaskId); + if (!subtaskIdStr.includes('.')) { + const errorMessage = `Invalid subtask ID format: ${subtaskIdStr}. Subtask ID must be in format "parentId.subtaskId" (e.g., "5.2").`; + log.error(errorMessage); + return { + success: false, + error: { code: 'INVALID_SUBTASK_ID_FORMAT', message: errorMessage }, + fromCache: false + }; + } + + // Get tasks file path + let tasksPath; + try { + tasksPath = findTasksJsonPath(args, log); + } catch (error) { + log.error(`Error finding tasks file: ${error.message}`); + return { + success: false, + error: { code: 'TASKS_FILE_ERROR', message: error.message }, + fromCache: false + }; + } + + // Get research flag + const useResearch = args.research === true; + + log.info( + `Updating subtask with ID ${subtaskIdStr} with prompt "${args.prompt}" and research: ${useResearch}` + ); + + // Initialize the appropriate AI client based on research flag + try { + if (useResearch) { + // Initialize Perplexity client + await getPerplexityClientForMCP(session); + } else { + // Initialize Anthropic client + await getAnthropicClientForMCP(session); + } + } catch (error) { + log.error(`AI client initialization error: ${error.message}`); + return { + success: false, + error: { + code: 'AI_CLIENT_ERROR', + message: error.message || 'Failed to initialize AI client' + }, + fromCache: false + }; + } + + try { + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); + + // Create a logger wrapper object to handle logging without breaking the mcpLog[level] calls + // This ensures outputFormat is set to 'json' while still supporting proper logging + const logWrapper = { + info: (message) => log.info(message), + warn: (message) => log.warn(message), + error: (message) => log.error(message), + debug: (message) => log.debug && log.debug(message), + success: (message) => log.info(message) // Map success to info if needed + }; + + // Execute core updateSubtaskById function + // Pass both session and logWrapper as mcpLog to ensure outputFormat is 'json' + const updatedSubtask = await updateSubtaskById( + tasksPath, + subtaskIdStr, + args.prompt, + useResearch, + { + session, + mcpLog: logWrapper + } + ); + + // Restore normal logging + disableSilentMode(); + + // Handle the case where the subtask couldn't be updated (e.g., already marked as done) + if (!updatedSubtask) { + return { + success: false, + error: { + code: 'SUBTASK_UPDATE_FAILED', + message: + 'Failed to update subtask. It may be marked as completed, or another error occurred.' + }, + fromCache: false + }; + } + + // Return the updated subtask information + return { + success: true, + data: { + message: `Successfully updated subtask with ID ${subtaskIdStr}`, + subtaskId: subtaskIdStr, + parentId: subtaskIdStr.split('.')[0], + subtask: updatedSubtask, + tasksPath, + useResearch + }, + fromCache: false // This operation always modifies state and should never be cached + }; + } catch (error) { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + throw error; // Rethrow to be caught by outer catch block + } + } catch (error) { + // Ensure silent mode is disabled + disableSilentMode(); + + log.error(`Error updating subtask by ID: ${error.message}`); + return { + success: false, + error: { + code: 'UPDATE_SUBTASK_ERROR', + message: error.message || 'Unknown error updating subtask' + }, + fromCache: false + }; + } +} diff --git a/mcp-server/src/core/direct-functions/update-task-by-id.js b/mcp-server/src/core/direct-functions/update-task-by-id.js index 36fac855..98c368d2 100644 --- a/mcp-server/src/core/direct-functions/update-task-by-id.js +++ b/mcp-server/src/core/direct-functions/update-task-by-id.js @@ -5,168 +5,181 @@ import { updateTaskById } from '../../../../scripts/modules/task-manager.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; -import { - getAnthropicClientForMCP, - getPerplexityClientForMCP +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; +import { + getAnthropicClientForMCP, + getPerplexityClientForMCP } from '../utils/ai-client-utils.js'; /** * Direct function wrapper for updateTaskById with error handling. - * + * * @param {Object} args - Command arguments containing id, prompt, useResearch and file path options. * @param {Object} log - Logger object. * @param {Object} context - Context object containing session data. * @returns {Promise} - Result object with success status and data/error information. */ export async function updateTaskByIdDirect(args, log, context = {}) { - const { session } = context; // Only extract session, not reportProgress - - try { - log.info(`Updating task with args: ${JSON.stringify(args)}`); - - // Check required parameters - if (!args.id) { - const errorMessage = 'No task ID specified. Please provide a task ID to update.'; - log.error(errorMessage); - return { - success: false, - error: { code: 'MISSING_TASK_ID', message: errorMessage }, - fromCache: false - }; - } - - if (!args.prompt) { - const errorMessage = 'No prompt specified. Please provide a prompt with new information for the task update.'; - log.error(errorMessage); - return { - success: false, - error: { code: 'MISSING_PROMPT', message: errorMessage }, - fromCache: false - }; - } - - // Parse taskId - handle both string and number values - let taskId; - if (typeof args.id === 'string') { - // Handle subtask IDs (e.g., "5.2") - if (args.id.includes('.')) { - taskId = args.id; // Keep as string for subtask IDs - } else { - // Parse as integer for main task IDs - taskId = parseInt(args.id, 10); - if (isNaN(taskId)) { - const errorMessage = `Invalid task ID: ${args.id}. Task ID must be a positive integer or subtask ID (e.g., "5.2").`; - log.error(errorMessage); - return { - success: false, - error: { code: 'INVALID_TASK_ID', message: errorMessage }, - fromCache: false - }; - } - } - } else { - taskId = args.id; - } - - // Get tasks file path - let tasksPath; - try { - tasksPath = findTasksJsonPath(args, log); - } catch (error) { - log.error(`Error finding tasks file: ${error.message}`); - return { - success: false, - error: { code: 'TASKS_FILE_ERROR', message: error.message }, - fromCache: false - }; - } - - // Get research flag - const useResearch = args.research === true; - - // Initialize appropriate AI client based on research flag - let aiClient; - try { - if (useResearch) { - log.info('Using Perplexity AI for research-backed task update'); - aiClient = await getPerplexityClientForMCP(session, log); - } else { - log.info('Using Claude AI for task update'); - aiClient = getAnthropicClientForMCP(session, log); - } - } catch (error) { - log.error(`Failed to initialize AI client: ${error.message}`); - return { - success: false, - error: { - code: 'AI_CLIENT_ERROR', - message: `Cannot initialize AI client: ${error.message}` - }, - fromCache: false - }; - } - - log.info(`Updating task with ID ${taskId} with prompt "${args.prompt}" and research: ${useResearch}`); - - try { - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - // Create a logger wrapper that matches what updateTaskById expects - const logWrapper = { - info: (message) => log.info(message), - warn: (message) => log.warn(message), - error: (message) => log.error(message), - debug: (message) => log.debug && log.debug(message), - success: (message) => log.info(message) // Map success to info since many loggers don't have success - }; - - // Execute core updateTaskById function with proper parameters - await updateTaskById( - tasksPath, - taskId, - args.prompt, - useResearch, - { - mcpLog: logWrapper, // Use our wrapper object that has the expected method structure - session - }, - 'json' - ); - - // Since updateTaskById doesn't return a value but modifies the tasks file, - // we'll return a success message - return { - success: true, - data: { - message: `Successfully updated task with ID ${taskId} based on the prompt`, - taskId, - tasksPath, - useResearch - }, - fromCache: false // This operation always modifies state and should never be cached - }; - } catch (error) { - log.error(`Error updating task by ID: ${error.message}`); - return { - success: false, - error: { code: 'UPDATE_TASK_ERROR', message: error.message || 'Unknown error updating task' }, - fromCache: false - }; - } finally { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - } - } catch (error) { - // Ensure silent mode is disabled - disableSilentMode(); - - log.error(`Error updating task by ID: ${error.message}`); - return { - success: false, - error: { code: 'UPDATE_TASK_ERROR', message: error.message || 'Unknown error updating task' }, - fromCache: false - }; - } -} \ No newline at end of file + const { session } = context; // Only extract session, not reportProgress + + try { + log.info(`Updating task with args: ${JSON.stringify(args)}`); + + // Check required parameters + if (!args.id) { + const errorMessage = + 'No task ID specified. Please provide a task ID to update.'; + log.error(errorMessage); + return { + success: false, + error: { code: 'MISSING_TASK_ID', message: errorMessage }, + fromCache: false + }; + } + + if (!args.prompt) { + const errorMessage = + 'No prompt specified. Please provide a prompt with new information for the task update.'; + log.error(errorMessage); + return { + success: false, + error: { code: 'MISSING_PROMPT', message: errorMessage }, + fromCache: false + }; + } + + // Parse taskId - handle both string and number values + let taskId; + if (typeof args.id === 'string') { + // Handle subtask IDs (e.g., "5.2") + if (args.id.includes('.')) { + taskId = args.id; // Keep as string for subtask IDs + } else { + // Parse as integer for main task IDs + taskId = parseInt(args.id, 10); + if (isNaN(taskId)) { + const errorMessage = `Invalid task ID: ${args.id}. Task ID must be a positive integer or subtask ID (e.g., "5.2").`; + log.error(errorMessage); + return { + success: false, + error: { code: 'INVALID_TASK_ID', message: errorMessage }, + fromCache: false + }; + } + } + } else { + taskId = args.id; + } + + // Get tasks file path + let tasksPath; + try { + tasksPath = findTasksJsonPath(args, log); + } catch (error) { + log.error(`Error finding tasks file: ${error.message}`); + return { + success: false, + error: { code: 'TASKS_FILE_ERROR', message: error.message }, + fromCache: false + }; + } + + // Get research flag + const useResearch = args.research === true; + + // Initialize appropriate AI client based on research flag + let aiClient; + try { + if (useResearch) { + log.info('Using Perplexity AI for research-backed task update'); + aiClient = await getPerplexityClientForMCP(session, log); + } else { + log.info('Using Claude AI for task update'); + aiClient = getAnthropicClientForMCP(session, log); + } + } catch (error) { + log.error(`Failed to initialize AI client: ${error.message}`); + return { + success: false, + error: { + code: 'AI_CLIENT_ERROR', + message: `Cannot initialize AI client: ${error.message}` + }, + fromCache: false + }; + } + + log.info( + `Updating task with ID ${taskId} with prompt "${args.prompt}" and research: ${useResearch}` + ); + + try { + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); + + // Create a logger wrapper that matches what updateTaskById expects + const logWrapper = { + info: (message) => log.info(message), + warn: (message) => log.warn(message), + error: (message) => log.error(message), + debug: (message) => log.debug && log.debug(message), + success: (message) => log.info(message) // Map success to info since many loggers don't have success + }; + + // Execute core updateTaskById function with proper parameters + await updateTaskById( + tasksPath, + taskId, + args.prompt, + useResearch, + { + mcpLog: logWrapper, // Use our wrapper object that has the expected method structure + session + }, + 'json' + ); + + // Since updateTaskById doesn't return a value but modifies the tasks file, + // we'll return a success message + return { + success: true, + data: { + message: `Successfully updated task with ID ${taskId} based on the prompt`, + taskId, + tasksPath, + useResearch + }, + fromCache: false // This operation always modifies state and should never be cached + }; + } catch (error) { + log.error(`Error updating task by ID: ${error.message}`); + return { + success: false, + error: { + code: 'UPDATE_TASK_ERROR', + message: error.message || 'Unknown error updating task' + }, + fromCache: false + }; + } finally { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + } + } catch (error) { + // Ensure silent mode is disabled + disableSilentMode(); + + log.error(`Error updating task by ID: ${error.message}`); + return { + success: false, + error: { + code: 'UPDATE_TASK_ERROR', + message: error.message || 'Unknown error updating task' + }, + fromCache: false + }; + } +} diff --git a/mcp-server/src/core/direct-functions/update-tasks.js b/mcp-server/src/core/direct-functions/update-tasks.js index fab2ce86..7a5e925e 100644 --- a/mcp-server/src/core/direct-functions/update-tasks.js +++ b/mcp-server/src/core/direct-functions/update-tasks.js @@ -4,168 +4,177 @@ */ import { updateTasks } from '../../../../scripts/modules/task-manager.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { - getAnthropicClientForMCP, - getPerplexityClientForMCP +import { + getAnthropicClientForMCP, + getPerplexityClientForMCP } from '../utils/ai-client-utils.js'; /** * Direct function wrapper for updating tasks based on new context/prompt. - * + * * @param {Object} args - Command arguments containing fromId, prompt, useResearch and file path options. * @param {Object} log - Logger object. * @param {Object} context - Context object containing session data. * @returns {Promise} - Result object with success status and data/error information. */ export async function updateTasksDirect(args, log, context = {}) { - const { session } = context; // Only extract session, not reportProgress - - try { - log.info(`Updating tasks with args: ${JSON.stringify(args)}`); - - // Check for the common mistake of using 'id' instead of 'from' - if (args.id !== undefined && args.from === undefined) { - const errorMessage = "You specified 'id' parameter but 'update' requires 'from' parameter. Use 'from' for this tool or use 'update_task' tool if you want to update a single task."; - log.error(errorMessage); - return { - success: false, - error: { - code: 'PARAMETER_MISMATCH', - message: errorMessage, - suggestion: "Use 'from' parameter instead of 'id', or use the 'update_task' tool for single task updates" - }, - fromCache: false - }; - } - - // Check required parameters - if (!args.from) { - const errorMessage = 'No from ID specified. Please provide a task ID to start updating from.'; - log.error(errorMessage); - return { - success: false, - error: { code: 'MISSING_FROM_ID', message: errorMessage }, - fromCache: false - }; - } - - if (!args.prompt) { - const errorMessage = 'No prompt specified. Please provide a prompt with new context for task updates.'; - log.error(errorMessage); - return { - success: false, - error: { code: 'MISSING_PROMPT', message: errorMessage }, - fromCache: false - }; - } - - // Parse fromId - handle both string and number values - let fromId; - if (typeof args.from === 'string') { - fromId = parseInt(args.from, 10); - if (isNaN(fromId)) { - const errorMessage = `Invalid from ID: ${args.from}. Task ID must be a positive integer.`; - log.error(errorMessage); - return { - success: false, - error: { code: 'INVALID_FROM_ID', message: errorMessage }, - fromCache: false - }; - } - } else { - fromId = args.from; - } - - // Get tasks file path - let tasksPath; - try { - tasksPath = findTasksJsonPath(args, log); - } catch (error) { - log.error(`Error finding tasks file: ${error.message}`); - return { - success: false, - error: { code: 'TASKS_FILE_ERROR', message: error.message }, - fromCache: false - }; - } - - // Get research flag - const useResearch = args.research === true; - - // Initialize appropriate AI client based on research flag - let aiClient; - try { - if (useResearch) { - log.info('Using Perplexity AI for research-backed task updates'); - aiClient = await getPerplexityClientForMCP(session, log); - } else { - log.info('Using Claude AI for task updates'); - aiClient = getAnthropicClientForMCP(session, log); - } - } catch (error) { - log.error(`Failed to initialize AI client: ${error.message}`); - return { - success: false, - error: { - code: 'AI_CLIENT_ERROR', - message: `Cannot initialize AI client: ${error.message}` - }, - fromCache: false - }; - } - - log.info(`Updating tasks from ID ${fromId} with prompt "${args.prompt}" and research: ${useResearch}`); - - try { - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - // Execute core updateTasks function, passing the AI client and session - await updateTasks( - tasksPath, - fromId, - args.prompt, - useResearch, - { - mcpLog: log, - session - } - ); - - // Since updateTasks doesn't return a value but modifies the tasks file, - // we'll return a success message - return { - success: true, - data: { - message: `Successfully updated tasks from ID ${fromId} based on the prompt`, - fromId, - tasksPath, - useResearch - }, - fromCache: false // This operation always modifies state and should never be cached - }; - } catch (error) { - log.error(`Error updating tasks: ${error.message}`); - return { - success: false, - error: { code: 'UPDATE_TASKS_ERROR', message: error.message || 'Unknown error updating tasks' }, - fromCache: false - }; - } finally { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - } - } catch (error) { - // Ensure silent mode is disabled - disableSilentMode(); - - log.error(`Error updating tasks: ${error.message}`); - return { - success: false, - error: { code: 'UPDATE_TASKS_ERROR', message: error.message || 'Unknown error updating tasks' }, - fromCache: false - }; - } -} \ No newline at end of file + const { session } = context; // Only extract session, not reportProgress + + try { + log.info(`Updating tasks with args: ${JSON.stringify(args)}`); + + // Check for the common mistake of using 'id' instead of 'from' + if (args.id !== undefined && args.from === undefined) { + const errorMessage = + "You specified 'id' parameter but 'update' requires 'from' parameter. Use 'from' for this tool or use 'update_task' tool if you want to update a single task."; + log.error(errorMessage); + return { + success: false, + error: { + code: 'PARAMETER_MISMATCH', + message: errorMessage, + suggestion: + "Use 'from' parameter instead of 'id', or use the 'update_task' tool for single task updates" + }, + fromCache: false + }; + } + + // Check required parameters + if (!args.from) { + const errorMessage = + 'No from ID specified. Please provide a task ID to start updating from.'; + log.error(errorMessage); + return { + success: false, + error: { code: 'MISSING_FROM_ID', message: errorMessage }, + fromCache: false + }; + } + + if (!args.prompt) { + const errorMessage = + 'No prompt specified. Please provide a prompt with new context for task updates.'; + log.error(errorMessage); + return { + success: false, + error: { code: 'MISSING_PROMPT', message: errorMessage }, + fromCache: false + }; + } + + // Parse fromId - handle both string and number values + let fromId; + if (typeof args.from === 'string') { + fromId = parseInt(args.from, 10); + if (isNaN(fromId)) { + const errorMessage = `Invalid from ID: ${args.from}. Task ID must be a positive integer.`; + log.error(errorMessage); + return { + success: false, + error: { code: 'INVALID_FROM_ID', message: errorMessage }, + fromCache: false + }; + } + } else { + fromId = args.from; + } + + // Get tasks file path + let tasksPath; + try { + tasksPath = findTasksJsonPath(args, log); + } catch (error) { + log.error(`Error finding tasks file: ${error.message}`); + return { + success: false, + error: { code: 'TASKS_FILE_ERROR', message: error.message }, + fromCache: false + }; + } + + // Get research flag + const useResearch = args.research === true; + + // Initialize appropriate AI client based on research flag + let aiClient; + try { + if (useResearch) { + log.info('Using Perplexity AI for research-backed task updates'); + aiClient = await getPerplexityClientForMCP(session, log); + } else { + log.info('Using Claude AI for task updates'); + aiClient = getAnthropicClientForMCP(session, log); + } + } catch (error) { + log.error(`Failed to initialize AI client: ${error.message}`); + return { + success: false, + error: { + code: 'AI_CLIENT_ERROR', + message: `Cannot initialize AI client: ${error.message}` + }, + fromCache: false + }; + } + + log.info( + `Updating tasks from ID ${fromId} with prompt "${args.prompt}" and research: ${useResearch}` + ); + + try { + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); + + // Execute core updateTasks function, passing the AI client and session + await updateTasks(tasksPath, fromId, args.prompt, useResearch, { + mcpLog: log, + session + }); + + // Since updateTasks doesn't return a value but modifies the tasks file, + // we'll return a success message + return { + success: true, + data: { + message: `Successfully updated tasks from ID ${fromId} based on the prompt`, + fromId, + tasksPath, + useResearch + }, + fromCache: false // This operation always modifies state and should never be cached + }; + } catch (error) { + log.error(`Error updating tasks: ${error.message}`); + return { + success: false, + error: { + code: 'UPDATE_TASKS_ERROR', + message: error.message || 'Unknown error updating tasks' + }, + fromCache: false + }; + } finally { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + } + } catch (error) { + // Ensure silent mode is disabled + disableSilentMode(); + + log.error(`Error updating tasks: ${error.message}`); + return { + success: false, + error: { + code: 'UPDATE_TASKS_ERROR', + message: error.message || 'Unknown error updating tasks' + }, + fromCache: false + }; + } +} diff --git a/mcp-server/src/core/direct-functions/validate-dependencies.js b/mcp-server/src/core/direct-functions/validate-dependencies.js index 7044cbd7..487aa08e 100644 --- a/mcp-server/src/core/direct-functions/validate-dependencies.js +++ b/mcp-server/src/core/direct-functions/validate-dependencies.js @@ -4,7 +4,10 @@ import { validateDependenciesCommand } from '../../../../scripts/modules/dependency-manager.js'; import { findTasksJsonPath } from '../utils/path-utils.js'; -import { enableSilentMode, disableSilentMode } from '../../../../scripts/modules/utils.js'; +import { + enableSilentMode, + disableSilentMode +} from '../../../../scripts/modules/utils.js'; import fs from 'fs'; /** @@ -16,50 +19,50 @@ import fs from 'fs'; * @returns {Promise<{success: boolean, data?: Object, error?: {code: string, message: string}}>} */ export async function validateDependenciesDirect(args, log) { - try { - log.info(`Validating dependencies in tasks...`); - - // Find the tasks.json path - const tasksPath = findTasksJsonPath(args, log); - - // Verify the file exists - if (!fs.existsSync(tasksPath)) { - return { - success: false, - error: { - code: 'FILE_NOT_FOUND', - message: `Tasks file not found at ${tasksPath}` - } - }; - } - - // Enable silent mode to prevent console logs from interfering with JSON response - enableSilentMode(); - - // Call the original command function - await validateDependenciesCommand(tasksPath); - - // Restore normal logging - disableSilentMode(); - - return { - success: true, - data: { - message: 'Dependencies validated successfully', - tasksPath - } - }; - } catch (error) { - // Make sure to restore normal logging even if there's an error - disableSilentMode(); - - log.error(`Error validating dependencies: ${error.message}`); - return { - success: false, - error: { - code: 'VALIDATION_ERROR', - message: error.message - } - }; - } -} \ No newline at end of file + try { + log.info(`Validating dependencies in tasks...`); + + // Find the tasks.json path + const tasksPath = findTasksJsonPath(args, log); + + // Verify the file exists + if (!fs.existsSync(tasksPath)) { + return { + success: false, + error: { + code: 'FILE_NOT_FOUND', + message: `Tasks file not found at ${tasksPath}` + } + }; + } + + // Enable silent mode to prevent console logs from interfering with JSON response + enableSilentMode(); + + // Call the original command function + await validateDependenciesCommand(tasksPath); + + // Restore normal logging + disableSilentMode(); + + return { + success: true, + data: { + message: 'Dependencies validated successfully', + tasksPath + } + }; + } catch (error) { + // Make sure to restore normal logging even if there's an error + disableSilentMode(); + + log.error(`Error validating dependencies: ${error.message}`); + return { + success: false, + error: { + code: 'VALIDATION_ERROR', + message: error.message + } + }; + } +} diff --git a/mcp-server/src/core/task-master-core.js b/mcp-server/src/core/task-master-core.js index 862439ab..2299a503 100644 --- a/mcp-server/src/core/task-master-core.js +++ b/mcp-server/src/core/task-master-core.js @@ -33,64 +33,64 @@ import { removeTaskDirect } from './direct-functions/remove-task.js'; export { findTasksJsonPath } from './utils/path-utils.js'; // Re-export AI client utilities -export { - getAnthropicClientForMCP, - getPerplexityClientForMCP, - getModelConfig, - getBestAvailableAIModel, - handleClaudeError +export { + getAnthropicClientForMCP, + getPerplexityClientForMCP, + getModelConfig, + getBestAvailableAIModel, + handleClaudeError } from './utils/ai-client-utils.js'; // Use Map for potential future enhancements like introspection or dynamic dispatch export const directFunctions = new Map([ - ['listTasksDirect', listTasksDirect], - ['getCacheStatsDirect', getCacheStatsDirect], - ['parsePRDDirect', parsePRDDirect], - ['updateTasksDirect', updateTasksDirect], - ['updateTaskByIdDirect', updateTaskByIdDirect], - ['updateSubtaskByIdDirect', updateSubtaskByIdDirect], - ['generateTaskFilesDirect', generateTaskFilesDirect], - ['setTaskStatusDirect', setTaskStatusDirect], - ['showTaskDirect', showTaskDirect], - ['nextTaskDirect', nextTaskDirect], - ['expandTaskDirect', expandTaskDirect], - ['addTaskDirect', addTaskDirect], - ['addSubtaskDirect', addSubtaskDirect], - ['removeSubtaskDirect', removeSubtaskDirect], - ['analyzeTaskComplexityDirect', analyzeTaskComplexityDirect], - ['clearSubtasksDirect', clearSubtasksDirect], - ['expandAllTasksDirect', expandAllTasksDirect], - ['removeDependencyDirect', removeDependencyDirect], - ['validateDependenciesDirect', validateDependenciesDirect], - ['fixDependenciesDirect', fixDependenciesDirect], - ['complexityReportDirect', complexityReportDirect], - ['addDependencyDirect', addDependencyDirect], - ['removeTaskDirect', removeTaskDirect] + ['listTasksDirect', listTasksDirect], + ['getCacheStatsDirect', getCacheStatsDirect], + ['parsePRDDirect', parsePRDDirect], + ['updateTasksDirect', updateTasksDirect], + ['updateTaskByIdDirect', updateTaskByIdDirect], + ['updateSubtaskByIdDirect', updateSubtaskByIdDirect], + ['generateTaskFilesDirect', generateTaskFilesDirect], + ['setTaskStatusDirect', setTaskStatusDirect], + ['showTaskDirect', showTaskDirect], + ['nextTaskDirect', nextTaskDirect], + ['expandTaskDirect', expandTaskDirect], + ['addTaskDirect', addTaskDirect], + ['addSubtaskDirect', addSubtaskDirect], + ['removeSubtaskDirect', removeSubtaskDirect], + ['analyzeTaskComplexityDirect', analyzeTaskComplexityDirect], + ['clearSubtasksDirect', clearSubtasksDirect], + ['expandAllTasksDirect', expandAllTasksDirect], + ['removeDependencyDirect', removeDependencyDirect], + ['validateDependenciesDirect', validateDependenciesDirect], + ['fixDependenciesDirect', fixDependenciesDirect], + ['complexityReportDirect', complexityReportDirect], + ['addDependencyDirect', addDependencyDirect], + ['removeTaskDirect', removeTaskDirect] ]); // Re-export all direct function implementations export { - listTasksDirect, - getCacheStatsDirect, - parsePRDDirect, - updateTasksDirect, - updateTaskByIdDirect, - updateSubtaskByIdDirect, - generateTaskFilesDirect, - setTaskStatusDirect, - showTaskDirect, - nextTaskDirect, - expandTaskDirect, - addTaskDirect, - addSubtaskDirect, - removeSubtaskDirect, - analyzeTaskComplexityDirect, - clearSubtasksDirect, - expandAllTasksDirect, - removeDependencyDirect, - validateDependenciesDirect, - fixDependenciesDirect, - complexityReportDirect, - addDependencyDirect, - removeTaskDirect -}; \ No newline at end of file + listTasksDirect, + getCacheStatsDirect, + parsePRDDirect, + updateTasksDirect, + updateTaskByIdDirect, + updateSubtaskByIdDirect, + generateTaskFilesDirect, + setTaskStatusDirect, + showTaskDirect, + nextTaskDirect, + expandTaskDirect, + addTaskDirect, + addSubtaskDirect, + removeSubtaskDirect, + analyzeTaskComplexityDirect, + clearSubtasksDirect, + expandAllTasksDirect, + removeDependencyDirect, + validateDependenciesDirect, + fixDependenciesDirect, + complexityReportDirect, + addDependencyDirect, + removeTaskDirect +}; diff --git a/mcp-server/src/core/utils/ai-client-utils.js b/mcp-server/src/core/utils/ai-client-utils.js index 0ad0e9c5..57250d09 100644 --- a/mcp-server/src/core/utils/ai-client-utils.js +++ b/mcp-server/src/core/utils/ai-client-utils.js @@ -11,9 +11,9 @@ dotenv.config(); // Default model configuration from CLI environment const DEFAULT_MODEL_CONFIG = { - model: 'claude-3-7-sonnet-20250219', - maxTokens: 64000, - temperature: 0.2 + model: 'claude-3-7-sonnet-20250219', + maxTokens: 64000, + temperature: 0.2 }; /** @@ -24,25 +24,28 @@ const DEFAULT_MODEL_CONFIG = { * @throws {Error} If API key is missing */ export function getAnthropicClientForMCP(session, log = console) { - try { - // Extract API key from session.env or fall back to environment variables - const apiKey = session?.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY; - - if (!apiKey) { - throw new Error('ANTHROPIC_API_KEY not found in session environment or process.env'); - } - - // Initialize and return a new Anthropic client - return new Anthropic({ - apiKey, - defaultHeaders: { - 'anthropic-beta': 'output-128k-2025-02-19' // Include header for increased token limit - } - }); - } catch (error) { - log.error(`Failed to initialize Anthropic client: ${error.message}`); - throw error; - } + try { + // Extract API key from session.env or fall back to environment variables + const apiKey = + session?.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY; + + if (!apiKey) { + throw new Error( + 'ANTHROPIC_API_KEY not found in session environment or process.env' + ); + } + + // Initialize and return a new Anthropic client + return new Anthropic({ + apiKey, + defaultHeaders: { + 'anthropic-beta': 'output-128k-2025-02-19' // Include header for increased token limit + } + }); + } catch (error) { + log.error(`Failed to initialize Anthropic client: ${error.message}`); + throw error; + } } /** @@ -53,26 +56,29 @@ export function getAnthropicClientForMCP(session, log = console) { * @throws {Error} If API key is missing or OpenAI package can't be imported */ export async function getPerplexityClientForMCP(session, log = console) { - try { - // Extract API key from session.env or fall back to environment variables - const apiKey = session?.env?.PERPLEXITY_API_KEY || process.env.PERPLEXITY_API_KEY; - - if (!apiKey) { - throw new Error('PERPLEXITY_API_KEY not found in session environment or process.env'); - } - - // Dynamically import OpenAI (it may not be used in all contexts) - const { default: OpenAI } = await import('openai'); - - // Initialize and return a new OpenAI client configured for Perplexity - return new OpenAI({ - apiKey, - baseURL: 'https://api.perplexity.ai' - }); - } catch (error) { - log.error(`Failed to initialize Perplexity client: ${error.message}`); - throw error; - } + try { + // Extract API key from session.env or fall back to environment variables + const apiKey = + session?.env?.PERPLEXITY_API_KEY || process.env.PERPLEXITY_API_KEY; + + if (!apiKey) { + throw new Error( + 'PERPLEXITY_API_KEY not found in session environment or process.env' + ); + } + + // Dynamically import OpenAI (it may not be used in all contexts) + const { default: OpenAI } = await import('openai'); + + // Initialize and return a new OpenAI client configured for Perplexity + return new OpenAI({ + apiKey, + baseURL: 'https://api.perplexity.ai' + }); + } catch (error) { + log.error(`Failed to initialize Perplexity client: ${error.message}`); + throw error; + } } /** @@ -82,12 +88,12 @@ export async function getPerplexityClientForMCP(session, log = console) { * @returns {Object} Model configuration with model, maxTokens, and temperature */ export function getModelConfig(session, defaults = DEFAULT_MODEL_CONFIG) { - // Get values from session or fall back to defaults - return { - model: session?.env?.MODEL || defaults.model, - maxTokens: parseInt(session?.env?.MAX_TOKENS || defaults.maxTokens), - temperature: parseFloat(session?.env?.TEMPERATURE || defaults.temperature) - }; + // Get values from session or fall back to defaults + return { + model: session?.env?.MODEL || defaults.model, + maxTokens: parseInt(session?.env?.MAX_TOKENS || defaults.maxTokens), + temperature: parseFloat(session?.env?.TEMPERATURE || defaults.temperature) + }; } /** @@ -100,59 +106,78 @@ export function getModelConfig(session, defaults = DEFAULT_MODEL_CONFIG) { * @returns {Promise} Selected model info with type and client * @throws {Error} If no AI models are available */ -export async function getBestAvailableAIModel(session, options = {}, log = console) { - const { requiresResearch = false, claudeOverloaded = false } = options; - - // Test case: When research is needed but no Perplexity, use Claude - if (requiresResearch && - !(session?.env?.PERPLEXITY_API_KEY || process.env.PERPLEXITY_API_KEY) && - (session?.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY)) { - try { - log.warn('Perplexity not available for research, using Claude'); - const client = getAnthropicClientForMCP(session, log); - return { type: 'claude', client }; - } catch (error) { - log.error(`Claude not available: ${error.message}`); - throw new Error('No AI models available for research'); - } - } - - // Regular path: Perplexity for research when available - if (requiresResearch && (session?.env?.PERPLEXITY_API_KEY || process.env.PERPLEXITY_API_KEY)) { - try { - const client = await getPerplexityClientForMCP(session, log); - return { type: 'perplexity', client }; - } catch (error) { - log.warn(`Perplexity not available: ${error.message}`); - // Fall through to Claude as backup - } - } - - // Test case: Claude for overloaded scenario - if (claudeOverloaded && (session?.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY)) { - try { - log.warn('Claude is overloaded but no alternatives are available. Proceeding with Claude anyway.'); - const client = getAnthropicClientForMCP(session, log); - return { type: 'claude', client }; - } catch (error) { - log.error(`Claude not available despite being overloaded: ${error.message}`); - throw new Error('No AI models available'); - } - } - - // Default case: Use Claude when available and not overloaded - if (!claudeOverloaded && (session?.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY)) { - try { - const client = getAnthropicClientForMCP(session, log); - return { type: 'claude', client }; - } catch (error) { - log.warn(`Claude not available: ${error.message}`); - // Fall through to error if no other options - } - } - - // If we got here, no models were successfully initialized - throw new Error('No AI models available. Please check your API keys.'); +export async function getBestAvailableAIModel( + session, + options = {}, + log = console +) { + const { requiresResearch = false, claudeOverloaded = false } = options; + + // Test case: When research is needed but no Perplexity, use Claude + if ( + requiresResearch && + !(session?.env?.PERPLEXITY_API_KEY || process.env.PERPLEXITY_API_KEY) && + (session?.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) + ) { + try { + log.warn('Perplexity not available for research, using Claude'); + const client = getAnthropicClientForMCP(session, log); + return { type: 'claude', client }; + } catch (error) { + log.error(`Claude not available: ${error.message}`); + throw new Error('No AI models available for research'); + } + } + + // Regular path: Perplexity for research when available + if ( + requiresResearch && + (session?.env?.PERPLEXITY_API_KEY || process.env.PERPLEXITY_API_KEY) + ) { + try { + const client = await getPerplexityClientForMCP(session, log); + return { type: 'perplexity', client }; + } catch (error) { + log.warn(`Perplexity not available: ${error.message}`); + // Fall through to Claude as backup + } + } + + // Test case: Claude for overloaded scenario + if ( + claudeOverloaded && + (session?.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) + ) { + try { + log.warn( + 'Claude is overloaded but no alternatives are available. Proceeding with Claude anyway.' + ); + const client = getAnthropicClientForMCP(session, log); + return { type: 'claude', client }; + } catch (error) { + log.error( + `Claude not available despite being overloaded: ${error.message}` + ); + throw new Error('No AI models available'); + } + } + + // Default case: Use Claude when available and not overloaded + if ( + !claudeOverloaded && + (session?.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) + ) { + try { + const client = getAnthropicClientForMCP(session, log); + return { type: 'claude', client }; + } catch (error) { + log.warn(`Claude not available: ${error.message}`); + // Fall through to error if no other options + } + } + + // If we got here, no models were successfully initialized + throw new Error('No AI models available. Please check your API keys.'); } /** @@ -161,28 +186,28 @@ export async function getBestAvailableAIModel(session, options = {}, log = conso * @returns {string} User-friendly error message */ export function handleClaudeError(error) { - // Check if it's a structured error response - if (error.type === 'error' && error.error) { - switch (error.error.type) { - case 'overloaded_error': - 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}`; -} \ No newline at end of file + // Check if it's a structured error response + if (error.type === 'error' && error.error) { + switch (error.error.type) { + case 'overloaded_error': + 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}`; +} diff --git a/mcp-server/src/core/utils/async-manager.js b/mcp-server/src/core/utils/async-manager.js index 5f4c79e1..cf75c8b4 100644 --- a/mcp-server/src/core/utils/async-manager.js +++ b/mcp-server/src/core/utils/async-manager.js @@ -1,213 +1,247 @@ import { v4 as uuidv4 } from 'uuid'; class AsyncOperationManager { - constructor() { - this.operations = new Map(); // Stores active operation state - this.completedOperations = new Map(); // Stores completed operations - this.maxCompletedOperations = 100; // Maximum number of completed operations to store - this.listeners = new Map(); // For potential future notifications - } + constructor() { + this.operations = new Map(); // Stores active operation state + this.completedOperations = new Map(); // Stores completed operations + this.maxCompletedOperations = 100; // Maximum number of completed operations to store + this.listeners = new Map(); // For potential future notifications + } - /** - * Adds an operation to be executed asynchronously. - * @param {Function} operationFn - The async function to execute (e.g., a Direct function). - * @param {Object} args - Arguments to pass to the operationFn. - * @param {Object} context - The MCP tool context { log, reportProgress, session }. - * @returns {string} The unique ID assigned to this operation. - */ - addOperation(operationFn, args, context) { - const operationId = `op-${uuidv4()}`; - const operation = { - id: operationId, - status: 'pending', - startTime: Date.now(), - endTime: null, - result: null, - error: null, - // Store necessary parts of context, especially log for background execution - log: context.log, - reportProgress: context.reportProgress, // Pass reportProgress through - session: context.session // Pass session through if needed by the operationFn - }; - this.operations.set(operationId, operation); - this.log(operationId, 'info', `Operation added.`); + /** + * Adds an operation to be executed asynchronously. + * @param {Function} operationFn - The async function to execute (e.g., a Direct function). + * @param {Object} args - Arguments to pass to the operationFn. + * @param {Object} context - The MCP tool context { log, reportProgress, session }. + * @returns {string} The unique ID assigned to this operation. + */ + addOperation(operationFn, args, context) { + const operationId = `op-${uuidv4()}`; + const operation = { + id: operationId, + status: 'pending', + startTime: Date.now(), + endTime: null, + result: null, + error: null, + // Store necessary parts of context, especially log for background execution + log: context.log, + reportProgress: context.reportProgress, // Pass reportProgress through + session: context.session // Pass session through if needed by the operationFn + }; + this.operations.set(operationId, operation); + this.log(operationId, 'info', `Operation added.`); - // Start execution in the background (don't await here) - this._runOperation(operationId, operationFn, args, context).catch(err => { - // Catch unexpected errors during the async execution setup itself - this.log(operationId, 'error', `Critical error starting operation: ${err.message}`, { stack: err.stack }); - operation.status = 'failed'; - operation.error = { code: 'MANAGER_EXECUTION_ERROR', message: err.message }; - operation.endTime = Date.now(); - - // Move to completed operations - this._moveToCompleted(operationId); - }); + // Start execution in the background (don't await here) + this._runOperation(operationId, operationFn, args, context).catch((err) => { + // Catch unexpected errors during the async execution setup itself + this.log( + operationId, + 'error', + `Critical error starting operation: ${err.message}`, + { stack: err.stack } + ); + operation.status = 'failed'; + operation.error = { + code: 'MANAGER_EXECUTION_ERROR', + message: err.message + }; + operation.endTime = Date.now(); - return operationId; - } + // Move to completed operations + this._moveToCompleted(operationId); + }); - /** - * Internal function to execute the operation. - * @param {string} operationId - The ID of the operation. - * @param {Function} operationFn - The async function to execute. - * @param {Object} args - Arguments for the function. - * @param {Object} context - The original MCP tool context. - */ - async _runOperation(operationId, operationFn, args, context) { - const operation = this.operations.get(operationId); - if (!operation) return; // Should not happen + return operationId; + } - operation.status = 'running'; - this.log(operationId, 'info', `Operation running.`); - this.emit('statusChanged', { operationId, status: 'running' }); + /** + * Internal function to execute the operation. + * @param {string} operationId - The ID of the operation. + * @param {Function} operationFn - The async function to execute. + * @param {Object} args - Arguments for the function. + * @param {Object} context - The original MCP tool context. + */ + async _runOperation(operationId, operationFn, args, context) { + const operation = this.operations.get(operationId); + if (!operation) return; // Should not happen - try { - // Pass the necessary context parts to the direct function - // The direct function needs to be adapted if it needs reportProgress - // We pass the original context's log, plus our wrapped reportProgress - const result = await operationFn(args, operation.log, { - reportProgress: (progress) => this._handleProgress(operationId, progress), - mcpLog: operation.log, // Pass log as mcpLog if direct fn expects it - session: operation.session - }); - - operation.status = result.success ? 'completed' : 'failed'; - operation.result = result.success ? result.data : null; - operation.error = result.success ? null : result.error; - this.log(operationId, 'info', `Operation finished with status: ${operation.status}`); + operation.status = 'running'; + this.log(operationId, 'info', `Operation running.`); + this.emit('statusChanged', { operationId, status: 'running' }); - } catch (error) { - this.log(operationId, 'error', `Operation failed with error: ${error.message}`, { stack: error.stack }); - operation.status = 'failed'; - operation.error = { code: 'OPERATION_EXECUTION_ERROR', message: error.message }; - } finally { - operation.endTime = Date.now(); - this.emit('statusChanged', { operationId, status: operation.status, result: operation.result, error: operation.error }); - - // Move to completed operations if done or failed - if (operation.status === 'completed' || operation.status === 'failed') { - this._moveToCompleted(operationId); - } - } - } - - /** - * Move an operation from active operations to completed operations history. - * @param {string} operationId - The ID of the operation to move. - * @private - */ - _moveToCompleted(operationId) { - const operation = this.operations.get(operationId); - if (!operation) return; - - // Store only the necessary data in completed operations - const completedData = { - id: operation.id, - status: operation.status, - startTime: operation.startTime, - endTime: operation.endTime, - result: operation.result, - error: operation.error, - }; - - this.completedOperations.set(operationId, completedData); - this.operations.delete(operationId); - - // Trim completed operations if exceeding maximum - if (this.completedOperations.size > this.maxCompletedOperations) { - // Get the oldest operation (sorted by endTime) - const oldest = [...this.completedOperations.entries()] - .sort((a, b) => a[1].endTime - b[1].endTime)[0]; - - if (oldest) { - this.completedOperations.delete(oldest[0]); - } - } - } - - /** - * Handles progress updates from the running operation and forwards them. - * @param {string} operationId - The ID of the operation reporting progress. - * @param {Object} progress - The progress object { progress, total? }. - */ - _handleProgress(operationId, progress) { - const operation = this.operations.get(operationId); - if (operation && operation.reportProgress) { - try { - // Use the reportProgress function captured from the original context - operation.reportProgress(progress); - this.log(operationId, 'debug', `Reported progress: ${JSON.stringify(progress)}`); - } catch(err) { - this.log(operationId, 'warn', `Failed to report progress: ${err.message}`); - // Don't stop the operation, just log the reporting failure - } - } - } + try { + // Pass the necessary context parts to the direct function + // The direct function needs to be adapted if it needs reportProgress + // We pass the original context's log, plus our wrapped reportProgress + const result = await operationFn(args, operation.log, { + reportProgress: (progress) => + this._handleProgress(operationId, progress), + mcpLog: operation.log, // Pass log as mcpLog if direct fn expects it + session: operation.session + }); - /** - * Retrieves the status and result/error of an operation. - * @param {string} operationId - The ID of the operation. - * @returns {Object | null} The operation details or null if not found. - */ - getStatus(operationId) { - // First check active operations - const operation = this.operations.get(operationId); - if (operation) { - return { - id: operation.id, - status: operation.status, - startTime: operation.startTime, - endTime: operation.endTime, - result: operation.result, - error: operation.error, - }; - } - - // Then check completed operations - const completedOperation = this.completedOperations.get(operationId); - if (completedOperation) { - return completedOperation; - } - - // Operation not found in either active or completed - return { - error: { - code: 'OPERATION_NOT_FOUND', - message: `Operation ID ${operationId} not found. It may have been completed and removed from history, or the ID may be invalid.` - }, - status: 'not_found' - }; - } - - /** - * Internal logging helper to prefix logs with the operation ID. - * @param {string} operationId - The ID of the operation. - * @param {'info'|'warn'|'error'|'debug'} level - Log level. - * @param {string} message - Log message. - * @param {Object} [meta] - Additional metadata. - */ - log(operationId, level, message, meta = {}) { - const operation = this.operations.get(operationId); - // Use the logger instance associated with the operation if available, otherwise console - const logger = operation?.log || console; - const logFn = logger[level] || logger.log || console.log; // Fallback - logFn(`[AsyncOp ${operationId}] ${message}`, meta); - } + operation.status = result.success ? 'completed' : 'failed'; + operation.result = result.success ? result.data : null; + operation.error = result.success ? null : result.error; + this.log( + operationId, + 'info', + `Operation finished with status: ${operation.status}` + ); + } catch (error) { + this.log( + operationId, + 'error', + `Operation failed with error: ${error.message}`, + { stack: error.stack } + ); + operation.status = 'failed'; + operation.error = { + code: 'OPERATION_EXECUTION_ERROR', + message: error.message + }; + } finally { + operation.endTime = Date.now(); + this.emit('statusChanged', { + operationId, + status: operation.status, + result: operation.result, + error: operation.error + }); - // --- Basic Event Emitter --- - on(eventName, listener) { - if (!this.listeners.has(eventName)) { - this.listeners.set(eventName, []); - } - this.listeners.get(eventName).push(listener); - } + // Move to completed operations if done or failed + if (operation.status === 'completed' || operation.status === 'failed') { + this._moveToCompleted(operationId); + } + } + } - emit(eventName, data) { - if (this.listeners.has(eventName)) { - this.listeners.get(eventName).forEach(listener => listener(data)); - } - } + /** + * Move an operation from active operations to completed operations history. + * @param {string} operationId - The ID of the operation to move. + * @private + */ + _moveToCompleted(operationId) { + const operation = this.operations.get(operationId); + if (!operation) return; + + // Store only the necessary data in completed operations + const completedData = { + id: operation.id, + status: operation.status, + startTime: operation.startTime, + endTime: operation.endTime, + result: operation.result, + error: operation.error + }; + + this.completedOperations.set(operationId, completedData); + this.operations.delete(operationId); + + // Trim completed operations if exceeding maximum + if (this.completedOperations.size > this.maxCompletedOperations) { + // Get the oldest operation (sorted by endTime) + const oldest = [...this.completedOperations.entries()].sort( + (a, b) => a[1].endTime - b[1].endTime + )[0]; + + if (oldest) { + this.completedOperations.delete(oldest[0]); + } + } + } + + /** + * Handles progress updates from the running operation and forwards them. + * @param {string} operationId - The ID of the operation reporting progress. + * @param {Object} progress - The progress object { progress, total? }. + */ + _handleProgress(operationId, progress) { + const operation = this.operations.get(operationId); + if (operation && operation.reportProgress) { + try { + // Use the reportProgress function captured from the original context + operation.reportProgress(progress); + this.log( + operationId, + 'debug', + `Reported progress: ${JSON.stringify(progress)}` + ); + } catch (err) { + this.log( + operationId, + 'warn', + `Failed to report progress: ${err.message}` + ); + // Don't stop the operation, just log the reporting failure + } + } + } + + /** + * Retrieves the status and result/error of an operation. + * @param {string} operationId - The ID of the operation. + * @returns {Object | null} The operation details or null if not found. + */ + getStatus(operationId) { + // First check active operations + const operation = this.operations.get(operationId); + if (operation) { + return { + id: operation.id, + status: operation.status, + startTime: operation.startTime, + endTime: operation.endTime, + result: operation.result, + error: operation.error + }; + } + + // Then check completed operations + const completedOperation = this.completedOperations.get(operationId); + if (completedOperation) { + return completedOperation; + } + + // Operation not found in either active or completed + return { + error: { + code: 'OPERATION_NOT_FOUND', + message: `Operation ID ${operationId} not found. It may have been completed and removed from history, or the ID may be invalid.` + }, + status: 'not_found' + }; + } + + /** + * Internal logging helper to prefix logs with the operation ID. + * @param {string} operationId - The ID of the operation. + * @param {'info'|'warn'|'error'|'debug'} level - Log level. + * @param {string} message - Log message. + * @param {Object} [meta] - Additional metadata. + */ + log(operationId, level, message, meta = {}) { + const operation = this.operations.get(operationId); + // Use the logger instance associated with the operation if available, otherwise console + const logger = operation?.log || console; + const logFn = logger[level] || logger.log || console.log; // Fallback + logFn(`[AsyncOp ${operationId}] ${message}`, meta); + } + + // --- Basic Event Emitter --- + on(eventName, listener) { + if (!this.listeners.has(eventName)) { + this.listeners.set(eventName, []); + } + this.listeners.get(eventName).push(listener); + } + + emit(eventName, data) { + if (this.listeners.has(eventName)) { + this.listeners.get(eventName).forEach((listener) => listener(data)); + } + } } // Export a singleton instance diff --git a/mcp-server/src/core/utils/env-utils.js b/mcp-server/src/core/utils/env-utils.js index 1eb7e9a7..5289bc99 100644 --- a/mcp-server/src/core/utils/env-utils.js +++ b/mcp-server/src/core/utils/env-utils.js @@ -6,38 +6,42 @@ * @returns {Promise} The result of the actionFn. */ export async function withSessionEnv(sessionEnv, actionFn) { - if (!sessionEnv || typeof sessionEnv !== 'object' || Object.keys(sessionEnv).length === 0) { - // If no sessionEnv is provided, just run the action directly - return await actionFn(); - } - - const originalEnv = {}; - const keysToRestore = []; - - // Set environment variables from sessionEnv - for (const key in sessionEnv) { - if (Object.prototype.hasOwnProperty.call(sessionEnv, key)) { - // Store original value if it exists, otherwise mark for deletion - if (process.env[key] !== undefined) { - originalEnv[key] = process.env[key]; - } - keysToRestore.push(key); - process.env[key] = sessionEnv[key]; - } - } - - try { - // Execute the provided action function - return await actionFn(); - } finally { - // Restore original environment variables - for (const key of keysToRestore) { - if (Object.prototype.hasOwnProperty.call(originalEnv, key)) { - process.env[key] = originalEnv[key]; - } else { - // If the key didn't exist originally, delete it - delete process.env[key]; - } - } - } - } \ No newline at end of file + if ( + !sessionEnv || + typeof sessionEnv !== 'object' || + Object.keys(sessionEnv).length === 0 + ) { + // If no sessionEnv is provided, just run the action directly + return await actionFn(); + } + + const originalEnv = {}; + const keysToRestore = []; + + // Set environment variables from sessionEnv + for (const key in sessionEnv) { + if (Object.prototype.hasOwnProperty.call(sessionEnv, key)) { + // Store original value if it exists, otherwise mark for deletion + if (process.env[key] !== undefined) { + originalEnv[key] = process.env[key]; + } + keysToRestore.push(key); + process.env[key] = sessionEnv[key]; + } + } + + try { + // Execute the provided action function + return await actionFn(); + } finally { + // Restore original environment variables + for (const key of keysToRestore) { + if (Object.prototype.hasOwnProperty.call(originalEnv, key)) { + process.env[key] = originalEnv[key]; + } else { + // If the key didn't exist originally, delete it + delete process.env[key]; + } + } + } +} diff --git a/mcp-server/src/core/utils/path-utils.js b/mcp-server/src/core/utils/path-utils.js index 7760d703..5ede8d1b 100644 --- a/mcp-server/src/core/utils/path-utils.js +++ b/mcp-server/src/core/utils/path-utils.js @@ -1,9 +1,9 @@ /** * path-utils.js * Utility functions for file path operations in Task Master - * + * * This module provides robust path resolution for both: - * 1. PACKAGE PATH: Where task-master code is installed + * 1. PACKAGE PATH: Where task-master code is installed * (global node_modules OR local ./node_modules/task-master OR direct from repo) * 2. PROJECT PATH: Where user's tasks.json resides (typically user's project root) */ @@ -18,43 +18,43 @@ export let lastFoundProjectRoot = null; // Project marker files that indicate a potential project root export const PROJECT_MARKERS = [ - // Task Master specific - 'tasks.json', - 'tasks/tasks.json', - - // Common version control - '.git', - '.svn', - - // Common package files - 'package.json', - 'pyproject.toml', - 'Gemfile', - 'go.mod', - 'Cargo.toml', - - // Common IDE/editor folders - '.cursor', - '.vscode', - '.idea', - - // Common dependency directories (check if directory) - 'node_modules', - 'venv', - '.venv', - - // Common config files - '.env', - '.eslintrc', - 'tsconfig.json', - 'babel.config.js', - 'jest.config.js', - 'webpack.config.js', - - // Common CI/CD files - '.github/workflows', - '.gitlab-ci.yml', - '.circleci/config.yml' + // Task Master specific + 'tasks.json', + 'tasks/tasks.json', + + // Common version control + '.git', + '.svn', + + // Common package files + 'package.json', + 'pyproject.toml', + 'Gemfile', + 'go.mod', + 'Cargo.toml', + + // Common IDE/editor folders + '.cursor', + '.vscode', + '.idea', + + // Common dependency directories (check if directory) + 'node_modules', + 'venv', + '.venv', + + // Common config files + '.env', + '.eslintrc', + 'tsconfig.json', + 'babel.config.js', + 'jest.config.js', + 'webpack.config.js', + + // Common CI/CD files + '.github/workflows', + '.gitlab-ci.yml', + '.circleci/config.yml' ]; /** @@ -63,15 +63,15 @@ export const PROJECT_MARKERS = [ * @returns {string} - Absolute path to the package installation directory */ export function getPackagePath() { - // When running from source, __dirname is the directory containing this file - // When running from npm, we need to find the package root - const thisFilePath = fileURLToPath(import.meta.url); - const thisFileDir = path.dirname(thisFilePath); - - // Navigate from core/utils up to the package root - // In dev: /path/to/task-master/mcp-server/src/core/utils -> /path/to/task-master - // In npm: /path/to/node_modules/task-master/mcp-server/src/core/utils -> /path/to/node_modules/task-master - return path.resolve(thisFileDir, '../../../../'); + // When running from source, __dirname is the directory containing this file + // When running from npm, we need to find the package root + const thisFilePath = fileURLToPath(import.meta.url); + const thisFileDir = path.dirname(thisFilePath); + + // Navigate from core/utils up to the package root + // In dev: /path/to/task-master/mcp-server/src/core/utils -> /path/to/task-master + // In npm: /path/to/node_modules/task-master/mcp-server/src/core/utils -> /path/to/node_modules/task-master + return path.resolve(thisFileDir, '../../../../'); } /** @@ -82,62 +82,73 @@ export function getPackagePath() { * @throws {Error} - If tasks.json cannot be found. */ export function findTasksJsonPath(args, log) { - // PRECEDENCE ORDER for finding tasks.json: - // 1. Explicitly provided `projectRoot` in args (Highest priority, expected in MCP context) - // 2. Previously found/cached `lastFoundProjectRoot` (primarily for CLI performance) - // 3. Search upwards from current working directory (`process.cwd()`) - CLI usage - - // 1. If project root is explicitly provided (e.g., from MCP session), use it directly - if (args.projectRoot) { - const projectRoot = args.projectRoot; - log.info(`Using explicitly provided project root: ${projectRoot}`); - try { - // This will throw if tasks.json isn't found within this root - return findTasksJsonInDirectory(projectRoot, args.file, log); - } catch (error) { - // Include debug info in error - const debugInfo = { - projectRoot, - currentDir: process.cwd(), - serverDir: path.dirname(process.argv[1]), - possibleProjectRoot: path.resolve(path.dirname(process.argv[1]), '../..'), - lastFoundProjectRoot, - searchedPaths: error.message - }; - - error.message = `Tasks file not found in any of the expected locations relative to project root "${projectRoot}" (from session).\nDebug Info: ${JSON.stringify(debugInfo, null, 2)}`; - throw error; - } - } - - // --- Fallback logic primarily for CLI or when projectRoot isn't passed --- + // PRECEDENCE ORDER for finding tasks.json: + // 1. Explicitly provided `projectRoot` in args (Highest priority, expected in MCP context) + // 2. Previously found/cached `lastFoundProjectRoot` (primarily for CLI performance) + // 3. Search upwards from current working directory (`process.cwd()`) - CLI usage - // 2. If we have a last known project root that worked, try it first - if (lastFoundProjectRoot) { - log.info(`Trying last known project root: ${lastFoundProjectRoot}`); - try { - // Use the cached root - const tasksPath = findTasksJsonInDirectory(lastFoundProjectRoot, args.file, log); - return tasksPath; // Return if found in cached root - } catch (error) { - log.info(`Task file not found in last known project root, continuing search.`); - // Continue with search if not found in cache - } - } - - // 3. Start search from current directory (most common CLI scenario) - const startDir = process.cwd(); - log.info(`Searching for tasks.json starting from current directory: ${startDir}`); - - // Try to find tasks.json by walking up the directory tree from cwd - try { - // This will throw if not found in the CWD tree - return findTasksJsonWithParentSearch(startDir, args.file, log); - } catch (error) { - // If all attempts fail, augment and throw the original error from CWD search - error.message = `${error.message}\n\nPossible solutions:\n1. Run the command from your project directory containing tasks.json\n2. Use --project-root=/path/to/project to specify the project location (if using CLI)\n3. Ensure the project root is correctly passed from the client (if using MCP)\n\nCurrent working directory: ${startDir}\nLast known project root: ${lastFoundProjectRoot}\nProject root from args: ${args.projectRoot}`; - throw error; - } + // 1. If project root is explicitly provided (e.g., from MCP session), use it directly + if (args.projectRoot) { + const projectRoot = args.projectRoot; + log.info(`Using explicitly provided project root: ${projectRoot}`); + try { + // This will throw if tasks.json isn't found within this root + return findTasksJsonInDirectory(projectRoot, args.file, log); + } catch (error) { + // Include debug info in error + const debugInfo = { + projectRoot, + currentDir: process.cwd(), + serverDir: path.dirname(process.argv[1]), + possibleProjectRoot: path.resolve( + path.dirname(process.argv[1]), + '../..' + ), + lastFoundProjectRoot, + searchedPaths: error.message + }; + + error.message = `Tasks file not found in any of the expected locations relative to project root "${projectRoot}" (from session).\nDebug Info: ${JSON.stringify(debugInfo, null, 2)}`; + throw error; + } + } + + // --- Fallback logic primarily for CLI or when projectRoot isn't passed --- + + // 2. If we have a last known project root that worked, try it first + if (lastFoundProjectRoot) { + log.info(`Trying last known project root: ${lastFoundProjectRoot}`); + try { + // Use the cached root + const tasksPath = findTasksJsonInDirectory( + lastFoundProjectRoot, + args.file, + log + ); + return tasksPath; // Return if found in cached root + } catch (error) { + log.info( + `Task file not found in last known project root, continuing search.` + ); + // Continue with search if not found in cache + } + } + + // 3. Start search from current directory (most common CLI scenario) + const startDir = process.cwd(); + log.info( + `Searching for tasks.json starting from current directory: ${startDir}` + ); + + // Try to find tasks.json by walking up the directory tree from cwd + try { + // This will throw if not found in the CWD tree + return findTasksJsonWithParentSearch(startDir, args.file, log); + } catch (error) { + // If all attempts fail, augment and throw the original error from CWD search + error.message = `${error.message}\n\nPossible solutions:\n1. Run the command from your project directory containing tasks.json\n2. Use --project-root=/path/to/project to specify the project location (if using CLI)\n3. Ensure the project root is correctly passed from the client (if using MCP)\n\nCurrent working directory: ${startDir}\nLast known project root: ${lastFoundProjectRoot}\nProject root from args: ${args.projectRoot}`; + throw error; + } } /** @@ -146,11 +157,11 @@ export function findTasksJsonPath(args, log) { * @returns {boolean} - True if the directory contains any project markers */ function hasProjectMarkers(dirPath) { - return PROJECT_MARKERS.some(marker => { - const markerPath = path.join(dirPath, marker); - // Check if the marker exists as either a file or directory - return fs.existsSync(markerPath); - }); + return PROJECT_MARKERS.some((marker) => { + const markerPath = path.join(dirPath, marker); + // Check if the marker exists as either a file or directory + return fs.existsSync(markerPath); + }); } /** @@ -162,39 +173,41 @@ function hasProjectMarkers(dirPath) { * @throws {Error} - If tasks.json cannot be found */ function findTasksJsonInDirectory(dirPath, explicitFilePath, log) { - const possiblePaths = []; + const possiblePaths = []; - // 1. If a file is explicitly provided relative to dirPath - if (explicitFilePath) { - possiblePaths.push(path.resolve(dirPath, explicitFilePath)); - } + // 1. If a file is explicitly provided relative to dirPath + if (explicitFilePath) { + possiblePaths.push(path.resolve(dirPath, explicitFilePath)); + } - // 2. Check the standard locations relative to dirPath - possiblePaths.push( - path.join(dirPath, 'tasks.json'), - path.join(dirPath, 'tasks', 'tasks.json') - ); + // 2. Check the standard locations relative to dirPath + possiblePaths.push( + path.join(dirPath, 'tasks.json'), + path.join(dirPath, '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) { - log.info(`Checking if exists: ${p}`); - const exists = fs.existsSync(p); - log.info(`Path ${p} exists: ${exists}`); - - if (exists) { - log.info(`Found tasks file at: ${p}`); - // Store the project root for future use - lastFoundProjectRoot = dirPath; - return p; - } - } + // Find the first existing path + for (const p of possiblePaths) { + log.info(`Checking if exists: ${p}`); + const exists = fs.existsSync(p); + log.info(`Path ${p} exists: ${exists}`); - // If no file was found, throw an error - const error = new Error(`Tasks file not found in any of the expected locations relative to ${dirPath}: ${possiblePaths.join(', ')}`); - error.code = 'TASKS_FILE_NOT_FOUND'; - throw error; + if (exists) { + log.info(`Found tasks file at: ${p}`); + // Store the project root for future use + lastFoundProjectRoot = dirPath; + 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 ${dirPath}: ${possiblePaths.join(', ')}` + ); + error.code = 'TASKS_FILE_NOT_FOUND'; + throw error; } /** @@ -207,66 +220,74 @@ function findTasksJsonInDirectory(dirPath, explicitFilePath, log) { * @throws {Error} - If tasks.json cannot be found in any parent directory */ function findTasksJsonWithParentSearch(startDir, explicitFilePath, log) { - let currentDir = startDir; - const rootDir = path.parse(currentDir).root; - - // Keep traversing up until we hit the root directory - while (currentDir !== rootDir) { - // First check for tasks.json directly - try { - return findTasksJsonInDirectory(currentDir, explicitFilePath, log); - } catch (error) { - // If tasks.json not found but the directory has project markers, - // log it as a potential project root (helpful for debugging) - if (hasProjectMarkers(currentDir)) { - log.info(`Found project markers in ${currentDir}, but no tasks.json`); - } - - // Move up to parent directory - const parentDir = path.dirname(currentDir); - - // Check if we've reached the root - if (parentDir === currentDir) { - break; - } - - log.info(`Tasks file not found in ${currentDir}, searching in parent directory: ${parentDir}`); - currentDir = parentDir; - } - } - - // If we've searched all the way to the root and found nothing - const error = new Error(`Tasks file not found in ${startDir} or any parent directory.`); - error.code = 'TASKS_FILE_NOT_FOUND'; - throw error; + let currentDir = startDir; + const rootDir = path.parse(currentDir).root; + + // Keep traversing up until we hit the root directory + while (currentDir !== rootDir) { + // First check for tasks.json directly + try { + return findTasksJsonInDirectory(currentDir, explicitFilePath, log); + } catch (error) { + // If tasks.json not found but the directory has project markers, + // log it as a potential project root (helpful for debugging) + if (hasProjectMarkers(currentDir)) { + log.info(`Found project markers in ${currentDir}, but no tasks.json`); + } + + // Move up to parent directory + const parentDir = path.dirname(currentDir); + + // Check if we've reached the root + if (parentDir === currentDir) { + break; + } + + log.info( + `Tasks file not found in ${currentDir}, searching in parent directory: ${parentDir}` + ); + currentDir = parentDir; + } + } + + // If we've searched all the way to the root and found nothing + const error = new Error( + `Tasks file not found in ${startDir} or any parent directory.` + ); + error.code = 'TASKS_FILE_NOT_FOUND'; + throw error; } // Note: findTasksWithNpmConsideration is not used by findTasksJsonPath and might be legacy or used elsewhere. // If confirmed unused, it could potentially be removed in a separate cleanup. function findTasksWithNpmConsideration(startDir, log) { - // First try our recursive parent search from cwd - try { - return findTasksJsonWithParentSearch(startDir, null, log); - } catch (error) { - // If that fails, try looking relative to the executable location - const execPath = process.argv[1]; - const execDir = path.dirname(execPath); - log.info(`Looking for tasks file relative to executable at: ${execDir}`); - - try { - return findTasksJsonWithParentSearch(execDir, null, log); - } catch (secondError) { - // If that also fails, check standard locations in user's home directory - const homeDir = os.homedir(); - log.info(`Looking for tasks file in home directory: ${homeDir}`); - - try { - // Check standard locations in home dir - return findTasksJsonInDirectory(path.join(homeDir, '.task-master'), null, log); - } catch (thirdError) { - // If all approaches fail, throw the original error - throw error; - } - } - } -} \ No newline at end of file + // First try our recursive parent search from cwd + try { + return findTasksJsonWithParentSearch(startDir, null, log); + } catch (error) { + // If that fails, try looking relative to the executable location + const execPath = process.argv[1]; + const execDir = path.dirname(execPath); + log.info(`Looking for tasks file relative to executable at: ${execDir}`); + + try { + return findTasksJsonWithParentSearch(execDir, null, log); + } catch (secondError) { + // If that also fails, check standard locations in user's home directory + const homeDir = os.homedir(); + log.info(`Looking for tasks file in home directory: ${homeDir}`); + + try { + // Check standard locations in home dir + return findTasksJsonInDirectory( + path.join(homeDir, '.task-master'), + null, + log + ); + } catch (thirdError) { + // If all approaches fail, throw the original error + throw error; + } + } + } +} diff --git a/mcp-server/src/index.js b/mcp-server/src/index.js index 72e37dd7..a3fe5bd0 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'; import { asyncOperationManager } from './core/utils/async-manager.js'; // Load environment variables @@ -18,74 +18,74 @@ 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({}); - // Make the manager accessible (e.g., pass it to tool registration) - this.asyncManager = asyncOperationManager; + // Make the manager accessible (e.g., pass it to tool registration) + this.asyncManager = asyncOperationManager; - // 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; - // Pass the manager instance to the tool registration function - registerTaskMasterTools(this.server, this.asyncManager); + // Pass the manager instance to the tool registration function + registerTaskMasterTools(this.server, this.asyncManager); - 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 with increased timeout - await this.server.start({ - transportType: "stdio", - timeout: 120000 // 2 minutes timeout (in milliseconds) - }); + // Start the FastMCP server with increased timeout + await this.server.start({ + transportType: 'stdio', + timeout: 120000 // 2 minutes timeout (in milliseconds) + }); - 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 the manager from here as well, if needed elsewhere diff --git a/mcp-server/src/logger.js b/mcp-server/src/logger.js index 3c0e2da4..63e2a865 100644 --- a/mcp-server/src/logger.js +++ b/mcp-server/src/logger.js @@ -1,19 +1,19 @@ -import chalk from "chalk"; -import { isSilentMode } from "../../scripts/modules/utils.js"; +import chalk from 'chalk'; +import { isSilentMode } from '../../scripts/modules/utils.js'; // 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.info; + ? (LOG_LEVELS[process.env.LOG_LEVEL.toLowerCase()] ?? LOG_LEVELS.info) + : LOG_LEVELS.info; /** * Logs a message with the specified level @@ -21,56 +21,66 @@ const LOG_LEVEL = process.env.LOG_LEVEL * @param {...any} args - Arguments to log */ function log(level, ...args) { - // Skip logging if silent mode is enabled - if (isSilentMode()) { - return; - } + // Skip logging if silent mode is enabled + if (isSilentMode()) { + return; + } - // Use text prefixes instead of emojis - const prefixes = { - debug: chalk.gray("[DEBUG]"), - info: chalk.blue("[INFO]"), - warn: chalk.yellow("[WARN]"), - error: chalk.red("[ERROR]"), - success: chalk.green("[SUCCESS]"), - }; + // Use text prefixes instead of emojis + const prefixes = { + debug: chalk.gray('[DEBUG]'), + info: chalk.blue('[INFO]'), + warn: chalk.yellow('[WARN]'), + error: chalk.red('[ERROR]'), + success: chalk.green('[SUCCESS]') + }; - if (LOG_LEVELS[level] !== undefined && LOG_LEVELS[level] >= LOG_LEVEL) { - const prefix = prefixes[level] || ""; - let coloredArgs = args; + if (LOG_LEVELS[level] !== undefined && LOG_LEVELS[level] >= LOG_LEVEL) { + const prefix = prefixes[level] || ''; + let coloredArgs = args; - try { - switch(level) { - case "error": - coloredArgs = args.map(arg => typeof arg === 'string' ? chalk.red(arg) : arg); - break; - case "warn": - coloredArgs = args.map(arg => typeof arg === 'string' ? chalk.yellow(arg) : arg); - break; - case "success": - coloredArgs = args.map(arg => typeof arg === 'string' ? chalk.green(arg) : arg); - break; - case "info": - coloredArgs = args.map(arg => typeof arg === 'string' ? chalk.blue(arg) : arg); - break; - case "debug": - coloredArgs = args.map(arg => typeof arg === 'string' ? chalk.gray(arg) : arg); - break; - // default: use original args (no color) - } - } catch (colorError) { - // Fallback if chalk fails on an argument - // Use console.error here for internal logger errors, separate from normal logging - console.error("Internal Logger Error applying chalk color:", colorError); - coloredArgs = args; - } + try { + switch (level) { + case 'error': + coloredArgs = args.map((arg) => + typeof arg === 'string' ? chalk.red(arg) : arg + ); + break; + case 'warn': + coloredArgs = args.map((arg) => + typeof arg === 'string' ? chalk.yellow(arg) : arg + ); + break; + case 'success': + coloredArgs = args.map((arg) => + typeof arg === 'string' ? chalk.green(arg) : arg + ); + break; + case 'info': + coloredArgs = args.map((arg) => + typeof arg === 'string' ? chalk.blue(arg) : arg + ); + break; + case 'debug': + coloredArgs = args.map((arg) => + typeof arg === 'string' ? chalk.gray(arg) : arg + ); + break; + // default: use original args (no color) + } + } catch (colorError) { + // Fallback if chalk fails on an argument + // Use console.error here for internal logger errors, separate from normal logging + console.error('Internal Logger Error applying chalk color:', colorError); + coloredArgs = args; + } - // Revert to console.log - FastMCP's context logger (context.log) - // is responsible for directing logs correctly (e.g., to stderr) - // during tool execution without upsetting the client connection. - // Logs outside of tool execution (like startup) will go to stdout. - console.log(prefix, ...coloredArgs); - } + // Revert to console.log - FastMCP's context logger (context.log) + // is responsible for directing logs correctly (e.g., to stderr) + // during tool execution without upsetting the client connection. + // Logs outside of tool execution (like startup) will go to stdout. + console.log(prefix, ...coloredArgs); + } } /** @@ -78,16 +88,19 @@ function log(level, ...args) { * @returns {Object} Logger object with info, error, debug, warn, and success methods */ export function createLogger() { - const createLogMethod = (level) => (...args) => log(level, ...args); + const createLogMethod = + (level) => + (...args) => + log(level, ...args); - return { - debug: createLogMethod("debug"), - info: createLogMethod("info"), - warn: createLogMethod("warn"), - error: createLogMethod("error"), - success: createLogMethod("success"), - log: log, // Also expose the raw log function - }; + return { + debug: createLogMethod('debug'), + info: createLogMethod('info'), + warn: createLogMethod('warn'), + error: createLogMethod('error'), + success: createLogMethod('success'), + log: log // Also expose the raw log function + }; } // Export a default logger instance diff --git a/mcp-server/src/tools/add-dependency.js b/mcp-server/src/tools/add-dependency.js index 75f62d6b..b9dce478 100644 --- a/mcp-server/src/tools/add-dependency.js +++ b/mcp-server/src/tools/add-dependency.js @@ -3,63 +3,79 @@ * Tool for adding a dependency to a task */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { addDependencyDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { addDependencyDirect } from '../core/task-master-core.js'; /** * Register the addDependency tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerAddDependencyTool(server) { - server.addTool({ - name: "add_dependency", - description: "Add a dependency relationship between two tasks", - parameters: z.object({ - id: z.string().describe("ID of task that will depend on another task"), - dependsOn: z.string().describe("ID of task that will become a dependency"), - file: z.string().optional().describe("Path to the tasks file (default: tasks/tasks.json)"), - projectRoot: z.string().optional().describe("Root directory of the project (default: current working directory)") - }), - execute: async (args, { log, session, reportProgress }) => { - try { - log.info(`Adding dependency for task ${args.id} to depend on ${args.dependsOn}`); - reportProgress({ progress: 0 }); - - // Get project root using the utility function - let rootFolder = getProjectRootFromSession(session, log); - - // Fallback to args.projectRoot if session didn't provide one - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - // Call the direct function with the resolved rootFolder - const result = await addDependencyDirect({ - projectRoot: rootFolder, - ...args - }, log, { reportProgress, mcpLog: log, session}); + server.addTool({ + name: 'add_dependency', + description: 'Add a dependency relationship between two tasks', + parameters: z.object({ + id: z.string().describe('ID of task that will depend on another task'), + dependsOn: z + .string() + .describe('ID of task that will become a dependency'), + file: z + .string() + .optional() + .describe('Path to the tasks file (default: tasks/tasks.json)'), + projectRoot: z + .string() + .optional() + .describe( + 'Root directory of the project (default: current working directory)' + ) + }), + execute: async (args, { log, session, reportProgress }) => { + try { + log.info( + `Adding dependency for task ${args.id} to depend on ${args.dependsOn}` + ); + reportProgress({ progress: 0 }); - reportProgress({ progress: 100 }); - - // Log result - if (result.success) { - log.info(`Successfully added dependency: ${result.data.message}`); - } else { - log.error(`Failed to add dependency: ${result.error.message}`); - } - - // Use handleApiResult to format the response - return handleApiResult(result, log, 'Error adding dependency'); - } catch (error) { - log.error(`Error in addDependency tool: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); -} \ No newline at end of file + // Get project root using the utility function + let rootFolder = getProjectRootFromSession(session, log); + + // Fallback to args.projectRoot if session didn't provide one + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + // Call the direct function with the resolved rootFolder + const result = await addDependencyDirect( + { + projectRoot: rootFolder, + ...args + }, + log, + { reportProgress, mcpLog: log, session } + ); + + reportProgress({ progress: 100 }); + + // Log result + if (result.success) { + log.info(`Successfully added dependency: ${result.data.message}`); + } else { + log.error(`Failed to add dependency: ${result.error.message}`); + } + + // Use handleApiResult to format the response + return handleApiResult(result, log, 'Error adding dependency'); + } catch (error) { + log.error(`Error in addDependency tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-server/src/tools/add-subtask.js b/mcp-server/src/tools/add-subtask.js index e4855076..9197e091 100644 --- a/mcp-server/src/tools/add-subtask.js +++ b/mcp-server/src/tools/add-subtask.js @@ -3,61 +3,94 @@ * Tool for adding subtasks to existing tasks */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { addSubtaskDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { addSubtaskDirect } from '../core/task-master-core.js'; /** * Register the addSubtask tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerAddSubtaskTool(server) { - server.addTool({ - name: "add_subtask", - description: "Add a subtask to an existing task", - parameters: z.object({ - id: z.string().describe("Parent task ID (required)"), - taskId: z.string().optional().describe("Existing task ID to convert to subtask"), - title: z.string().optional().describe("Title for the new subtask (when creating a new subtask)"), - description: z.string().optional().describe("Description for the new subtask"), - details: z.string().optional().describe("Implementation details for the new subtask"), - status: z.string().optional().describe("Status for the new subtask (default: 'pending')"), - dependencies: z.string().optional().describe("Comma-separated list of dependency IDs for the new subtask"), - file: z.string().optional().describe("Path to the tasks file (default: tasks/tasks.json)"), - skipGenerate: z.boolean().optional().describe("Skip regenerating task files"), - projectRoot: z.string().optional().describe("Root directory of the project (default: current working directory)") - }), - execute: async (args, { log, session, reportProgress }) => { - try { - log.info(`Adding subtask with args: ${JSON.stringify(args)}`); - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - const result = await addSubtaskDirect({ - projectRoot: rootFolder, - ...args - }, log, { reportProgress, mcpLog: log, session}); - - if (result.success) { - log.info(`Subtask added successfully: ${result.data.message}`); - } else { - log.error(`Failed to add subtask: ${result.error.message}`); - } - - return handleApiResult(result, log, 'Error adding subtask'); - } catch (error) { - log.error(`Error in addSubtask tool: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); -} \ No newline at end of file + server.addTool({ + name: 'add_subtask', + description: 'Add a subtask to an existing task', + parameters: z.object({ + id: z.string().describe('Parent task ID (required)'), + taskId: z + .string() + .optional() + .describe('Existing task ID to convert to subtask'), + title: z + .string() + .optional() + .describe('Title for the new subtask (when creating a new subtask)'), + description: z + .string() + .optional() + .describe('Description for the new subtask'), + details: z + .string() + .optional() + .describe('Implementation details for the new subtask'), + status: z + .string() + .optional() + .describe("Status for the new subtask (default: 'pending')"), + dependencies: z + .string() + .optional() + .describe('Comma-separated list of dependency IDs for the new subtask'), + file: z + .string() + .optional() + .describe('Path to the tasks file (default: tasks/tasks.json)'), + skipGenerate: z + .boolean() + .optional() + .describe('Skip regenerating task files'), + projectRoot: z + .string() + .optional() + .describe( + 'Root directory of the project (default: current working directory)' + ) + }), + execute: async (args, { log, session, reportProgress }) => { + try { + log.info(`Adding subtask with args: ${JSON.stringify(args)}`); + + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + const result = await addSubtaskDirect( + { + projectRoot: rootFolder, + ...args + }, + log, + { reportProgress, mcpLog: log, session } + ); + + if (result.success) { + log.info(`Subtask added successfully: ${result.data.message}`); + } else { + log.error(`Failed to add subtask: ${result.error.message}`); + } + + return handleApiResult(result, log, 'Error adding subtask'); + } catch (error) { + log.error(`Error in addSubtask tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-server/src/tools/add-task.js b/mcp-server/src/tools/add-task.js index 0ee2c76a..e692ee69 100644 --- a/mcp-server/src/tools/add-task.js +++ b/mcp-server/src/tools/add-task.js @@ -3,56 +3,72 @@ * Tool to add a new task using AI */ -import { z } from "zod"; +import { z } from 'zod'; import { - createErrorResponse, - createContentResponse, - getProjectRootFromSession, - executeTaskMasterCommand, - handleApiResult -} from "./utils.js"; -import { addTaskDirect } from "../core/task-master-core.js"; + createErrorResponse, + createContentResponse, + getProjectRootFromSession, + executeTaskMasterCommand, + handleApiResult +} from './utils.js'; +import { addTaskDirect } from '../core/task-master-core.js'; /** * Register the addTask tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerAddTaskTool(server) { - server.addTool({ - name: "add_task", - 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().optional().describe("Root directory of the project"), - research: z.boolean().optional().describe("Whether to use research capabilities for task creation") - }), - execute: async (args, { log, reportProgress, session }) => { - try { - log.info(`Starting add-task with args: ${JSON.stringify(args)}`); - - // Get project root from session - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - // Call the direct function - const result = await addTaskDirect({ - ...args, - projectRoot: rootFolder - }, log, { reportProgress, session }); - - // Return the result - return handleApiResult(result, log); - } catch (error) { - log.error(`Error in add-task tool: ${error.message}`); - return createErrorResponse(error.message); - } - } - }); -} \ No newline at end of file + server.addTool({ + name: 'add_task', + 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() + .optional() + .describe('Root directory of the project'), + research: z + .boolean() + .optional() + .describe('Whether to use research capabilities for task creation') + }), + execute: async (args, { log, reportProgress, session }) => { + try { + log.info(`Starting add-task with args: ${JSON.stringify(args)}`); + + // Get project root from session + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + // Call the direct function + const result = await addTaskDirect( + { + ...args, + projectRoot: rootFolder + }, + log, + { reportProgress, session } + ); + + // Return the result + return handleApiResult(result, log); + } catch (error) { + log.error(`Error in add-task tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-server/src/tools/analyze.js b/mcp-server/src/tools/analyze.js index cb6758a0..ee0f6cef 100644 --- a/mcp-server/src/tools/analyze.js +++ b/mcp-server/src/tools/analyze.js @@ -3,58 +3,95 @@ * Tool for analyzing task complexity and generating recommendations */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { analyzeTaskComplexityDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { analyzeTaskComplexityDirect } from '../core/task-master-core.js'; /** * Register the analyze tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerAnalyzeTool(server) { - server.addTool({ - name: "analyze_project_complexity", - description: "Analyze task complexity and generate expansion recommendations", - parameters: z.object({ - output: z.string().optional().describe("Output file path for the report (default: scripts/task-complexity-report.json)"), - model: z.string().optional().describe("LLM model to use for analysis (defaults to configured model)"), - threshold: z.union([z.number(), z.string()]).optional().describe("Minimum complexity score to recommend expansion (1-10) (default: 5)"), - file: z.string().optional().describe("Path to the tasks file (default: tasks/tasks.json)"), - research: z.boolean().optional().describe("Use Perplexity AI for research-backed complexity analysis"), - projectRoot: z.string().optional().describe("Root directory of the project (default: current working directory)") - }), - execute: async (args, { log, session }) => { - try { - log.info(`Analyzing task complexity with args: ${JSON.stringify(args)}`); - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - const result = await analyzeTaskComplexityDirect({ - projectRoot: rootFolder, - ...args - }, log, { session }); - - if (result.success) { - log.info(`Task complexity analysis complete: ${result.data.message}`); - log.info(`Report summary: ${JSON.stringify(result.data.reportSummary)}`); - } else { - log.error(`Failed to analyze task complexity: ${result.error.message}`); - } - - return handleApiResult(result, log, 'Error analyzing task complexity'); - } catch (error) { - log.error(`Error in analyze tool: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); -} \ No newline at end of file + server.addTool({ + name: 'analyze_project_complexity', + description: + 'Analyze task complexity and generate expansion recommendations', + parameters: z.object({ + output: z + .string() + .optional() + .describe( + 'Output file path for the report (default: scripts/task-complexity-report.json)' + ), + model: z + .string() + .optional() + .describe( + 'LLM model to use for analysis (defaults to configured model)' + ), + threshold: z + .union([z.number(), z.string()]) + .optional() + .describe( + 'Minimum complexity score to recommend expansion (1-10) (default: 5)' + ), + file: z + .string() + .optional() + .describe('Path to the tasks file (default: tasks/tasks.json)'), + research: z + .boolean() + .optional() + .describe('Use Perplexity AI for research-backed complexity analysis'), + projectRoot: z + .string() + .optional() + .describe( + 'Root directory of the project (default: current working directory)' + ) + }), + execute: async (args, { log, session }) => { + try { + log.info( + `Analyzing task complexity with args: ${JSON.stringify(args)}` + ); + + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + const result = await analyzeTaskComplexityDirect( + { + projectRoot: rootFolder, + ...args + }, + log, + { session } + ); + + if (result.success) { + log.info(`Task complexity analysis complete: ${result.data.message}`); + log.info( + `Report summary: ${JSON.stringify(result.data.reportSummary)}` + ); + } else { + log.error( + `Failed to analyze task complexity: ${result.error.message}` + ); + } + + return handleApiResult(result, log, 'Error analyzing task complexity'); + } catch (error) { + log.error(`Error in analyze tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-server/src/tools/clear-subtasks.js b/mcp-server/src/tools/clear-subtasks.js index cf1a32ea..8bd5e283 100644 --- a/mcp-server/src/tools/clear-subtasks.js +++ b/mcp-server/src/tools/clear-subtasks.js @@ -3,61 +3,78 @@ * Tool for clearing subtasks from parent tasks */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { clearSubtasksDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { clearSubtasksDirect } from '../core/task-master-core.js'; /** * Register the clearSubtasks tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerClearSubtasksTool(server) { - server.addTool({ - name: "clear_subtasks", - description: "Clear subtasks from specified tasks", - parameters: z.object({ - id: z.string().optional().describe("Task IDs (comma-separated) to clear subtasks from"), - all: z.boolean().optional().describe("Clear subtasks from all tasks"), - file: z.string().optional().describe("Path to the tasks file (default: tasks/tasks.json)"), - projectRoot: z.string().optional().describe("Root directory of the project (default: current working directory)") - }).refine(data => data.id || data.all, { - message: "Either 'id' or 'all' parameter must be provided", - path: ["id", "all"] - }), - execute: async (args, { log, session, reportProgress }) => { - try { - log.info(`Clearing subtasks with args: ${JSON.stringify(args)}`); - await reportProgress({ progress: 0 }); - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - const result = await clearSubtasksDirect({ - projectRoot: rootFolder, - ...args - }, log, { reportProgress, mcpLog: log, session}); - - reportProgress({ progress: 100 }); - - if (result.success) { - log.info(`Subtasks cleared successfully: ${result.data.message}`); - } else { - log.error(`Failed to clear subtasks: ${result.error.message}`); - } - - return handleApiResult(result, log, 'Error clearing subtasks'); - } catch (error) { - log.error(`Error in clearSubtasks tool: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); -} \ No newline at end of file + server.addTool({ + name: 'clear_subtasks', + description: 'Clear subtasks from specified tasks', + parameters: z + .object({ + id: z + .string() + .optional() + .describe('Task IDs (comma-separated) to clear subtasks from'), + all: z.boolean().optional().describe('Clear subtasks from all tasks'), + file: z + .string() + .optional() + .describe('Path to the tasks file (default: tasks/tasks.json)'), + projectRoot: z + .string() + .optional() + .describe( + 'Root directory of the project (default: current working directory)' + ) + }) + .refine((data) => data.id || data.all, { + message: "Either 'id' or 'all' parameter must be provided", + path: ['id', 'all'] + }), + execute: async (args, { log, session, reportProgress }) => { + try { + log.info(`Clearing subtasks with args: ${JSON.stringify(args)}`); + await reportProgress({ progress: 0 }); + + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + const result = await clearSubtasksDirect( + { + projectRoot: rootFolder, + ...args + }, + log, + { reportProgress, mcpLog: log, session } + ); + + reportProgress({ progress: 100 }); + + if (result.success) { + log.info(`Subtasks cleared successfully: ${result.data.message}`); + } else { + log.error(`Failed to clear subtasks: ${result.error.message}`); + } + + return handleApiResult(result, log, 'Error clearing subtasks'); + } catch (error) { + log.error(`Error in clearSubtasks tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-server/src/tools/complexity-report.js b/mcp-server/src/tools/complexity-report.js index 4c2d1c9d..5c555856 100644 --- a/mcp-server/src/tools/complexity-report.js +++ b/mcp-server/src/tools/complexity-report.js @@ -3,56 +3,81 @@ * Tool for displaying the complexity analysis report */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { complexityReportDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { complexityReportDirect } from '../core/task-master-core.js'; /** * Register the complexityReport tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerComplexityReportTool(server) { - server.addTool({ - name: "complexity_report", - description: "Display the complexity analysis report in a readable format", - parameters: z.object({ - file: z.string().optional().describe("Path to the report file (default: scripts/task-complexity-report.json)"), - projectRoot: z.string().optional().describe("Root directory of the project (default: current working directory)") - }), - execute: async (args, { log, session, reportProgress }) => { - try { - log.info(`Getting complexity report with args: ${JSON.stringify(args)}`); - // await reportProgress({ progress: 0 }); - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - const result = await complexityReportDirect({ - projectRoot: rootFolder, - ...args - }, log/*, { reportProgress, mcpLog: log, session}*/); - - // await reportProgress({ progress: 100 }); - - if (result.success) { - log.info(`Successfully retrieved complexity report${result.fromCache ? ' (from cache)' : ''}`); - } else { - log.error(`Failed to retrieve complexity report: ${result.error.message}`); - } - - return handleApiResult(result, log, 'Error retrieving complexity report'); - } catch (error) { - log.error(`Error in complexity-report tool: ${error.message}`); - return createErrorResponse(`Failed to retrieve complexity report: ${error.message}`); - } - }, - }); -} \ No newline at end of file + server.addTool({ + name: 'complexity_report', + description: 'Display the complexity analysis report in a readable format', + parameters: z.object({ + file: z + .string() + .optional() + .describe( + 'Path to the report file (default: scripts/task-complexity-report.json)' + ), + projectRoot: z + .string() + .optional() + .describe( + 'Root directory of the project (default: current working directory)' + ) + }), + execute: async (args, { log, session, reportProgress }) => { + try { + log.info( + `Getting complexity report with args: ${JSON.stringify(args)}` + ); + // await reportProgress({ progress: 0 }); + + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + const result = await complexityReportDirect( + { + projectRoot: rootFolder, + ...args + }, + log /*, { reportProgress, mcpLog: log, session}*/ + ); + + // await reportProgress({ progress: 100 }); + + if (result.success) { + log.info( + `Successfully retrieved complexity report${result.fromCache ? ' (from cache)' : ''}` + ); + } else { + log.error( + `Failed to retrieve complexity report: ${result.error.message}` + ); + } + + return handleApiResult( + result, + log, + 'Error retrieving complexity report' + ); + } catch (error) { + log.error(`Error in complexity-report tool: ${error.message}`); + return createErrorResponse( + `Failed to retrieve complexity report: ${error.message}` + ); + } + } + }); +} diff --git a/mcp-server/src/tools/expand-all.js b/mcp-server/src/tools/expand-all.js index b14fc6e9..0e283c31 100644 --- a/mcp-server/src/tools/expand-all.js +++ b/mcp-server/src/tools/expand-all.js @@ -3,57 +3,87 @@ * Tool for expanding all pending tasks with subtasks */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { expandAllTasksDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { expandAllTasksDirect } from '../core/task-master-core.js'; /** * Register the expandAll tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerExpandAllTool(server) { - server.addTool({ - name: "expand_all", - description: "Expand all pending tasks into subtasks", - parameters: z.object({ - num: z.string().optional().describe("Number of subtasks to generate for each task"), - 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 (default: tasks/tasks.json)"), - projectRoot: z.string().optional().describe("Root directory of the project (default: current working directory)") - }), - execute: async (args, { log, session }) => { - try { - log.info(`Expanding all tasks with args: ${JSON.stringify(args)}`); - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - const result = await expandAllTasksDirect({ - projectRoot: rootFolder, - ...args - }, log, { session }); - - if (result.success) { - log.info(`Successfully expanded all tasks: ${result.data.message}`); - } else { - log.error(`Failed to expand all tasks: ${result.error?.message || 'Unknown error'}`); - } - - return handleApiResult(result, log, 'Error expanding all tasks'); - } catch (error) { - log.error(`Error in expand-all tool: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); -} \ No newline at end of file + server.addTool({ + name: 'expand_all', + description: 'Expand all pending tasks into subtasks', + parameters: z.object({ + num: z + .string() + .optional() + .describe('Number of subtasks to generate for each task'), + 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 (default: tasks/tasks.json)'), + projectRoot: z + .string() + .optional() + .describe( + 'Root directory of the project (default: current working directory)' + ) + }), + execute: async (args, { log, session }) => { + try { + log.info(`Expanding all tasks with args: ${JSON.stringify(args)}`); + + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + const result = await expandAllTasksDirect( + { + projectRoot: rootFolder, + ...args + }, + log, + { session } + ); + + if (result.success) { + log.info(`Successfully expanded all tasks: ${result.data.message}`); + } else { + log.error( + `Failed to expand all tasks: ${result.error?.message || 'Unknown error'}` + ); + } + + return handleApiResult(result, log, 'Error expanding all tasks'); + } catch (error) { + log.error(`Error in expand-all tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-server/src/tools/expand-task.js b/mcp-server/src/tools/expand-task.js index e578fdef..2a09e2b4 100644 --- a/mcp-server/src/tools/expand-task.js +++ b/mcp-server/src/tools/expand-task.js @@ -3,75 +3,88 @@ * Tool to expand a task into subtasks */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { expandTaskDirect } from "../core/task-master-core.js"; -import fs from "fs"; -import path from "path"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { expandTaskDirect } from '../core/task-master-core.js'; +import fs from 'fs'; +import path from 'path'; /** * Register the expand-task tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerExpandTaskTool(server) { - server.addTool({ - name: "expand_task", - description: "Expand a task into subtasks for detailed implementation", - parameters: z.object({ - id: z.string().describe("ID of task to expand"), - num: z.union([z.string(), z.number()]).optional().describe("Number of subtasks to generate"), - research: z.boolean().optional().describe("Use Perplexity AI for research-backed generation"), - prompt: z.string().optional().describe("Additional context for subtask generation"), - 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, reportProgress, session }) => { - try { - log.info(`Starting expand-task with args: ${JSON.stringify(args)}`); - - // Get project root from session - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - log.info(`Project root resolved to: ${rootFolder}`); - - // Check for tasks.json in the standard locations - const tasksJsonPath = path.join(rootFolder, 'tasks', 'tasks.json'); - - if (fs.existsSync(tasksJsonPath)) { - log.info(`Found tasks.json at ${tasksJsonPath}`); - // Add the file parameter directly to args - args.file = tasksJsonPath; - } else { - log.warn(`Could not find tasks.json at ${tasksJsonPath}`); - } - - // Call direct function with only session in the context, not reportProgress - // Use the pattern recommended in the MCP guidelines - const result = await expandTaskDirect({ - ...args, - projectRoot: rootFolder - }, log, { session }); // Only pass session, NOT reportProgress - - // Return the result - return handleApiResult(result, log, 'Error expanding task'); - } catch (error) { - log.error(`Error in expand task tool: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); -} \ No newline at end of file + server.addTool({ + name: 'expand_task', + description: 'Expand a task into subtasks for detailed implementation', + parameters: z.object({ + id: z.string().describe('ID of task to expand'), + num: z + .union([z.string(), z.number()]) + .optional() + .describe('Number of subtasks to generate'), + research: z + .boolean() + .optional() + .describe('Use Perplexity AI for research-backed generation'), + prompt: z + .string() + .optional() + .describe('Additional context for subtask generation'), + 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, reportProgress, session }) => { + try { + log.info(`Starting expand-task with args: ${JSON.stringify(args)}`); + + // Get project root from session + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + log.info(`Project root resolved to: ${rootFolder}`); + + // Check for tasks.json in the standard locations + const tasksJsonPath = path.join(rootFolder, 'tasks', 'tasks.json'); + + if (fs.existsSync(tasksJsonPath)) { + log.info(`Found tasks.json at ${tasksJsonPath}`); + // Add the file parameter directly to args + args.file = tasksJsonPath; + } else { + log.warn(`Could not find tasks.json at ${tasksJsonPath}`); + } + + // Call direct function with only session in the context, not reportProgress + // Use the pattern recommended in the MCP guidelines + const result = await expandTaskDirect( + { + ...args, + projectRoot: rootFolder + }, + log, + { session } + ); // Only pass session, NOT reportProgress + + // Return the result + return handleApiResult(result, log, 'Error expanding task'); + } catch (error) { + log.error(`Error in expand task tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-server/src/tools/fix-dependencies.js b/mcp-server/src/tools/fix-dependencies.js index 0d999940..326c2dec 100644 --- a/mcp-server/src/tools/fix-dependencies.js +++ b/mcp-server/src/tools/fix-dependencies.js @@ -3,56 +3,65 @@ * Tool for automatically fixing invalid task dependencies */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { fixDependenciesDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { fixDependenciesDirect } from '../core/task-master-core.js'; /** * Register the fixDependencies tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerFixDependenciesTool(server) { - server.addTool({ - name: "fix_dependencies", - description: "Fix invalid dependencies in tasks automatically", - parameters: z.object({ - 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, session, reportProgress }) => { - try { - log.info(`Fixing dependencies with args: ${JSON.stringify(args)}`); - await reportProgress({ progress: 0 }); - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - const result = await fixDependenciesDirect({ - projectRoot: rootFolder, - ...args - }, log, { reportProgress, mcpLog: log, session}); - - await reportProgress({ progress: 100 }); - - if (result.success) { - log.info(`Successfully fixed dependencies: ${result.data.message}`); - } else { - log.error(`Failed to fix dependencies: ${result.error.message}`); - } - - return handleApiResult(result, log, 'Error fixing dependencies'); - } catch (error) { - log.error(`Error in fixDependencies tool: ${error.message}`); - return createErrorResponse(error.message); - } - } - }); -} \ No newline at end of file + server.addTool({ + name: 'fix_dependencies', + description: 'Fix invalid dependencies in tasks automatically', + parameters: z.object({ + 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, session, reportProgress }) => { + try { + log.info(`Fixing dependencies with args: ${JSON.stringify(args)}`); + await reportProgress({ progress: 0 }); + + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + const result = await fixDependenciesDirect( + { + projectRoot: rootFolder, + ...args + }, + log, + { reportProgress, mcpLog: log, session } + ); + + await reportProgress({ progress: 100 }); + + if (result.success) { + log.info(`Successfully fixed dependencies: ${result.data.message}`); + } else { + log.error(`Failed to fix dependencies: ${result.error.message}`); + } + + return handleApiResult(result, log, 'Error fixing dependencies'); + } catch (error) { + log.error(`Error in fixDependencies tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-server/src/tools/generate.js b/mcp-server/src/tools/generate.js index 27fceb1a..e3996090 100644 --- a/mcp-server/src/tools/generate.js +++ b/mcp-server/src/tools/generate.js @@ -3,62 +3,71 @@ * Tool to generate individual task files from tasks.json */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { generateTaskFilesDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { generateTaskFilesDirect } from '../core/task-master-core.js'; /** * Register the generate tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerGenerateTool(server) { - server.addTool({ - name: "generate", - description: "Generates individual task files in tasks/ directory based on tasks.json", - parameters: z.object({ - file: z.string().optional().describe("Path to the tasks file"), - output: z.string().optional().describe("Output directory (default: same directory as tasks file)"), - projectRoot: z - .string() - .optional() - .describe( - "Root directory of the project (default: current working directory)" - ), - }), - execute: async (args, { log, session, reportProgress }) => { - try { - log.info(`Generating task files with args: ${JSON.stringify(args)}`); - // await reportProgress({ progress: 0 }); - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - const result = await generateTaskFilesDirect({ - projectRoot: rootFolder, - ...args - }, log/*, { reportProgress, mcpLog: log, session}*/); - - // await reportProgress({ progress: 100 }); - - if (result.success) { - log.info(`Successfully generated task files: ${result.data.message}`); - } else { - log.error(`Failed to generate task files: ${result.error?.message || 'Unknown error'}`); - } - - return handleApiResult(result, log, 'Error generating task files'); - } catch (error) { - log.error(`Error in generate tool: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); -} \ No newline at end of file + server.addTool({ + name: 'generate', + description: + 'Generates individual task files in tasks/ directory based on tasks.json', + parameters: z.object({ + file: z.string().optional().describe('Path to the tasks file'), + output: z + .string() + .optional() + .describe('Output directory (default: same directory as tasks file)'), + projectRoot: z + .string() + .optional() + .describe( + 'Root directory of the project (default: current working directory)' + ) + }), + execute: async (args, { log, session, reportProgress }) => { + try { + log.info(`Generating task files with args: ${JSON.stringify(args)}`); + // await reportProgress({ progress: 0 }); + + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + const result = await generateTaskFilesDirect( + { + projectRoot: rootFolder, + ...args + }, + log /*, { reportProgress, mcpLog: log, session}*/ + ); + + // await reportProgress({ progress: 100 }); + + if (result.success) { + log.info(`Successfully generated task files: ${result.data.message}`); + } else { + log.error( + `Failed to generate task files: ${result.error?.message || 'Unknown error'}` + ); + } + + return handleApiResult(result, log, 'Error generating task files'); + } catch (error) { + log.error(`Error in generate tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-server/src/tools/get-operation-status.js b/mcp-server/src/tools/get-operation-status.js index 9b8d2999..7713c612 100644 --- a/mcp-server/src/tools/get-operation-status.js +++ b/mcp-server/src/tools/get-operation-status.js @@ -8,35 +8,40 @@ import { createErrorResponse, createContentResponse } from './utils.js'; // Assu * @param {AsyncOperationManager} asyncManager - The async operation manager. */ export function registerGetOperationStatusTool(server, asyncManager) { - server.addTool({ - name: 'get_operation_status', - description: 'Retrieves the status and result/error of a background operation.', - parameters: z.object({ - operationId: z.string().describe('The ID of the operation to check.'), - }), - execute: async (args, { log }) => { - try { - const { operationId } = args; - log.info(`Checking status for operation ID: ${operationId}`); + server.addTool({ + name: 'get_operation_status', + description: + 'Retrieves the status and result/error of a background operation.', + parameters: z.object({ + operationId: z.string().describe('The ID of the operation to check.') + }), + execute: async (args, { log }) => { + try { + const { operationId } = args; + log.info(`Checking status for operation ID: ${operationId}`); - const status = asyncManager.getStatus(operationId); + const status = asyncManager.getStatus(operationId); - // Status will now always return an object, but it might have status='not_found' - if (status.status === 'not_found') { - log.warn(`Operation ID not found: ${operationId}`); - return createErrorResponse( - status.error?.message || `Operation ID not found: ${operationId}`, - status.error?.code || 'OPERATION_NOT_FOUND' - ); - } + // Status will now always return an object, but it might have status='not_found' + if (status.status === 'not_found') { + log.warn(`Operation ID not found: ${operationId}`); + return createErrorResponse( + status.error?.message || `Operation ID not found: ${operationId}`, + status.error?.code || 'OPERATION_NOT_FOUND' + ); + } - log.info(`Status for ${operationId}: ${status.status}`); - return createContentResponse(status); - - } catch (error) { - log.error(`Error in get_operation_status tool: ${error.message}`, { stack: error.stack }); - return createErrorResponse(`Failed to get operation status: ${error.message}`, 'GET_STATUS_ERROR'); - } - }, - }); -} \ No newline at end of file + log.info(`Status for ${operationId}: ${status.status}`); + return createContentResponse(status); + } catch (error) { + log.error(`Error in get_operation_status tool: ${error.message}`, { + stack: error.stack + }); + return createErrorResponse( + `Failed to get operation status: ${error.message}`, + 'GET_STATUS_ERROR' + ); + } + } + }); +} diff --git a/mcp-server/src/tools/get-task.js b/mcp-server/src/tools/get-task.js index 17289059..24479181 100644 --- a/mcp-server/src/tools/get-task.js +++ b/mcp-server/src/tools/get-task.js @@ -3,13 +3,13 @@ * Tool to get task details by ID */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { showTaskDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { showTaskDirect } from '../core/task-master-core.js'; /** * Custom processor function that removes allTasks from the response @@ -17,16 +17,16 @@ import { showTaskDirect } from "../core/task-master-core.js"; * @returns {Object} - The processed data with allTasks removed */ function processTaskResponse(data) { - if (!data) return data; - - // If we have the expected structure with task and allTasks - if (data.task) { - // Return only the task object, removing the allTasks array - return data.task; - } - - // If structure is unexpected, return as is - return data; + if (!data) return data; + + // If we have the expected structure with task and allTasks + if (data.task) { + // Return only the task object, removing the allTasks array + return data.task; + } + + // If structure is unexpected, return as is + return data; } /** @@ -34,59 +34,75 @@ function processTaskResponse(data) { * @param {Object} server - FastMCP server instance */ export function registerShowTaskTool(server) { - server.addTool({ - name: "get_task", - description: "Get detailed information about a specific task", - parameters: z.object({ - id: z.string().describe("Task ID to get"), - 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, session, reportProgress }) => { - // Log the session right at the start of execute - log.info(`Session object received in execute: ${JSON.stringify(session)}`); // Use JSON.stringify for better visibility + server.addTool({ + name: 'get_task', + description: 'Get detailed information about a specific task', + parameters: z.object({ + id: z.string().describe('Task ID to get'), + 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, session, reportProgress }) => { + // Log the session right at the start of execute + log.info( + `Session object received in execute: ${JSON.stringify(session)}` + ); // Use JSON.stringify for better visibility - try { - log.info(`Getting task details for ID: ${args.id}`); + try { + log.info(`Getting task details for ID: ${args.id}`); - log.info(`Session object received in execute: ${JSON.stringify(session)}`); // Use JSON.stringify for better visibility - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } else if (!rootFolder) { - // Ensure we always have *some* root, even if session failed and args didn't provide one - rootFolder = process.cwd(); - log.warn(`Session and args failed to provide root, using CWD: ${rootFolder}`); - } + log.info( + `Session object received in execute: ${JSON.stringify(session)}` + ); // Use JSON.stringify for better visibility - log.info(`Attempting to use project root: ${rootFolder}`); // Log the final resolved root + let rootFolder = getProjectRootFromSession(session, log); - log.info(`Root folder: ${rootFolder}`); // Log the final resolved root - const result = await showTaskDirect({ - projectRoot: rootFolder, - ...args - }, log); - - if (result.success) { - log.info(`Successfully retrieved task details for ID: ${args.id}${result.fromCache ? ' (from cache)' : ''}`); - } else { - log.error(`Failed to get task: ${result.error.message}`); - } - - // Use our custom processor function to remove allTasks from the response - return handleApiResult(result, log, 'Error retrieving task details', processTaskResponse); - } catch (error) { - log.error(`Error in get-task tool: ${error.message}\n${error.stack}`); // Add stack trace - return createErrorResponse(`Failed to get task: ${error.message}`); - } - }, - }); -} \ No newline at end of file + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } else if (!rootFolder) { + // Ensure we always have *some* root, even if session failed and args didn't provide one + rootFolder = process.cwd(); + log.warn( + `Session and args failed to provide root, using CWD: ${rootFolder}` + ); + } + + log.info(`Attempting to use project root: ${rootFolder}`); // Log the final resolved root + + log.info(`Root folder: ${rootFolder}`); // Log the final resolved root + const result = await showTaskDirect( + { + projectRoot: rootFolder, + ...args + }, + log + ); + + if (result.success) { + log.info( + `Successfully retrieved task details for ID: ${args.id}${result.fromCache ? ' (from cache)' : ''}` + ); + } else { + log.error(`Failed to get task: ${result.error.message}`); + } + + // Use our custom processor function to remove allTasks from the response + return handleApiResult( + result, + log, + 'Error retrieving task details', + processTaskResponse + ); + } catch (error) { + log.error(`Error in get-task tool: ${error.message}\n${error.stack}`); // Add stack trace + return createErrorResponse(`Failed to get task: ${error.message}`); + } + } + }); +} diff --git a/mcp-server/src/tools/get-tasks.js b/mcp-server/src/tools/get-tasks.js index 44242efe..055f7c38 100644 --- a/mcp-server/src/tools/get-tasks.js +++ b/mcp-server/src/tools/get-tasks.js @@ -3,63 +3,79 @@ * Tool to get all tasks from Task Master */ -import { z } from "zod"; +import { z } from 'zod'; import { - createErrorResponse, - handleApiResult, - getProjectRootFromSession -} from "./utils.js"; -import { listTasksDirect } from "../core/task-master-core.js"; + createErrorResponse, + handleApiResult, + getProjectRootFromSession +} from './utils.js'; +import { listTasksDirect } from '../core/task-master-core.js'; /** * Register the getTasks tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerListTasksTool(server) { - server.addTool({ - name: "get_tasks", - description: "Get all tasks from Task Master, optionally filtering by status and including subtasks.", - parameters: z.object({ - status: z.string().optional().describe("Filter tasks by status (e.g., 'pending', 'done')"), - withSubtasks: z - .boolean() - .optional() - .describe("Include subtasks nested within their parent tasks in the response"), - file: z.string().optional().describe("Path to the tasks file (relative to project root or absolute)"), - projectRoot: z - .string() - .optional() - .describe( - "Root directory of the project (default: automatically detected from session or CWD)" - ), - }), - execute: async (args, { log, session, reportProgress }) => { - try { - log.info(`Getting tasks with filters: ${JSON.stringify(args)}`); - // await reportProgress({ progress: 0 }); - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - const result = await listTasksDirect({ - projectRoot: rootFolder, - ...args - }, log/*, { reportProgress, mcpLog: log, session}*/); - - // await reportProgress({ progress: 100 }); - - log.info(`Retrieved ${result.success ? (result.data?.tasks?.length || 0) : 0} tasks${result.fromCache ? ' (from cache)' : ''}`); - return handleApiResult(result, log, 'Error getting tasks'); - } catch (error) { - log.error(`Error getting tasks: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); + server.addTool({ + name: 'get_tasks', + description: + 'Get all tasks from Task Master, optionally filtering by status and including subtasks.', + parameters: z.object({ + status: z + .string() + .optional() + .describe("Filter tasks by status (e.g., 'pending', 'done')"), + withSubtasks: z + .boolean() + .optional() + .describe( + 'Include subtasks nested within their parent tasks in the response' + ), + file: z + .string() + .optional() + .describe( + 'Path to the tasks file (relative to project root or absolute)' + ), + projectRoot: z + .string() + .optional() + .describe( + 'Root directory of the project (default: automatically detected from session or CWD)' + ) + }), + execute: async (args, { log, session, reportProgress }) => { + try { + log.info(`Getting tasks with filters: ${JSON.stringify(args)}`); + // await reportProgress({ progress: 0 }); + + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + const result = await listTasksDirect( + { + projectRoot: rootFolder, + ...args + }, + log /*, { reportProgress, mcpLog: log, session}*/ + ); + + // await reportProgress({ progress: 100 }); + + log.info( + `Retrieved ${result.success ? result.data?.tasks?.length || 0 : 0} tasks${result.fromCache ? ' (from cache)' : ''}` + ); + return handleApiResult(result, log, 'Error getting tasks'); + } catch (error) { + log.error(`Error getting 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/index.js b/mcp-server/src/tools/index.js index af53176b..30fa72ac 100644 --- a/mcp-server/src/tools/index.js +++ b/mcp-server/src/tools/index.js @@ -3,28 +3,28 @@ * Export all Task Master CLI tools for MCP server */ -import { registerListTasksTool } from "./get-tasks.js"; -import logger from "../logger.js"; -import { registerSetTaskStatusTool } from "./set-task-status.js"; -import { registerParsePRDTool } from "./parse-prd.js"; -import { registerUpdateTool } from "./update.js"; -import { registerUpdateTaskTool } from "./update-task.js"; -import { registerUpdateSubtaskTool } from "./update-subtask.js"; -import { registerGenerateTool } from "./generate.js"; -import { registerShowTaskTool } from "./get-task.js"; -import { registerNextTaskTool } from "./next-task.js"; -import { registerExpandTaskTool } from "./expand-task.js"; -import { registerAddTaskTool } from "./add-task.js"; -import { registerAddSubtaskTool } from "./add-subtask.js"; -import { registerRemoveSubtaskTool } from "./remove-subtask.js"; -import { registerAnalyzeTool } from "./analyze.js"; -import { registerClearSubtasksTool } from "./clear-subtasks.js"; -import { registerExpandAllTool } from "./expand-all.js"; -import { registerRemoveDependencyTool } from "./remove-dependency.js"; -import { registerValidateDependenciesTool } from "./validate-dependencies.js"; -import { registerFixDependenciesTool } from "./fix-dependencies.js"; -import { registerComplexityReportTool } from "./complexity-report.js"; -import { registerAddDependencyTool } from "./add-dependency.js"; +import { registerListTasksTool } from './get-tasks.js'; +import logger from '../logger.js'; +import { registerSetTaskStatusTool } from './set-task-status.js'; +import { registerParsePRDTool } from './parse-prd.js'; +import { registerUpdateTool } from './update.js'; +import { registerUpdateTaskTool } from './update-task.js'; +import { registerUpdateSubtaskTool } from './update-subtask.js'; +import { registerGenerateTool } from './generate.js'; +import { registerShowTaskTool } from './get-task.js'; +import { registerNextTaskTool } from './next-task.js'; +import { registerExpandTaskTool } from './expand-task.js'; +import { registerAddTaskTool } from './add-task.js'; +import { registerAddSubtaskTool } from './add-subtask.js'; +import { registerRemoveSubtaskTool } from './remove-subtask.js'; +import { registerAnalyzeTool } from './analyze.js'; +import { registerClearSubtasksTool } from './clear-subtasks.js'; +import { registerExpandAllTool } from './expand-all.js'; +import { registerRemoveDependencyTool } from './remove-dependency.js'; +import { registerValidateDependenciesTool } from './validate-dependencies.js'; +import { registerFixDependenciesTool } from './fix-dependencies.js'; +import { registerComplexityReportTool } from './complexity-report.js'; +import { registerAddDependencyTool } from './add-dependency.js'; import { registerRemoveTaskTool } from './remove-task.js'; import { registerInitializeProjectTool } from './initialize-project.js'; import { asyncOperationManager } from '../core/utils/async-manager.js'; @@ -34,40 +34,40 @@ import { asyncOperationManager } from '../core/utils/async-manager.js'; * @param {Object} server - FastMCP server instance * @param {asyncOperationManager} asyncManager - The async operation manager instance */ -export function registerTaskMasterTools(server, asyncManager) { - try { - // Register each tool - registerListTasksTool(server); - registerSetTaskStatusTool(server); - registerParsePRDTool(server); - registerUpdateTool(server); - registerUpdateTaskTool(server); - registerUpdateSubtaskTool(server); - registerGenerateTool(server); - registerShowTaskTool(server); - registerNextTaskTool(server); - registerExpandTaskTool(server); - registerAddTaskTool(server, asyncManager); - registerAddSubtaskTool(server); - registerRemoveSubtaskTool(server); - registerAnalyzeTool(server); - registerClearSubtasksTool(server); - registerExpandAllTool(server); - registerRemoveDependencyTool(server); - registerValidateDependenciesTool(server); - registerFixDependenciesTool(server); - registerComplexityReportTool(server); - registerAddDependencyTool(server); - registerRemoveTaskTool(server); - registerInitializeProjectTool(server); - } catch (error) { - logger.error(`Error registering Task Master tools: ${error.message}`); - throw error; - } +export function registerTaskMasterTools(server, asyncManager) { + try { + // Register each tool + registerListTasksTool(server); + registerSetTaskStatusTool(server); + registerParsePRDTool(server); + registerUpdateTool(server); + registerUpdateTaskTool(server); + registerUpdateSubtaskTool(server); + registerGenerateTool(server); + registerShowTaskTool(server); + registerNextTaskTool(server); + registerExpandTaskTool(server); + registerAddTaskTool(server, asyncManager); + registerAddSubtaskTool(server); + registerRemoveSubtaskTool(server); + registerAnalyzeTool(server); + registerClearSubtasksTool(server); + registerExpandAllTool(server); + registerRemoveDependencyTool(server); + registerValidateDependenciesTool(server); + registerFixDependenciesTool(server); + registerComplexityReportTool(server); + registerAddDependencyTool(server); + registerRemoveTaskTool(server); + registerInitializeProjectTool(server); + } catch (error) { + logger.error(`Error registering Task Master tools: ${error.message}`); + throw error; + } - logger.info('Registered Task Master MCP tools'); + logger.info('Registered Task Master MCP tools'); } export default { - registerTaskMasterTools, -}; \ No newline at end of file + registerTaskMasterTools +}; diff --git a/mcp-server/src/tools/initialize-project.js b/mcp-server/src/tools/initialize-project.js index 9b7e03b2..168af1fc 100644 --- a/mcp-server/src/tools/initialize-project.js +++ b/mcp-server/src/tools/initialize-project.js @@ -1,62 +1,99 @@ -import { z } from "zod"; +import { z } from 'zod'; import { execSync } from 'child_process'; -import { createContentResponse, createErrorResponse } from "./utils.js"; // Only need response creators +import { createContentResponse, createErrorResponse } from './utils.js'; // Only need response creators export function registerInitializeProjectTool(server) { - server.addTool({ - name: "initialize_project", // snake_case for tool name - description: "Initializes a new Task Master project structure in the current working directory by running 'task-master init'.", - parameters: z.object({ - projectName: z.string().optional().describe("The name for the new project."), - projectDescription: z.string().optional().describe("A brief description for the project."), - projectVersion: z.string().optional().describe("The initial version for the project (e.g., '0.1.0')."), - authorName: z.string().optional().describe("The author's name."), - skipInstall: z.boolean().optional().default(false).describe("Skip installing dependencies automatically."), - addAliases: z.boolean().optional().default(false).describe("Add shell aliases (tm, taskmaster) to shell config file."), - yes: z.boolean().optional().default(false).describe("Skip prompts and use default values or provided arguments."), - // projectRoot is not needed here as 'init' works on the current directory - }), - execute: async (args, { log }) => { // Destructure context to get log - try { - log.info(`Executing initialize_project with args: ${JSON.stringify(args)}`); + server.addTool({ + name: 'initialize_project', // snake_case for tool name + description: + "Initializes a new Task Master project structure in the current working directory by running 'task-master init'.", + parameters: z.object({ + projectName: z + .string() + .optional() + .describe('The name for the new project.'), + projectDescription: z + .string() + .optional() + .describe('A brief description for the project.'), + projectVersion: z + .string() + .optional() + .describe("The initial version for the project (e.g., '0.1.0')."), + authorName: z.string().optional().describe("The author's name."), + skipInstall: z + .boolean() + .optional() + .default(false) + .describe('Skip installing dependencies automatically.'), + addAliases: z + .boolean() + .optional() + .default(false) + .describe('Add shell aliases (tm, taskmaster) to shell config file.'), + yes: z + .boolean() + .optional() + .default(false) + .describe('Skip prompts and use default values or provided arguments.') + // projectRoot is not needed here as 'init' works on the current directory + }), + execute: async (args, { log }) => { + // Destructure context to get log + try { + log.info( + `Executing initialize_project with args: ${JSON.stringify(args)}` + ); - // Construct the command arguments carefully - // Using npx ensures it uses the locally installed version if available, or fetches it - let command = 'npx task-master init'; - const cliArgs = []; - if (args.projectName) cliArgs.push(`--name "${args.projectName.replace(/"/g, '\\"')}"`); // Escape quotes - if (args.projectDescription) cliArgs.push(`--description "${args.projectDescription.replace(/"/g, '\\"')}"`); - if (args.projectVersion) cliArgs.push(`--version "${args.projectVersion.replace(/"/g, '\\"')}"`); - if (args.authorName) cliArgs.push(`--author "${args.authorName.replace(/"/g, '\\"')}"`); - if (args.skipInstall) cliArgs.push('--skip-install'); - if (args.addAliases) cliArgs.push('--aliases'); - if (args.yes) cliArgs.push('--yes'); + // Construct the command arguments carefully + // Using npx ensures it uses the locally installed version if available, or fetches it + let command = 'npx task-master init'; + const cliArgs = []; + if (args.projectName) + cliArgs.push(`--name "${args.projectName.replace(/"/g, '\\"')}"`); // Escape quotes + if (args.projectDescription) + cliArgs.push( + `--description "${args.projectDescription.replace(/"/g, '\\"')}"` + ); + if (args.projectVersion) + cliArgs.push( + `--version "${args.projectVersion.replace(/"/g, '\\"')}"` + ); + if (args.authorName) + cliArgs.push(`--author "${args.authorName.replace(/"/g, '\\"')}"`); + if (args.skipInstall) cliArgs.push('--skip-install'); + if (args.addAliases) cliArgs.push('--aliases'); + if (args.yes) cliArgs.push('--yes'); - command += ' ' + cliArgs.join(' '); + command += ' ' + cliArgs.join(' '); - log.info(`Constructed command: ${command}`); + log.info(`Constructed command: ${command}`); - // Execute the command in the current working directory of the server process - // Capture stdout/stderr. Use a reasonable timeout (e.g., 5 minutes) - const output = execSync(command, { encoding: 'utf8', stdio: 'pipe', timeout: 300000 }); + // Execute the command in the current working directory of the server process + // Capture stdout/stderr. Use a reasonable timeout (e.g., 5 minutes) + const output = execSync(command, { + encoding: 'utf8', + stdio: 'pipe', + timeout: 300000 + }); - log.info(`Initialization output:\n${output}`); + log.info(`Initialization output:\n${output}`); - // Return a standard success response manually - return createContentResponse( - "Project initialized successfully.", - { output: output } // Include output in the data payload - ); + // Return a standard success response manually + return createContentResponse( + 'Project initialized successfully.', + { output: output } // Include output in the data payload + ); + } catch (error) { + // Catch errors from execSync or timeouts + const errorMessage = `Project initialization failed: ${error.message}`; + const errorDetails = + error.stderr?.toString() || error.stdout?.toString() || error.message; // Provide stderr/stdout if available + log.error(`${errorMessage}\nDetails: ${errorDetails}`); - } catch (error) { - // Catch errors from execSync or timeouts - const errorMessage = `Project initialization failed: ${error.message}`; - const errorDetails = error.stderr?.toString() || error.stdout?.toString() || error.message; // Provide stderr/stdout if available - log.error(`${errorMessage}\nDetails: ${errorDetails}`); - - // Return a standard error response manually - return createErrorResponse(errorMessage, { details: errorDetails }); - } - } - }); -} \ No newline at end of file + // Return a standard error response manually + return createErrorResponse(errorMessage, { details: errorDetails }); + } + } + }); +} diff --git a/mcp-server/src/tools/next-task.js b/mcp-server/src/tools/next-task.js index 53f27c85..40be7972 100644 --- a/mcp-server/src/tools/next-task.js +++ b/mcp-server/src/tools/next-task.js @@ -3,61 +3,69 @@ * Tool to find the next task to work on */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { nextTaskDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { nextTaskDirect } from '../core/task-master-core.js'; /** * Register the next-task tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerNextTaskTool(server) { - server.addTool({ - name: "next_task", - description: "Find 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() - .optional() - .describe( - "Root directory of the project (default: current working directory)" - ), - }), - execute: async (args, { log, session, reportProgress }) => { - try { - log.info(`Finding next task with args: ${JSON.stringify(args)}`); - // await reportProgress({ progress: 0 }); - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - const result = await nextTaskDirect({ - projectRoot: rootFolder, - ...args - }, log/*, { reportProgress, mcpLog: log, session}*/); - - // await reportProgress({ progress: 100 }); - - if (result.success) { - log.info(`Successfully found next task: ${result.data?.task?.id || 'No available tasks'}`); - } else { - log.error(`Failed to find next task: ${result.error?.message || 'Unknown error'}`); - } - - return handleApiResult(result, log, 'Error finding next task'); - } catch (error) { - log.error(`Error in nextTask tool: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); -} \ No newline at end of file + server.addTool({ + name: 'next_task', + description: + 'Find 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() + .optional() + .describe( + 'Root directory of the project (default: current working directory)' + ) + }), + execute: async (args, { log, session, reportProgress }) => { + try { + log.info(`Finding next task with args: ${JSON.stringify(args)}`); + // await reportProgress({ progress: 0 }); + + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + const result = await nextTaskDirect( + { + projectRoot: rootFolder, + ...args + }, + log /*, { reportProgress, mcpLog: log, session}*/ + ); + + // await reportProgress({ progress: 100 }); + + if (result.success) { + log.info( + `Successfully found next task: ${result.data?.task?.id || 'No available tasks'}` + ); + } else { + log.error( + `Failed to find next task: ${result.error?.message || 'Unknown error'}` + ); + } + + return handleApiResult(result, log, 'Error finding next task'); + } catch (error) { + log.error(`Error in nextTask tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-server/src/tools/parse-prd.js b/mcp-server/src/tools/parse-prd.js index c51f5ce7..cacf2a91 100644 --- a/mcp-server/src/tools/parse-prd.js +++ b/mcp-server/src/tools/parse-prd.js @@ -3,61 +3,86 @@ * Tool to parse PRD document and generate tasks */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { parsePRDDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { parsePRDDirect } from '../core/task-master-core.js'; /** * Register the parsePRD tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerParsePRDTool(server) { - server.addTool({ - name: "parse_prd", - description: "Parse a Product Requirements Document (PRD) or text file to automatically generate initial tasks.", - parameters: z.object({ - input: z.string().default("tasks/tasks.json").describe("Path to the PRD document file (relative to project root or absolute)"), - numTasks: z.string().optional().describe("Approximate number of top-level tasks to generate (default: 10)"), - output: z.string().optional().describe("Output path for tasks.json file (relative to project root or absolute, default: tasks/tasks.json)"), - force: z.boolean().optional().describe("Allow overwriting an existing tasks.json file."), - projectRoot: z - .string() - .optional() - .describe( - "Root directory of the project (default: automatically detected from session or CWD)" - ), - }), - execute: async (args, { log, session }) => { - try { - log.info(`Parsing PRD with args: ${JSON.stringify(args)}`); - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - const result = await parsePRDDirect({ - projectRoot: rootFolder, - ...args - }, log, { session }); - - if (result.success) { - log.info(`Successfully parsed PRD: ${result.data.message}`); - } else { - log.error(`Failed to parse PRD: ${result.error?.message || 'Unknown error'}`); - } - - return handleApiResult(result, log, 'Error parsing PRD'); - } catch (error) { - log.error(`Error in parse-prd tool: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); -} \ No newline at end of file + server.addTool({ + name: 'parse_prd', + description: + 'Parse a Product Requirements Document (PRD) or text file to automatically generate initial tasks.', + parameters: z.object({ + input: z + .string() + .default('tasks/tasks.json') + .describe( + 'Path to the PRD document file (relative to project root or absolute)' + ), + numTasks: z + .string() + .optional() + .describe( + 'Approximate number of top-level tasks to generate (default: 10)' + ), + output: z + .string() + .optional() + .describe( + 'Output path for tasks.json file (relative to project root or absolute, default: tasks/tasks.json)' + ), + force: z + .boolean() + .optional() + .describe('Allow overwriting an existing tasks.json file.'), + projectRoot: z + .string() + .optional() + .describe( + 'Root directory of the project (default: automatically detected from session or CWD)' + ) + }), + execute: async (args, { log, session }) => { + try { + log.info(`Parsing PRD with args: ${JSON.stringify(args)}`); + + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + const result = await parsePRDDirect( + { + projectRoot: rootFolder, + ...args + }, + log, + { session } + ); + + if (result.success) { + log.info(`Successfully parsed PRD: ${result.data.message}`); + } else { + log.error( + `Failed to parse PRD: ${result.error?.message || 'Unknown error'}` + ); + } + + return handleApiResult(result, log, 'Error parsing PRD'); + } catch (error) { + log.error(`Error in parse-prd tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-server/src/tools/remove-dependency.js b/mcp-server/src/tools/remove-dependency.js index 99e6dfdb..7714df86 100644 --- a/mcp-server/src/tools/remove-dependency.js +++ b/mcp-server/src/tools/remove-dependency.js @@ -3,58 +3,71 @@ * Tool for removing a dependency from a task */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { removeDependencyDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { removeDependencyDirect } from '../core/task-master-core.js'; /** * Register the removeDependency tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerRemoveDependencyTool(server) { - server.addTool({ - name: "remove_dependency", - description: "Remove a dependency from a task", - parameters: z.object({ - id: z.string().describe("Task ID to remove dependency from"), - dependsOn: z.string().describe("Task ID to remove as a dependency"), - file: z.string().optional().describe("Path to the tasks file (default: tasks/tasks.json)"), - projectRoot: z.string().optional().describe("Root directory of the project (default: current working directory)") - }), - execute: async (args, { log, session, reportProgress }) => { - try { - log.info(`Removing dependency for task ${args.id} from ${args.dependsOn} with args: ${JSON.stringify(args)}`); - // await reportProgress({ progress: 0 }); - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - const result = await removeDependencyDirect({ - projectRoot: rootFolder, - ...args - }, log/*, { reportProgress, mcpLog: log, session}*/); - - // await reportProgress({ progress: 100 }); - - if (result.success) { - log.info(`Successfully removed dependency: ${result.data.message}`); - } else { - log.error(`Failed to remove dependency: ${result.error.message}`); - } - - return handleApiResult(result, log, 'Error removing dependency'); - } catch (error) { - log.error(`Error in removeDependency tool: ${error.message}`); - return createErrorResponse(error.message); - } - } - }); -} \ No newline at end of file + server.addTool({ + name: 'remove_dependency', + description: 'Remove a dependency from a task', + parameters: z.object({ + id: z.string().describe('Task ID to remove dependency from'), + dependsOn: z.string().describe('Task ID to remove as a dependency'), + file: z + .string() + .optional() + .describe('Path to the tasks file (default: tasks/tasks.json)'), + projectRoot: z + .string() + .optional() + .describe( + 'Root directory of the project (default: current working directory)' + ) + }), + execute: async (args, { log, session, reportProgress }) => { + try { + log.info( + `Removing dependency for task ${args.id} from ${args.dependsOn} with args: ${JSON.stringify(args)}` + ); + // await reportProgress({ progress: 0 }); + + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + const result = await removeDependencyDirect( + { + projectRoot: rootFolder, + ...args + }, + log /*, { reportProgress, mcpLog: log, session}*/ + ); + + // await reportProgress({ progress: 100 }); + + if (result.success) { + log.info(`Successfully removed dependency: ${result.data.message}`); + } else { + log.error(`Failed to remove dependency: ${result.error.message}`); + } + + return handleApiResult(result, log, 'Error removing dependency'); + } catch (error) { + log.error(`Error in removeDependency tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-server/src/tools/remove-subtask.js b/mcp-server/src/tools/remove-subtask.js index 4f1c9b55..b79a0050 100644 --- a/mcp-server/src/tools/remove-subtask.js +++ b/mcp-server/src/tools/remove-subtask.js @@ -3,59 +3,82 @@ * Tool for removing subtasks from parent tasks */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { removeSubtaskDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { removeSubtaskDirect } from '../core/task-master-core.js'; /** * Register the removeSubtask tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerRemoveSubtaskTool(server) { - server.addTool({ - name: "remove_subtask", - description: "Remove a subtask from its parent task", - parameters: z.object({ - id: z.string().describe("Subtask ID to remove in format 'parentId.subtaskId' (required)"), - convert: z.boolean().optional().describe("Convert the subtask to a standalone task instead of deleting it"), - file: z.string().optional().describe("Path to the tasks file (default: tasks/tasks.json)"), - skipGenerate: z.boolean().optional().describe("Skip regenerating task files"), - projectRoot: z.string().optional().describe("Root directory of the project (default: current working directory)") - }), - execute: async (args, { log, session, reportProgress }) => { - try { - log.info(`Removing subtask with args: ${JSON.stringify(args)}`); - // await reportProgress({ progress: 0 }); - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - const result = await removeSubtaskDirect({ - projectRoot: rootFolder, - ...args - }, log/*, { reportProgress, mcpLog: log, session}*/); - - // await reportProgress({ progress: 100 }); - - if (result.success) { - log.info(`Subtask removed successfully: ${result.data.message}`); - } else { - log.error(`Failed to remove subtask: ${result.error.message}`); - } - - return handleApiResult(result, log, 'Error removing subtask'); - } catch (error) { - log.error(`Error in removeSubtask tool: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); -} \ No newline at end of file + server.addTool({ + name: 'remove_subtask', + description: 'Remove a subtask from its parent task', + parameters: z.object({ + id: z + .string() + .describe( + "Subtask ID to remove in format 'parentId.subtaskId' (required)" + ), + convert: z + .boolean() + .optional() + .describe( + 'Convert the subtask to a standalone task instead of deleting it' + ), + file: z + .string() + .optional() + .describe('Path to the tasks file (default: tasks/tasks.json)'), + skipGenerate: z + .boolean() + .optional() + .describe('Skip regenerating task files'), + projectRoot: z + .string() + .optional() + .describe( + 'Root directory of the project (default: current working directory)' + ) + }), + execute: async (args, { log, session, reportProgress }) => { + try { + log.info(`Removing subtask with args: ${JSON.stringify(args)}`); + // await reportProgress({ progress: 0 }); + + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + const result = await removeSubtaskDirect( + { + projectRoot: rootFolder, + ...args + }, + log /*, { reportProgress, mcpLog: log, session}*/ + ); + + // await reportProgress({ progress: 100 }); + + if (result.success) { + log.info(`Subtask removed successfully: ${result.data.message}`); + } else { + log.error(`Failed to remove subtask: ${result.error.message}`); + } + + return handleApiResult(result, log, 'Error removing subtask'); + } catch (error) { + log.error(`Error in removeSubtask tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-server/src/tools/remove-task.js b/mcp-server/src/tools/remove-task.js index 65e82a12..c07660f6 100644 --- a/mcp-server/src/tools/remove-task.js +++ b/mcp-server/src/tools/remove-task.js @@ -3,69 +3,79 @@ * Tool to remove a task by ID */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { removeTaskDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { removeTaskDirect } from '../core/task-master-core.js'; /** * Register the remove-task tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerRemoveTaskTool(server) { - server.addTool({ - name: "remove_task", - description: "Remove a task or subtask permanently from the tasks list", - parameters: z.object({ - id: z.string().describe("ID of the task or subtask to remove (e.g., '5' or '5.2')"), - file: z.string().optional().describe("Path to the tasks file"), - projectRoot: z - .string() - .optional() - .describe( - "Root directory of the project (default: current working directory)" - ), - confirm: z.boolean().optional().describe("Whether to skip confirmation prompt (default: false)") - }), - execute: async (args, { log, session }) => { - try { - log.info(`Removing task with ID: ${args.id}`); - - // Get project root from session - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } else if (!rootFolder) { - // Ensure we have a default if nothing else works - rootFolder = process.cwd(); - log.warn(`Session and args failed to provide root, using CWD: ${rootFolder}`); - } - - log.info(`Using project root: ${rootFolder}`); - - // Assume client has already handled confirmation if needed - const result = await removeTaskDirect({ - id: args.id, - file: args.file, - projectRoot: rootFolder - }, log); - - if (result.success) { - log.info(`Successfully removed task: ${args.id}`); - } else { - log.error(`Failed to remove task: ${result.error.message}`); - } - - return handleApiResult(result, log, 'Error removing task'); - } catch (error) { - log.error(`Error in remove-task tool: ${error.message}`); - return createErrorResponse(`Failed to remove task: ${error.message}`); - } - }, - }); -} \ No newline at end of file + server.addTool({ + name: 'remove_task', + description: 'Remove a task or subtask permanently from the tasks list', + parameters: z.object({ + id: z + .string() + .describe("ID of the task or subtask to remove (e.g., '5' or '5.2')"), + file: z.string().optional().describe('Path to the tasks file'), + projectRoot: z + .string() + .optional() + .describe( + 'Root directory of the project (default: current working directory)' + ), + confirm: z + .boolean() + .optional() + .describe('Whether to skip confirmation prompt (default: false)') + }), + execute: async (args, { log, session }) => { + try { + log.info(`Removing task with ID: ${args.id}`); + + // Get project root from session + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } else if (!rootFolder) { + // Ensure we have a default if nothing else works + rootFolder = process.cwd(); + log.warn( + `Session and args failed to provide root, using CWD: ${rootFolder}` + ); + } + + log.info(`Using project root: ${rootFolder}`); + + // Assume client has already handled confirmation if needed + const result = await removeTaskDirect( + { + id: args.id, + file: args.file, + projectRoot: rootFolder + }, + log + ); + + if (result.success) { + log.info(`Successfully removed task: ${args.id}`); + } else { + log.error(`Failed to remove task: ${result.error.message}`); + } + + return handleApiResult(result, log, 'Error removing task'); + } catch (error) { + log.error(`Error in remove-task tool: ${error.message}`); + return createErrorResponse(`Failed to remove task: ${error.message}`); + } + } + }); +} diff --git a/mcp-server/src/tools/set-task-status.js b/mcp-server/src/tools/set-task-status.js index e81804d7..c0a2994b 100644 --- a/mcp-server/src/tools/set-task-status.js +++ b/mcp-server/src/tools/set-task-status.js @@ -3,68 +3,81 @@ * Tool to set the status of a task */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { setTaskStatusDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { setTaskStatusDirect } from '../core/task-master-core.js'; /** * Register the setTaskStatus tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerSetTaskStatusTool(server) { - server.addTool({ - name: "set_task_status", - description: "Set the status of one or more tasks or subtasks.", - parameters: z.object({ - id: z - .string() - .describe("Task ID or subtask ID (e.g., '15', '15.2'). Can be comma-separated for multiple updates."), - status: z - .string() - .describe("New status to set (e.g., 'pending', 'done', 'in-progress', 'review', 'deferred', 'cancelled'."), - file: z.string().optional().describe("Path to the tasks file"), - projectRoot: z - .string() - .optional() - .describe( - "Root directory of the project (default: automatically detected)" - ), - }), - execute: async (args, { log, session }) => { - try { - log.info(`Setting status of task(s) ${args.id} to: ${args.status}`); - - // Get project root from session - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - // Call the direct function with the project root - const result = await setTaskStatusDirect({ - ...args, - projectRoot: rootFolder - }, log); - - // Log the result - if (result.success) { - log.info(`Successfully updated status for task(s) ${args.id} to "${args.status}": ${result.data.message}`); - } else { - log.error(`Failed to update task status: ${result.error?.message || 'Unknown error'}`); - } - - // Format and return the result - return handleApiResult(result, log, 'Error setting task status'); - } catch (error) { - log.error(`Error in setTaskStatus tool: ${error.message}`); - return createErrorResponse(`Error setting task status: ${error.message}`); - } - }, - }); + server.addTool({ + name: 'set_task_status', + description: 'Set the status of one or more tasks or subtasks.', + parameters: z.object({ + id: z + .string() + .describe( + "Task ID or subtask ID (e.g., '15', '15.2'). Can be comma-separated for multiple updates." + ), + status: z + .string() + .describe( + "New status to set (e.g., 'pending', 'done', 'in-progress', 'review', 'deferred', 'cancelled'." + ), + file: z.string().optional().describe('Path to the tasks file'), + projectRoot: z + .string() + .optional() + .describe( + 'Root directory of the project (default: automatically detected)' + ) + }), + execute: async (args, { log, session }) => { + try { + log.info(`Setting status of task(s) ${args.id} to: ${args.status}`); + + // Get project root from session + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + // Call the direct function with the project root + const result = await setTaskStatusDirect( + { + ...args, + projectRoot: rootFolder + }, + log + ); + + // Log the result + if (result.success) { + log.info( + `Successfully updated status for task(s) ${args.id} to "${args.status}": ${result.data.message}` + ); + } else { + log.error( + `Failed to update task status: ${result.error?.message || 'Unknown error'}` + ); + } + + // Format and return the result + return handleApiResult(result, log, 'Error setting task status'); + } catch (error) { + log.error(`Error in setTaskStatus tool: ${error.message}`); + return createErrorResponse( + `Error setting task status: ${error.message}` + ); + } + } + }); } diff --git a/mcp-server/src/tools/update-subtask.js b/mcp-server/src/tools/update-subtask.js index d8c3081f..aff80376 100644 --- a/mcp-server/src/tools/update-subtask.js +++ b/mcp-server/src/tools/update-subtask.js @@ -3,61 +3,75 @@ * Tool to append additional information to a specific subtask */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { updateSubtaskByIdDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { updateSubtaskByIdDirect } from '../core/task-master-core.js'; /** * Register the update-subtask tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerUpdateSubtaskTool(server) { - server.addTool({ - name: "update_subtask", - description: "Appends additional information to a specific subtask without replacing existing content", - parameters: z.object({ - id: z.string().describe("ID of the subtask to update in format \"parentId.subtaskId\" (e.g., \"5.2\")"), - prompt: z.string().describe("Information to add to the subtask"), - research: z.boolean().optional().describe("Use Perplexity AI for research-backed updates"), - 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, session }) => { - try { - log.info(`Updating subtask with args: ${JSON.stringify(args)}`); - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - const result = await updateSubtaskByIdDirect({ - projectRoot: rootFolder, - ...args - }, log, { session }); - - if (result.success) { - log.info(`Successfully updated subtask with ID ${args.id}`); - } else { - log.error(`Failed to update subtask: ${result.error?.message || 'Unknown error'}`); - } - - return handleApiResult(result, log, 'Error updating subtask'); - } catch (error) { - log.error(`Error in update_subtask tool: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); -} \ No newline at end of file + server.addTool({ + name: 'update_subtask', + description: + 'Appends additional information to a specific subtask without replacing existing content', + parameters: z.object({ + id: z + .string() + .describe( + 'ID of the subtask to update in format "parentId.subtaskId" (e.g., "5.2")' + ), + prompt: z.string().describe('Information to add to the subtask'), + research: z + .boolean() + .optional() + .describe('Use Perplexity AI for research-backed updates'), + 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, session }) => { + try { + log.info(`Updating subtask with args: ${JSON.stringify(args)}`); + + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + const result = await updateSubtaskByIdDirect( + { + projectRoot: rootFolder, + ...args + }, + log, + { session } + ); + + if (result.success) { + log.info(`Successfully updated subtask with ID ${args.id}`); + } else { + log.error( + `Failed to update subtask: ${result.error?.message || 'Unknown error'}` + ); + } + + return handleApiResult(result, log, 'Error updating subtask'); + } catch (error) { + log.error(`Error in update_subtask tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-server/src/tools/update-task.js b/mcp-server/src/tools/update-task.js index e9a900c0..8a907fa9 100644 --- a/mcp-server/src/tools/update-task.js +++ b/mcp-server/src/tools/update-task.js @@ -3,61 +3,75 @@ * Tool to update a single task by ID with new information */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { updateTaskByIdDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { updateTaskByIdDirect } from '../core/task-master-core.js'; /** * Register the update-task tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerUpdateTaskTool(server) { - server.addTool({ - name: "update_task", - description: "Updates a single task by ID with new information or context provided in the prompt.", - parameters: z.object({ - id: z.string().describe("ID of the task or subtask (e.g., '15', '15.2') to update"), - prompt: z.string().describe("New information or context to incorporate into the task"), - research: z.boolean().optional().describe("Use Perplexity AI for research-backed updates"), - 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, session }) => { - try { - log.info(`Updating task with args: ${JSON.stringify(args)}`); - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - const result = await updateTaskByIdDirect({ - projectRoot: rootFolder, - ...args - }, log, { session }); - - if (result.success) { - log.info(`Successfully updated task with ID ${args.id}`); - } else { - log.error(`Failed to update task: ${result.error?.message || 'Unknown error'}`); - } - - return handleApiResult(result, log, 'Error updating task'); - } catch (error) { - log.error(`Error in update_task tool: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); -} \ No newline at end of file + server.addTool({ + name: 'update_task', + description: + 'Updates a single task by ID with new information or context provided in the prompt.', + parameters: z.object({ + id: z + .string() + .describe("ID of the task or subtask (e.g., '15', '15.2') to update"), + prompt: z + .string() + .describe('New information or context to incorporate into the task'), + research: z + .boolean() + .optional() + .describe('Use Perplexity AI for research-backed updates'), + 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, session }) => { + try { + log.info(`Updating task with args: ${JSON.stringify(args)}`); + + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + const result = await updateTaskByIdDirect( + { + projectRoot: rootFolder, + ...args + }, + log, + { session } + ); + + if (result.success) { + log.info(`Successfully updated task with ID ${args.id}`); + } else { + log.error( + `Failed to update task: ${result.error?.message || 'Unknown error'}` + ); + } + + return handleApiResult(result, log, 'Error updating task'); + } catch (error) { + log.error(`Error in update_task tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-server/src/tools/update.js b/mcp-server/src/tools/update.js index 3e7947a3..44c9de6e 100644 --- a/mcp-server/src/tools/update.js +++ b/mcp-server/src/tools/update.js @@ -3,61 +3,79 @@ * Tool to update tasks based on new context/prompt */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { updateTasksDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { updateTasksDirect } from '../core/task-master-core.js'; /** * Register the update tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerUpdateTool(server) { - server.addTool({ - name: "update", - description: "Update multiple upcoming tasks (with ID >= 'from' ID) based on new context or changes provided in the prompt. Use 'update_task' instead for a single specific task.", - parameters: z.object({ - from: z.string().describe("Task ID from which to start updating (inclusive). IMPORTANT: This tool uses 'from', not 'id'"), - prompt: z.string().describe("Explanation of changes or new context to apply"), - research: z.boolean().optional().describe("Use Perplexity AI for research-backed updates"), - 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, session }) => { - try { - log.info(`Updating tasks with args: ${JSON.stringify(args)}`); - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - const result = await updateTasksDirect({ - projectRoot: rootFolder, - ...args - }, log, { session }); - - if (result.success) { - log.info(`Successfully updated tasks from ID ${args.from}: ${result.data.message}`); - } else { - log.error(`Failed to update tasks: ${result.error?.message || 'Unknown error'}`); - } - - return handleApiResult(result, log, 'Error updating tasks'); - } catch (error) { - log.error(`Error in update tool: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); -} \ No newline at end of file + server.addTool({ + name: 'update', + description: + "Update multiple upcoming tasks (with ID >= 'from' ID) based on new context or changes provided in the prompt. Use 'update_task' instead for a single specific task.", + parameters: z.object({ + from: z + .string() + .describe( + "Task ID from which to start updating (inclusive). IMPORTANT: This tool uses 'from', not 'id'" + ), + prompt: z + .string() + .describe('Explanation of changes or new context to apply'), + research: z + .boolean() + .optional() + .describe('Use Perplexity AI for research-backed updates'), + 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, session }) => { + try { + log.info(`Updating tasks with args: ${JSON.stringify(args)}`); + + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + const result = await updateTasksDirect( + { + projectRoot: rootFolder, + ...args + }, + log, + { session } + ); + + if (result.success) { + log.info( + `Successfully updated tasks from ID ${args.from}: ${result.data.message}` + ); + } else { + log.error( + `Failed to update tasks: ${result.error?.message || 'Unknown error'}` + ); + } + + return handleApiResult(result, log, 'Error updating tasks'); + } catch (error) { + log.error(`Error in update tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-server/src/tools/utils.js b/mcp-server/src/tools/utils.js index be3cf863..571030e0 100644 --- a/mcp-server/src/tools/utils.js +++ b/mcp-server/src/tools/utils.js @@ -3,68 +3,83 @@ * 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 fs from 'fs'; import { contextManager } from '../core/context-manager.js'; // Import the singleton // Import path utilities to ensure consistent path resolution -import { lastFoundProjectRoot, PROJECT_MARKERS } from '../core/utils/path-utils.js'; +import { + lastFoundProjectRoot, + PROJECT_MARKERS +} from '../core/utils/path-utils.js'; /** - * 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 */ function getProjectRoot(projectRootRaw, log) { - // PRECEDENCE ORDER: - // 1. Environment variable override - // 2. Explicitly provided projectRoot in args - // 3. Previously found/cached project root - // 4. Current directory if it has project markers - // 5. Current directory with warning - - // 1. Check for environment variable override - if (process.env.TASK_MASTER_PROJECT_ROOT) { - const envRoot = process.env.TASK_MASTER_PROJECT_ROOT; - const absolutePath = path.isAbsolute(envRoot) - ? envRoot - : path.resolve(process.cwd(), envRoot); - log.info(`Using project root from TASK_MASTER_PROJECT_ROOT environment variable: ${absolutePath}`); - return absolutePath; - } + // PRECEDENCE ORDER: + // 1. Environment variable override + // 2. Explicitly provided projectRoot in args + // 3. Previously found/cached project root + // 4. Current directory if it has project markers + // 5. Current directory with warning - // 2. If project root is explicitly provided, use it - if (projectRootRaw) { - const absolutePath = path.isAbsolute(projectRootRaw) - ? projectRootRaw - : path.resolve(process.cwd(), projectRootRaw); - - log.info(`Using explicitly provided project root: ${absolutePath}`); - return absolutePath; - } - - // 3. If we have a last found project root from a tasks.json search, use that for consistency - if (lastFoundProjectRoot) { - log.info(`Using last known project root where tasks.json was found: ${lastFoundProjectRoot}`); - return lastFoundProjectRoot; - } - - // 4. Check if the current directory has any indicators of being a task-master project - const currentDir = process.cwd(); - if (PROJECT_MARKERS.some(marker => { - const markerPath = path.join(currentDir, marker); - return fs.existsSync(markerPath); - })) { - log.info(`Using current directory as project root (found project markers): ${currentDir}`); - return currentDir; - } - - // 5. Default to current working directory but warn the user - log.warn(`No task-master project detected in current directory. Using ${currentDir} as project root.`); - log.warn('Consider using --project-root to specify the correct project location or set TASK_MASTER_PROJECT_ROOT environment variable.'); - return currentDir; + // 1. Check for environment variable override + if (process.env.TASK_MASTER_PROJECT_ROOT) { + const envRoot = process.env.TASK_MASTER_PROJECT_ROOT; + const absolutePath = path.isAbsolute(envRoot) + ? envRoot + : path.resolve(process.cwd(), envRoot); + log.info( + `Using project root from TASK_MASTER_PROJECT_ROOT environment variable: ${absolutePath}` + ); + return absolutePath; + } + + // 2. If project root is explicitly provided, use it + if (projectRootRaw) { + const absolutePath = path.isAbsolute(projectRootRaw) + ? projectRootRaw + : path.resolve(process.cwd(), projectRootRaw); + + log.info(`Using explicitly provided project root: ${absolutePath}`); + return absolutePath; + } + + // 3. If we have a last found project root from a tasks.json search, use that for consistency + if (lastFoundProjectRoot) { + log.info( + `Using last known project root where tasks.json was found: ${lastFoundProjectRoot}` + ); + return lastFoundProjectRoot; + } + + // 4. Check if the current directory has any indicators of being a task-master project + const currentDir = process.cwd(); + if ( + PROJECT_MARKERS.some((marker) => { + const markerPath = path.join(currentDir, marker); + return fs.existsSync(markerPath); + }) + ) { + log.info( + `Using current directory as project root (found project markers): ${currentDir}` + ); + return currentDir; + } + + // 5. Default to current working directory but warn the user + log.warn( + `No task-master project detected in current directory. Using ${currentDir} as project root.` + ); + log.warn( + 'Consider using --project-root to specify the correct project location or set TASK_MASTER_PROJECT_ROOT environment variable.' + ); + return currentDir; } /** @@ -74,81 +89,87 @@ function getProjectRoot(projectRootRaw, log) { * @returns {string|null} - The absolute path to the project root, or null if not found. */ function getProjectRootFromSession(session, log) { - try { - // Add detailed logging of session structure - log.info(`Session object: ${JSON.stringify({ - hasSession: !!session, - hasRoots: !!session?.roots, - rootsType: typeof session?.roots, - isRootsArray: Array.isArray(session?.roots), - rootsLength: session?.roots?.length, - firstRoot: session?.roots?.[0], - hasRootsRoots: !!session?.roots?.roots, - rootsRootsType: typeof session?.roots?.roots, - isRootsRootsArray: Array.isArray(session?.roots?.roots), - rootsRootsLength: session?.roots?.roots?.length, - firstRootsRoot: session?.roots?.roots?.[0] - })}`); - - // ALWAYS ensure we return a valid path for project root - const cwd = process.cwd(); - - // If we have a session with roots array - if (session?.roots?.[0]?.uri) { - const rootUri = session.roots[0].uri; - log.info(`Found rootUri in session.roots[0].uri: ${rootUri}`); - const rootPath = rootUri.startsWith('file://') - ? decodeURIComponent(rootUri.slice(7)) - : rootUri; - log.info(`Decoded rootPath: ${rootPath}`); - return rootPath; - } - - // If we have a session with roots.roots array (different structure) - if (session?.roots?.roots?.[0]?.uri) { - const rootUri = session.roots.roots[0].uri; - log.info(`Found rootUri in session.roots.roots[0].uri: ${rootUri}`); - const rootPath = rootUri.startsWith('file://') - ? decodeURIComponent(rootUri.slice(7)) - : rootUri; - log.info(`Decoded rootPath: ${rootPath}`); - return rootPath; - } + try { + // Add detailed logging of session structure + log.info( + `Session object: ${JSON.stringify({ + hasSession: !!session, + hasRoots: !!session?.roots, + rootsType: typeof session?.roots, + isRootsArray: Array.isArray(session?.roots), + rootsLength: session?.roots?.length, + firstRoot: session?.roots?.[0], + hasRootsRoots: !!session?.roots?.roots, + rootsRootsType: typeof session?.roots?.roots, + isRootsRootsArray: Array.isArray(session?.roots?.roots), + rootsRootsLength: session?.roots?.roots?.length, + firstRootsRoot: session?.roots?.roots?.[0] + })}` + ); - // Get the server's location and try to find project root -- this is a fallback necessary in Cursor IDE - const serverPath = process.argv[1]; // This should be the path to server.js, which is in mcp-server/ - if (serverPath && serverPath.includes('mcp-server')) { - // Find the mcp-server directory first - const mcpServerIndex = serverPath.indexOf('mcp-server'); - if (mcpServerIndex !== -1) { - // Get the path up to mcp-server, which should be the project root - const projectRoot = serverPath.substring(0, mcpServerIndex - 1); // -1 to remove trailing slash - - // Verify this looks like our project root by checking for key files/directories - if (fs.existsSync(path.join(projectRoot, '.cursor')) || - fs.existsSync(path.join(projectRoot, 'mcp-server')) || - fs.existsSync(path.join(projectRoot, 'package.json'))) { - log.info(`Found project root from server path: ${projectRoot}`); - return projectRoot; - } - } - } + // ALWAYS ensure we return a valid path for project root + const cwd = process.cwd(); - // ALWAYS ensure we return a valid path as a last resort - log.info(`Using current working directory as ultimate fallback: ${cwd}`); - return cwd; - } catch (e) { - // If we have a server path, use it as a basis for project root - const serverPath = process.argv[1]; - if (serverPath && serverPath.includes('mcp-server')) { - const mcpServerIndex = serverPath.indexOf('mcp-server'); - return mcpServerIndex !== -1 ? serverPath.substring(0, mcpServerIndex - 1) : process.cwd(); - } - - // Only use cwd if it's not "/" - const cwd = process.cwd(); - return cwd !== '/' ? cwd : '/'; - } + // If we have a session with roots array + if (session?.roots?.[0]?.uri) { + const rootUri = session.roots[0].uri; + log.info(`Found rootUri in session.roots[0].uri: ${rootUri}`); + const rootPath = rootUri.startsWith('file://') + ? decodeURIComponent(rootUri.slice(7)) + : rootUri; + log.info(`Decoded rootPath: ${rootPath}`); + return rootPath; + } + + // If we have a session with roots.roots array (different structure) + if (session?.roots?.roots?.[0]?.uri) { + const rootUri = session.roots.roots[0].uri; + log.info(`Found rootUri in session.roots.roots[0].uri: ${rootUri}`); + const rootPath = rootUri.startsWith('file://') + ? decodeURIComponent(rootUri.slice(7)) + : rootUri; + log.info(`Decoded rootPath: ${rootPath}`); + return rootPath; + } + + // Get the server's location and try to find project root -- this is a fallback necessary in Cursor IDE + const serverPath = process.argv[1]; // This should be the path to server.js, which is in mcp-server/ + if (serverPath && serverPath.includes('mcp-server')) { + // Find the mcp-server directory first + const mcpServerIndex = serverPath.indexOf('mcp-server'); + if (mcpServerIndex !== -1) { + // Get the path up to mcp-server, which should be the project root + const projectRoot = serverPath.substring(0, mcpServerIndex - 1); // -1 to remove trailing slash + + // Verify this looks like our project root by checking for key files/directories + if ( + fs.existsSync(path.join(projectRoot, '.cursor')) || + fs.existsSync(path.join(projectRoot, 'mcp-server')) || + fs.existsSync(path.join(projectRoot, 'package.json')) + ) { + log.info(`Found project root from server path: ${projectRoot}`); + return projectRoot; + } + } + } + + // ALWAYS ensure we return a valid path as a last resort + log.info(`Using current working directory as ultimate fallback: ${cwd}`); + return cwd; + } catch (e) { + // If we have a server path, use it as a basis for project root + const serverPath = process.argv[1]; + if (serverPath && serverPath.includes('mcp-server')) { + const mcpServerIndex = serverPath.indexOf('mcp-server'); + return mcpServerIndex !== -1 + ? serverPath.substring(0, mcpServerIndex - 1) + : process.cwd(); + } + + // Only use cwd if it's not "/" + const cwd = process.cwd(); + return cwd !== '/' ? cwd : '/'; + } } /** @@ -159,28 +180,35 @@ function getProjectRootFromSession(session, log) { * @param {Function} processFunction - Optional function to process successful result data * @returns {Object} - Standardized MCP response object */ -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 +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); } /** @@ -193,75 +221,75 @@ function handleApiResult(result, log, errorPrefix = 'API error', processFunction * @returns {Object} - The result of the command execution */ function executeTaskMasterCommand( - command, - log, - args = [], - projectRootRaw = null, - customEnv = null // Changed from session to customEnv + command, + log, + args = [], + projectRootRaw = null, + customEnv = null // Changed from session to customEnv ) { - 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, - // Merge process.env with customEnv, giving precedence to customEnv - env: { ...process.env, ...(customEnv || {}) } - }; + // Common options for spawn + const spawnOptions = { + encoding: 'utf8', + cwd: cwd, + // Merge process.env with customEnv, giving precedence to customEnv + env: { ...process.env, ...(customEnv || {}) } + }; - // Log the environment being passed (optional, for debugging) - // log.info(`Spawn options env: ${JSON.stringify(spawnOptions.env)}`); + // Log the environment being passed (optional, for debugging) + // log.info(`Spawn options env: ${JSON.stringify(spawnOptions.env)}`); - // 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"); - // Pass the same spawnOptions (including env) to the fallback - 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'); + // Pass the same spawnOptions (including env) to the fallback + 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 + }; + } } /** @@ -277,40 +305,44 @@ function executeTaskMasterCommand( * Format: { success: boolean, data?: any, error?: { code: string, message: string }, fromCache: boolean } */ 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 + }; } /** @@ -320,56 +352,68 @@ async function getCachedOrExecute({ cacheKey, actionFn, log }) { * @param {string[]} fieldsToRemove - An array of field names to remove. * @returns {Object|Array} - The processed data with specified fields removed. */ -function processMCPResponseData(taskOrData, fieldsToRemove = ['details', 'testStrategy']) { - if (!taskOrData) { - return taskOrData; - } +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; } /** @@ -378,19 +422,20 @@ function processMCPResponseData(taskOrData, fieldsToRemove = ['details', 'testSt * @returns {Object} - Content response object in FastMCP format */ 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) + } + ] + }; } /** @@ -399,24 +444,24 @@ 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 + }; } // Ensure all functions are exported export { - getProjectRoot, - getProjectRootFromSession, - handleApiResult, - executeTaskMasterCommand, - getCachedOrExecute, - processMCPResponseData, - createContentResponse, + getProjectRoot, + getProjectRootFromSession, + handleApiResult, + executeTaskMasterCommand, + getCachedOrExecute, + processMCPResponseData, + createContentResponse }; diff --git a/mcp-server/src/tools/validate-dependencies.js b/mcp-server/src/tools/validate-dependencies.js index e24f0feb..4a22fa68 100644 --- a/mcp-server/src/tools/validate-dependencies.js +++ b/mcp-server/src/tools/validate-dependencies.js @@ -3,56 +3,68 @@ * Tool for validating task dependencies */ -import { z } from "zod"; +import { z } from 'zod'; import { - handleApiResult, - createErrorResponse, - getProjectRootFromSession -} from "./utils.js"; -import { validateDependenciesDirect } from "../core/task-master-core.js"; + handleApiResult, + createErrorResponse, + getProjectRootFromSession +} from './utils.js'; +import { validateDependenciesDirect } from '../core/task-master-core.js'; /** * Register the validateDependencies tool with the MCP server * @param {Object} server - FastMCP server instance */ export function registerValidateDependenciesTool(server) { - server.addTool({ - name: "validate_dependencies", - description: "Check tasks for dependency issues (like circular references or links to non-existent tasks) without making changes.", - parameters: z.object({ - 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, session, reportProgress }) => { - try { - log.info(`Validating dependencies with args: ${JSON.stringify(args)}`); - await reportProgress({ progress: 0 }); - - let rootFolder = getProjectRootFromSession(session, log); - - if (!rootFolder && args.projectRoot) { - rootFolder = args.projectRoot; - log.info(`Using project root from args as fallback: ${rootFolder}`); - } - - const result = await validateDependenciesDirect({ - projectRoot: rootFolder, - ...args - }, log, { reportProgress, mcpLog: log, session}); - - await reportProgress({ progress: 100 }); - - if (result.success) { - log.info(`Successfully validated dependencies: ${result.data.message}`); - } else { - log.error(`Failed to validate dependencies: ${result.error.message}`); - } - - return handleApiResult(result, log, 'Error validating dependencies'); - } catch (error) { - log.error(`Error in validateDependencies tool: ${error.message}`); - return createErrorResponse(error.message); - } - }, - }); -} \ No newline at end of file + server.addTool({ + name: 'validate_dependencies', + description: + 'Check tasks for dependency issues (like circular references or links to non-existent tasks) without making changes.', + parameters: z.object({ + 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, session, reportProgress }) => { + try { + log.info(`Validating dependencies with args: ${JSON.stringify(args)}`); + await reportProgress({ progress: 0 }); + + let rootFolder = getProjectRootFromSession(session, log); + + if (!rootFolder && args.projectRoot) { + rootFolder = args.projectRoot; + log.info(`Using project root from args as fallback: ${rootFolder}`); + } + + const result = await validateDependenciesDirect( + { + projectRoot: rootFolder, + ...args + }, + log, + { reportProgress, mcpLog: log, session } + ); + + await reportProgress({ progress: 100 }); + + if (result.success) { + log.info( + `Successfully validated dependencies: ${result.data.message}` + ); + } else { + log.error(`Failed to validate dependencies: ${result.error.message}`); + } + + return handleApiResult(result, log, 'Error validating dependencies'); + } catch (error) { + log.error(`Error in validateDependencies tool: ${error.message}`); + return createErrorResponse(error.message); + } + } + }); +} diff --git a/mcp-test.js b/mcp-test.js index f873c673..e13a72ee 100644 --- a/mcp-test.js +++ b/mcp-test.js @@ -8,64 +8,68 @@ import fs from 'fs'; console.error(`Current working directory: ${process.cwd()}`); try { - console.error('Attempting to load FastMCP Config...'); - - // Check if .cursor/mcp.json exists - const mcpPath = path.join(process.cwd(), '.cursor', 'mcp.json'); - console.error(`Checking if mcp.json exists at: ${mcpPath}`); - - if (fs.existsSync(mcpPath)) { - console.error('mcp.json file found'); - console.error(`File content: ${JSON.stringify(JSON.parse(fs.readFileSync(mcpPath, 'utf8')), null, 2)}`); - } else { - console.error('mcp.json file not found'); - } - - // Try to create Config - const config = new Config(); - console.error('Config created successfully'); - - // Check if env property exists - if (config.env) { - console.error(`Config.env exists with keys: ${Object.keys(config.env).join(', ')}`); - - // Print each env var value (careful with sensitive values) - for (const [key, value] of Object.entries(config.env)) { - if (key.includes('KEY')) { - console.error(`${key}: [value hidden]`); - } else { - console.error(`${key}: ${value}`); - } - } - } else { - console.error('Config.env does not exist'); - } + console.error('Attempting to load FastMCP Config...'); + + // Check if .cursor/mcp.json exists + const mcpPath = path.join(process.cwd(), '.cursor', 'mcp.json'); + console.error(`Checking if mcp.json exists at: ${mcpPath}`); + + if (fs.existsSync(mcpPath)) { + console.error('mcp.json file found'); + console.error( + `File content: ${JSON.stringify(JSON.parse(fs.readFileSync(mcpPath, 'utf8')), null, 2)}` + ); + } else { + console.error('mcp.json file not found'); + } + + // Try to create Config + const config = new Config(); + console.error('Config created successfully'); + + // Check if env property exists + if (config.env) { + console.error( + `Config.env exists with keys: ${Object.keys(config.env).join(', ')}` + ); + + // Print each env var value (careful with sensitive values) + for (const [key, value] of Object.entries(config.env)) { + if (key.includes('KEY')) { + console.error(`${key}: [value hidden]`); + } else { + console.error(`${key}: ${value}`); + } + } + } else { + console.error('Config.env does not exist'); + } } catch (error) { - console.error(`Error loading Config: ${error.message}`); - console.error(`Stack trace: ${error.stack}`); + console.error(`Error loading Config: ${error.message}`); + console.error(`Stack trace: ${error.stack}`); } // Log process.env to see if values from mcp.json were loaded automatically console.error('\nChecking if process.env already has values from mcp.json:'); const envVars = [ - 'ANTHROPIC_API_KEY', - 'PERPLEXITY_API_KEY', - 'MODEL', - 'PERPLEXITY_MODEL', - 'MAX_TOKENS', - 'TEMPERATURE', - 'DEFAULT_SUBTASKS', - 'DEFAULT_PRIORITY' + 'ANTHROPIC_API_KEY', + 'PERPLEXITY_API_KEY', + 'MODEL', + 'PERPLEXITY_MODEL', + 'MAX_TOKENS', + 'TEMPERATURE', + 'DEFAULT_SUBTASKS', + 'DEFAULT_PRIORITY' ]; for (const varName of envVars) { - if (process.env[varName]) { - if (varName.includes('KEY')) { - console.error(`${varName}: [value hidden]`); - } else { - console.error(`${varName}: ${process.env[varName]}`); - } - } else { - console.error(`${varName}: not set`); - } -} \ No newline at end of file + if (process.env[varName]) { + if (varName.includes('KEY')) { + console.error(`${varName}: [value hidden]`); + } else { + console.error(`${varName}: ${process.env[varName]}`); + } + } else { + console.error(`${varName}: not set`); + } +} 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 360598ec..923e7315 100644 --- a/package.json +++ b/package.json @@ -1,98 +1,98 @@ { - "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": "mcp-server/server.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 mcp-server/server.js", - "changeset": "changeset", - "release": "changeset publish", - "inspector": "CLIENT_PORT=8888 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node mcp-server/server.js", - "mcp-server": "node mcp-server/server.js", - "format-check": "prettier --check .", + "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": "mcp-server/server.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 mcp-server/server.js", + "changeset": "changeset", + "release": "changeset publish", + "inspector": "CLIENT_PORT=8888 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node mcp-server/server.js", + "mcp-server": "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", - "inquirer": "^12.5.0", - "jsonwebtoken": "^9.0.2", - "lru-cache": "^10.2.0", - "openai": "^4.89.0", - "ora": "^8.2.0", - "uuid": "^11.1.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" - } + }, + "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", + "inquirer": "^12.5.0", + "jsonwebtoken": "^9.0.2", + "lru-cache": "^10.2.0", + "openai": "^4.89.0", + "ora": "^8.2.0", + "uuid": "^11.1.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 227e1145..dd7cc7a0 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,698 +93,897 @@ 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 'taskmaster.mdc': - sourcePath = path.join(__dirname, '..', '.cursor', 'rules', 'taskmaster.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 'taskmaster.mdc': + sourcePath = path.join( + __dirname, + '..', + '.cursor', + 'rules', + 'taskmaster.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", - "boxen": "^8.0.1", - "chalk": "^4.1.2", - "commander": "^11.1.0", - "cli-table3": "^0.6.5", - "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", - "inquirer": "^12.5.0", - "jsonwebtoken": "^9.0.2", - "lru-cache": "^10.2.0", - "openai": "^4.89.0", - "ora": "^8.2.0", - "task-master-ai": "^0.9.31" - } - }; - - // 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')); +function createProjectStructure( + projectName, + projectDescription, + projectVersion, + authorName, + skipInstall, + addAliases +) { + const targetDir = process.cwd(); + log('info', `Initializing project in ${targetDir}`); - // Copy taskmaster.mdc - copyTemplateFile('taskmaster.mdc', path.join(targetDir, '.cursor', 'rules', 'taskmaster.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' - } - )); + // 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', + boxen: '^8.0.1', + chalk: '^4.1.2', + commander: '^11.1.0', + 'cli-table3': '^0.6.5', + 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', + inquirer: '^12.5.0', + jsonwebtoken: '^9.0.2', + 'lru-cache': '^10.2.0', + openai: '^4.89.0', + ora: '^8.2.0', + 'task-master-ai': '^0.9.31' + } + }; + + // 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 taskmaster.mdc + copyTemplateFile( + 'taskmaster.mdc', + path.join(targetDir, '.cursor', 'rules', 'taskmaster.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": [ - "-y", - "task-master-mcp-server" - ], - "env": { - "ANTHROPIC_API_KEY": "%ANTHROPIC_API_KEY%", - "PERPLEXITY_API_KEY": "%PERPLEXITY_API_KEY%", - "MODEL": "claude-3-7-sonnet-20250219", - "PERPLEXITY_MODEL": "sonar-pro", - "MAX_TOKENS": 64000, - "TEMPERATURE": 0.3, - "DEFAULT_SUBTASKS": 5, - "DEFAULT_PRIORITY": "medium" - } - } - }; - - // 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: ['-y', 'task-master-mcp-server'], + env: { + ANTHROPIC_API_KEY: '%ANTHROPIC_API_KEY%', + PERPLEXITY_API_KEY: '%PERPLEXITY_API_KEY%', + MODEL: 'claude-3-7-sonnet-20250219', + PERPLEXITY_MODEL: 'sonar-pro', + MAX_TOKENS: 64000, + TEMPERATURE: 0.3, + DEFAULT_SUBTASKS: 5, + DEFAULT_PRIORITY: 'medium' + } + } + }; + + // 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 @@ -793,42 +997,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 d2997498..2557f0fd 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}`; } /** @@ -144,12 +151,20 @@ function handleClaudeError(error) { * @param {Object} modelConfig - Model configuration (optional) * @returns {Object} Claude's response */ -async function callClaude(prdContent, prdPath, numTasks, retryCount = 0, { reportProgress, mcpLog, session } = {}, aiClient = null, modelConfig = null) { - 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. +async function callClaude( + prdContent, + prdPath, + numTasks, + retryCount = 0, + { reportProgress, mcpLog, session } = {}, + aiClient = null, + modelConfig = null +) { + 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: @@ -198,41 +213,53 @@ 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, - modelConfig?.maxTokens || CONFIG.maxTokens, - systemPrompt, - { reportProgress, mcpLog, session }, - aiClient || anthropic, - modelConfig - ); - } 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, + modelConfig?.maxTokens || CONFIG.maxTokens, + systemPrompt, + { reportProgress, mcpLog, session }, + aiClient || anthropic, + modelConfig + ); + } 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, { reportProgress, mcpLog, session }, aiClient, modelConfig); - } 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, + { reportProgress, mcpLog, session }, + aiClient, + modelConfig + ); + } else { + console.error(chalk.red(userMessage)); + if (CONFIG.debug) { + log('debug', 'Full error:', error); + } + throw new Error(userMessage); + } + } } /** @@ -250,104 +277,134 @@ Important: Your response must be valid JSON only, with no additional explanation * @param {Object} modelConfig - Model configuration (optional) * @returns {Object} Claude's response */ -async function handleStreamingRequest(prdContent, prdPath, numTasks, maxTokens, systemPrompt, { reportProgress, mcpLog, session } = {}, aiClient = null, modelConfig = null) { - // Determine output format based on mcpLog presence - const outputFormat = mcpLog ? 'json' : 'text'; - - // Create custom reporter that checks for MCP log and silent mode - const report = (message, level = 'info') => { - if (mcpLog) { - mcpLog[level](message); - } else if (!isSilentMode() && outputFormat === 'text') { - // Only log to console if not in silent mode and outputFormat is 'text' - log(level, message); - } - }; - - // Only show loading indicators for text output (CLI) - let loadingIndicator = null; - if (outputFormat === 'text' && !isSilentMode()) { - loadingIndicator = startLoadingIndicator('Generating tasks from PRD...'); - } - - if (reportProgress) { await reportProgress({ progress: 0 }); } - let responseText = ''; - let streamingInterval = null; - - try { - // Use streaming for handling large responses - const stream = await (aiClient || anthropic).messages.create({ - model: modelConfig?.model || session?.env?.ANTHROPIC_MODEL || CONFIG.model, - max_tokens: modelConfig?.maxTokens || session?.env?.MAX_TOKENS || maxTokens, - temperature: modelConfig?.temperature || session?.env?.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 - only for text output - if (outputFormat === 'text' && !isSilentMode()) { - 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 (reportProgress) { - await reportProgress({ progress: (responseText.length / maxTokens) * 100 }); - } - if (mcpLog) { - mcpLog.info(`Progress: ${responseText.length / maxTokens * 100}%`); - } - } - - if (streamingInterval) clearInterval(streamingInterval); - - // Only call stopLoadingIndicator if we started one - if (loadingIndicator && outputFormat === 'text' && !isSilentMode()) { - stopLoadingIndicator(loadingIndicator); - } - - report(`Completed streaming response from ${aiClient ? 'provided' : 'default'} AI client!`, 'info'); - - // Pass options to processClaudeResponse - return processClaudeResponse(responseText, numTasks, 0, prdContent, prdPath, { reportProgress, mcpLog, session }); - } catch (error) { - if (streamingInterval) clearInterval(streamingInterval); - - // Only call stopLoadingIndicator if we started one - if (loadingIndicator && outputFormat === 'text' && !isSilentMode()) { - stopLoadingIndicator(loadingIndicator); - } - - // Get user-friendly error message - const userMessage = handleClaudeError(error); - report(`Error: ${userMessage}`, 'error'); - - // Only show console error for text output (CLI) - if (outputFormat === 'text' && !isSilentMode()) { - console.error(chalk.red(userMessage)); - } - - if (CONFIG.debug && outputFormat === 'text' && !isSilentMode()) { - log('debug', 'Full error:', error); - } - - throw new Error(userMessage); - } +async function handleStreamingRequest( + prdContent, + prdPath, + numTasks, + maxTokens, + systemPrompt, + { reportProgress, mcpLog, session } = {}, + aiClient = null, + modelConfig = null +) { + // Determine output format based on mcpLog presence + const outputFormat = mcpLog ? 'json' : 'text'; + + // Create custom reporter that checks for MCP log and silent mode + const report = (message, level = 'info') => { + if (mcpLog) { + mcpLog[level](message); + } else if (!isSilentMode() && outputFormat === 'text') { + // Only log to console if not in silent mode and outputFormat is 'text' + log(level, message); + } + }; + + // Only show loading indicators for text output (CLI) + let loadingIndicator = null; + if (outputFormat === 'text' && !isSilentMode()) { + loadingIndicator = startLoadingIndicator('Generating tasks from PRD...'); + } + + if (reportProgress) { + await reportProgress({ progress: 0 }); + } + let responseText = ''; + let streamingInterval = null; + + try { + // Use streaming for handling large responses + const stream = await (aiClient || anthropic).messages.create({ + model: + modelConfig?.model || session?.env?.ANTHROPIC_MODEL || CONFIG.model, + max_tokens: + modelConfig?.maxTokens || session?.env?.MAX_TOKENS || maxTokens, + temperature: + modelConfig?.temperature || + session?.env?.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 - only for text output + if (outputFormat === 'text' && !isSilentMode()) { + 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 (reportProgress) { + await reportProgress({ + progress: (responseText.length / maxTokens) * 100 + }); + } + if (mcpLog) { + mcpLog.info(`Progress: ${(responseText.length / maxTokens) * 100}%`); + } + } + + if (streamingInterval) clearInterval(streamingInterval); + + // Only call stopLoadingIndicator if we started one + if (loadingIndicator && outputFormat === 'text' && !isSilentMode()) { + stopLoadingIndicator(loadingIndicator); + } + + report( + `Completed streaming response from ${aiClient ? 'provided' : 'default'} AI client!`, + 'info' + ); + + // Pass options to processClaudeResponse + return processClaudeResponse( + responseText, + numTasks, + 0, + prdContent, + prdPath, + { reportProgress, mcpLog, session } + ); + } catch (error) { + if (streamingInterval) clearInterval(streamingInterval); + + // Only call stopLoadingIndicator if we started one + if (loadingIndicator && outputFormat === 'text' && !isSilentMode()) { + stopLoadingIndicator(loadingIndicator); + } + + // Get user-friendly error message + const userMessage = handleClaudeError(error); + report(`Error: ${userMessage}`, 'error'); + + // Only show console error for text output (CLI) + if (outputFormat === 'text' && !isSilentMode()) { + console.error(chalk.red(userMessage)); + } + + if (CONFIG.debug && outputFormat === 'text' && !isSilentMode()) { + log('debug', 'Full error:', error); + } + + throw new Error(userMessage); + } } /** @@ -360,73 +417,96 @@ async function handleStreamingRequest(prdContent, prdPath, numTasks, maxTokens, * @param {Object} options - Options object containing mcpLog etc. * @returns {Object} Processed response */ -function processClaudeResponse(textContent, numTasks, retryCount, prdContent, prdPath, options = {}) { - const { mcpLog } = options; - - // Determine output format based on mcpLog presence - const outputFormat = mcpLog ? 'json' : 'text'; - - // Create custom reporter that checks for MCP log and silent mode - const report = (message, level = 'info') => { - if (mcpLog) { - mcpLog[level](message); - } else if (!isSilentMode() && outputFormat === 'text') { - // Only log to console if not in silent mode and outputFormat is 'text' - log(level, message); - } - }; - - 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) { - report(`Expected ${numTasks} tasks, but received ${parsedData.tasks.length}`, 'warn'); - } - - // 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) { - report(`Error processing Claude's response: ${error.message}`, 'error'); - - // Retry logic - if (retryCount < 2) { - report(`Retrying to parse response (${retryCount + 1}/2)...`, 'info'); - - // Try again with Claude for a cleaner response - if (retryCount === 1) { - report("Calling Claude again for a cleaner response...", 'info'); - return callClaude(prdContent, prdPath, numTasks, retryCount + 1, options); - } - - return processClaudeResponse(textContent, numTasks, retryCount + 1, prdContent, prdPath, options); - } else { - throw error; - } - } +function processClaudeResponse( + textContent, + numTasks, + retryCount, + prdContent, + prdPath, + options = {} +) { + const { mcpLog } = options; + + // Determine output format based on mcpLog presence + const outputFormat = mcpLog ? 'json' : 'text'; + + // Create custom reporter that checks for MCP log and silent mode + const report = (message, level = 'info') => { + if (mcpLog) { + mcpLog[level](message); + } else if (!isSilentMode() && outputFormat === 'text') { + // Only log to console if not in silent mode and outputFormat is 'text' + log(level, message); + } + }; + + 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) { + report( + `Expected ${numTasks} tasks, but received ${parsedData.tasks.length}`, + 'warn' + ); + } + + // 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) { + report(`Error processing Claude's response: ${error.message}`, 'error'); + + // Retry logic + if (retryCount < 2) { + report(`Retrying to parse response (${retryCount + 1}/2)...`, 'info'); + + // Try again with Claude for a cleaner response + if (retryCount === 1) { + report('Calling Claude again for a cleaner response...', 'info'); + return callClaude( + prdContent, + prdPath, + numTasks, + retryCount + 1, + options + ); + } + + return processClaudeResponse( + textContent, + numTasks, + retryCount + 1, + prdContent, + prdPath, + options + ); + } else { + throw error; + } + } } /** @@ -441,15 +521,26 @@ function processClaudeResponse(textContent, numTasks, retryCount, prdContent, pr * - session: Session object from MCP server (optional) * @returns {Array} Generated subtasks */ -async function generateSubtasks(task, numSubtasks, nextSubtaskId, additionalContext = '', { reportProgress, mcpLog, session } = {}) { - 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 = '', + { reportProgress, mcpLog, session } = {} +) { + 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: @@ -468,10 +559,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} @@ -493,61 +585,72 @@ 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); + 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); - // TODO: MOVE THIS TO THE STREAM REQUEST FUNCTION (DRY) - - // Use streaming API call - const stream = await anthropic.messages.create({ - model: session?.env?.ANTHROPIC_MODEL || CONFIG.model, - max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, - temperature: session?.env?.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 (reportProgress) { - await reportProgress({ progress: (responseText.length / CONFIG.maxTokens) * 100 }); - } - if (mcpLog) { - mcpLog.info(`Progress: ${responseText.length / CONFIG.maxTokens * 100}%`); - } - } - - 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; - } + // TODO: MOVE THIS TO THE STREAM REQUEST FUNCTION (DRY) + + // Use streaming API call + const stream = await anthropic.messages.create({ + model: session?.env?.ANTHROPIC_MODEL || CONFIG.model, + max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, + temperature: session?.env?.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 (reportProgress) { + await reportProgress({ + progress: (responseText.length / CONFIG.maxTokens) * 100 + }); + } + if (mcpLog) { + mcpLog.info( + `Progress: ${(responseText.length / CONFIG.maxTokens) * 100}%` + ); + } + } + + 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; + } } /** @@ -563,71 +666,90 @@ Note on dependencies: Subtasks can depend on other subtasks with lower IDs. Use * - session: Session object from MCP server (optional) * @returns {Array} Generated subtasks */ -async function generateSubtasksWithPerplexity(task, numSubtasks = 3, nextSubtaskId = 1, additionalContext = '', { reportProgress, mcpLog, silentMode, session } = {}) { - // Check both global silentMode and the passed parameter - const isSilent = silentMode || (typeof silentMode === 'undefined' && isSilentMode()); - - // Use mcpLog if provided, otherwise use regular log if not silent - const logFn = mcpLog ? - (level, ...args) => mcpLog[level](...args) : - (level, ...args) => !isSilent && log(level, ...args); - - try { - // First, perform research to get context - logFn('info', `Researching context for task ${task.id}: ${task.title}`); - const perplexityClient = getPerplexityClient(); - - const PERPLEXITY_MODEL = process.env.PERPLEXITY_MODEL || session?.env?.PERPLEXITY_MODEL || 'sonar-pro'; - - // Only create loading indicators if not in silent mode - let researchLoadingIndicator = null; - if (!isSilent) { - 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 = '', + { reportProgress, mcpLog, silentMode, session } = {} +) { + // Check both global silentMode and the passed parameter + const isSilent = + silentMode || (typeof silentMode === 'undefined' && isSilentMode()); + + // Use mcpLog if provided, otherwise use regular log if not silent + const logFn = mcpLog + ? (level, ...args) => mcpLog[level](...args) + : (level, ...args) => !isSilent && log(level, ...args); + + try { + // First, perform research to get context + logFn('info', `Researching context for task ${task.id}: ${task.title}`); + const perplexityClient = getPerplexityClient(); + + const PERPLEXITY_MODEL = + process.env.PERPLEXITY_MODEL || + session?.env?.PERPLEXITY_MODEL || + 'sonar-pro'; + + // Only create loading indicators if not in silent mode + let researchLoadingIndicator = null; + if (!isSilent) { + 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; - - // Only stop loading indicator if it was created - if (researchLoadingIndicator) { - stopLoadingIndicator(researchLoadingIndicator); - } - - logFn('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; + + // Only stop loading indicator if it was created + if (researchLoadingIndicator) { + stopLoadingIndicator(researchLoadingIndicator); + } + + logFn( + '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 - let loadingIndicator = null; - if (!isSilent) { - 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 + let loadingIndicator = null; + if (!isSilent) { + 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. @@ -649,7 +771,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} @@ -672,63 +794,76 @@ 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 - // Only create if not in silent mode - if (!isSilent) { - 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 via our helper function - responseText = await _handleAnthropicStream( - anthropic, - { - model: session?.env?.ANTHROPIC_MODEL || CONFIG.model, - max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, - temperature: session?.env?.TEMPERATURE || CONFIG.temperature, - system: systemPrompt, - messages: [{ role: 'user', content: userPrompt }] - }, - { reportProgress, mcpLog, silentMode }, - !isSilent // Only use CLI mode if not in silent mode - ); - - // Clean up - if (streamingInterval) { - clearInterval(streamingInterval); - streamingInterval = null; - } - - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - loadingIndicator = null; - } - - logFn('info', `Completed generating research-backed subtasks for task ${task.id}`); - - return parseSubtasksFromText(responseText, nextSubtaskId, numSubtasks, task.id); - } catch (error) { - // Clean up on error - if (streamingInterval) { - clearInterval(streamingInterval); - } - - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - } - - throw error; - } - } catch (error) { - logFn('error', `Error generating research-backed subtasks: ${error.message}`); - throw error; - } + try { + // Update loading indicator to show streaming progress + // Only create if not in silent mode + if (!isSilent) { + 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 via our helper function + responseText = await _handleAnthropicStream( + anthropic, + { + model: session?.env?.ANTHROPIC_MODEL || CONFIG.model, + max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, + temperature: session?.env?.TEMPERATURE || CONFIG.temperature, + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }] + }, + { reportProgress, mcpLog, silentMode }, + !isSilent // Only use CLI mode if not in silent mode + ); + + // Clean up + if (streamingInterval) { + clearInterval(streamingInterval); + streamingInterval = null; + } + + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + loadingIndicator = null; + } + + logFn( + 'info', + `Completed generating research-backed subtasks for task ${task.id}` + ); + + return parseSubtasksFromText( + responseText, + nextSubtaskId, + numSubtasks, + task.id + ); + } catch (error) { + // Clean up on error + if (streamingInterval) { + clearInterval(streamingInterval); + } + + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + } + + throw error; + } + } catch (error) { + logFn( + 'error', + `Error generating research-backed subtasks: ${error.message}` + ); + throw error; + } } /** @@ -740,78 +875,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; + } } /** @@ -820,16 +966,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: [ @@ -851,7 +1001,7 @@ IMPORTANT: Make sure to include an analysis for EVERY task listed above, with th /** * Handles streaming API calls to Anthropic (Claude) * This is a common helper function to standardize interaction with Anthropic's streaming API. - * + * * @param {Anthropic} client - Initialized Anthropic client * @param {Object} params - Parameters for the API call * @param {string} params.model - Claude model to use (e.g., 'claude-3-opus-20240229') @@ -866,146 +1016,168 @@ IMPORTANT: Make sure to include an analysis for EVERY task listed above, with th * @param {boolean} [cliMode=false] - Whether to show CLI-specific output like spinners * @returns {Promise} The accumulated response text */ -async function _handleAnthropicStream(client, params, { reportProgress, mcpLog, silentMode } = {}, cliMode = false) { - // Only set up loading indicator in CLI mode and not in silent mode - let loadingIndicator = null; - let streamingInterval = null; - let responseText = ''; - - // Check both the passed parameter and global silent mode using isSilentMode() - const isSilent = silentMode || (typeof silentMode === 'undefined' && isSilentMode()); - - // Only show CLI indicators if in cliMode AND not in silent mode - const showCLIOutput = cliMode && !isSilent; - - if (showCLIOutput) { - loadingIndicator = startLoadingIndicator('Processing request with Claude AI...'); - } - - try { - // Validate required parameters - if (!client) { - throw new Error('Anthropic client is required'); - } - - if (!params.messages || !Array.isArray(params.messages) || params.messages.length === 0) { - throw new Error('At least one message is required'); - } - - // Ensure the stream parameter is set - const streamParams = { - ...params, - stream: true - }; - - // Call Anthropic with streaming enabled - const stream = await client.messages.create(streamParams); - - // Set up streaming progress indicator for CLI (only if not in silent mode) - let dotCount = 0; - if (showCLIOutput) { - 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 - let streamIterator = stream[Symbol.asyncIterator](); - let streamDone = false; - - while (!streamDone) { - try { - const { done, value: chunk } = await streamIterator.next(); - - // Check if we've reached the end of the stream - if (done) { - streamDone = true; - continue; - } - - // Process the chunk - if (chunk && chunk.type === 'content_block_delta' && chunk.delta.text) { - responseText += chunk.delta.text; - } - - // Report progress - use only mcpLog in MCP context and avoid direct reportProgress calls - const maxTokens = params.max_tokens || CONFIG.maxTokens; - const progressPercent = Math.min(100, (responseText.length / maxTokens) * 100); - - // Only use reportProgress in CLI mode, not from MCP context, and not in silent mode - if (reportProgress && !mcpLog && !isSilent) { - await reportProgress({ - progress: progressPercent, - total: maxTokens - }); - } - - // Log progress if logger is provided (MCP mode) - if (mcpLog) { - mcpLog.info(`Progress: ${progressPercent}% (${responseText.length} chars generated)`); - } - } catch (iterError) { - // Handle iteration errors - if (mcpLog) { - mcpLog.error(`Stream iteration error: ${iterError.message}`); - } else if (!isSilent) { - log('error', `Stream iteration error: ${iterError.message}`); - } - - // If it's a "stream finished" error, just break the loop - if (iterError.message?.includes('finished') || iterError.message?.includes('closed')) { - streamDone = true; - } else { - // For other errors, rethrow - throw iterError; - } - } - } - - // Cleanup - ensure intervals are cleared - if (streamingInterval) { - clearInterval(streamingInterval); - streamingInterval = null; - } - - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - loadingIndicator = null; - } - - // Log completion - if (mcpLog) { - mcpLog.info("Completed streaming response from Claude API!"); - } else if (!isSilent) { - log('info', "Completed streaming response from Claude API!"); - } - - return responseText; - } catch (error) { - // Cleanup on error - if (streamingInterval) { - clearInterval(streamingInterval); - streamingInterval = null; - } - - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - loadingIndicator = null; - } - - // Log the error - if (mcpLog) { - mcpLog.error(`Error in Anthropic streaming: ${error.message}`); - } else if (!isSilent) { - log('error', `Error in Anthropic streaming: ${error.message}`); - } - - // Re-throw with context - throw new Error(`Anthropic streaming error: ${error.message}`); - } +async function _handleAnthropicStream( + client, + params, + { reportProgress, mcpLog, silentMode } = {}, + cliMode = false +) { + // Only set up loading indicator in CLI mode and not in silent mode + let loadingIndicator = null; + let streamingInterval = null; + let responseText = ''; + + // Check both the passed parameter and global silent mode using isSilentMode() + const isSilent = + silentMode || (typeof silentMode === 'undefined' && isSilentMode()); + + // Only show CLI indicators if in cliMode AND not in silent mode + const showCLIOutput = cliMode && !isSilent; + + if (showCLIOutput) { + loadingIndicator = startLoadingIndicator( + 'Processing request with Claude AI...' + ); + } + + try { + // Validate required parameters + if (!client) { + throw new Error('Anthropic client is required'); + } + + if ( + !params.messages || + !Array.isArray(params.messages) || + params.messages.length === 0 + ) { + throw new Error('At least one message is required'); + } + + // Ensure the stream parameter is set + const streamParams = { + ...params, + stream: true + }; + + // Call Anthropic with streaming enabled + const stream = await client.messages.create(streamParams); + + // Set up streaming progress indicator for CLI (only if not in silent mode) + let dotCount = 0; + if (showCLIOutput) { + 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 + let streamIterator = stream[Symbol.asyncIterator](); + let streamDone = false; + + while (!streamDone) { + try { + const { done, value: chunk } = await streamIterator.next(); + + // Check if we've reached the end of the stream + if (done) { + streamDone = true; + continue; + } + + // Process the chunk + if (chunk && chunk.type === 'content_block_delta' && chunk.delta.text) { + responseText += chunk.delta.text; + } + + // Report progress - use only mcpLog in MCP context and avoid direct reportProgress calls + const maxTokens = params.max_tokens || CONFIG.maxTokens; + const progressPercent = Math.min( + 100, + (responseText.length / maxTokens) * 100 + ); + + // Only use reportProgress in CLI mode, not from MCP context, and not in silent mode + if (reportProgress && !mcpLog && !isSilent) { + await reportProgress({ + progress: progressPercent, + total: maxTokens + }); + } + + // Log progress if logger is provided (MCP mode) + if (mcpLog) { + mcpLog.info( + `Progress: ${progressPercent}% (${responseText.length} chars generated)` + ); + } + } catch (iterError) { + // Handle iteration errors + if (mcpLog) { + mcpLog.error(`Stream iteration error: ${iterError.message}`); + } else if (!isSilent) { + log('error', `Stream iteration error: ${iterError.message}`); + } + + // If it's a "stream finished" error, just break the loop + if ( + iterError.message?.includes('finished') || + iterError.message?.includes('closed') + ) { + streamDone = true; + } else { + // For other errors, rethrow + throw iterError; + } + } + } + + // Cleanup - ensure intervals are cleared + if (streamingInterval) { + clearInterval(streamingInterval); + streamingInterval = null; + } + + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + loadingIndicator = null; + } + + // Log completion + if (mcpLog) { + mcpLog.info('Completed streaming response from Claude API!'); + } else if (!isSilent) { + log('info', 'Completed streaming response from Claude API!'); + } + + return responseText; + } catch (error) { + // Cleanup on error + if (streamingInterval) { + clearInterval(streamingInterval); + streamingInterval = null; + } + + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + loadingIndicator = null; + } + + // Log the error + if (mcpLog) { + mcpLog.error(`Error in Anthropic streaming: ${error.message}`); + } else if (!isSilent) { + log('error', `Error in Anthropic streaming: ${error.message}`); + } + + // Re-throw with context + throw new Error(`Anthropic streaming error: ${error.message}`); + } } /** @@ -1015,35 +1187,43 @@ async function _handleAnthropicStream(client, params, { reportProgress, mcpLog, * @throws {Error} If parsing fails or required fields are missing */ function parseTaskJsonResponse(responseText) { - try { - // Check if the response is wrapped in a code block - const jsonMatch = responseText.match(/```(?:json)?([^`]+)```/); - const jsonContent = jsonMatch ? jsonMatch[1].trim() : responseText; - - // Find the JSON object bounds - const jsonStartIndex = jsonContent.indexOf('{'); - const jsonEndIndex = jsonContent.lastIndexOf('}'); - - if (jsonStartIndex === -1 || jsonEndIndex === -1 || jsonEndIndex < jsonStartIndex) { - throw new Error("Could not locate valid JSON object in the response"); - } - - // Extract and parse the JSON - const jsonText = jsonContent.substring(jsonStartIndex, jsonEndIndex + 1); - const taskData = JSON.parse(jsonText); - - // Validate required fields - if (!taskData.title || !taskData.description) { - throw new Error("Missing required fields in the generated task (title or description)"); - } - - return taskData; - } catch (error) { - if (error.name === 'SyntaxError') { - throw new Error(`Failed to parse JSON: ${error.message} (Response content may be malformed)`); - } - throw error; - } + try { + // Check if the response is wrapped in a code block + const jsonMatch = responseText.match(/```(?:json)?([^`]+)```/); + const jsonContent = jsonMatch ? jsonMatch[1].trim() : responseText; + + // Find the JSON object bounds + const jsonStartIndex = jsonContent.indexOf('{'); + const jsonEndIndex = jsonContent.lastIndexOf('}'); + + if ( + jsonStartIndex === -1 || + jsonEndIndex === -1 || + jsonEndIndex < jsonStartIndex + ) { + throw new Error('Could not locate valid JSON object in the response'); + } + + // Extract and parse the JSON + const jsonText = jsonContent.substring(jsonStartIndex, jsonEndIndex + 1); + const taskData = JSON.parse(jsonText); + + // Validate required fields + if (!taskData.title || !taskData.description) { + throw new Error( + 'Missing required fields in the generated task (title or description)' + ); + } + + return taskData; + } catch (error) { + if (error.name === 'SyntaxError') { + throw new Error( + `Failed to parse JSON: ${error.message} (Response content may be malformed)` + ); + } + throw error; + } } /** @@ -1055,19 +1235,20 @@ function parseTaskJsonResponse(responseText) { * @returns {Object} Object containing systemPrompt and userPrompt */ function _buildAddTaskPrompt(prompt, contextTasks, { newTaskId } = {}) { - // 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."; - - const taskStructure = ` + // 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."; + + 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 taskIdInfo = newTaskId ? `(Task #${newTaskId})` : ''; - const userPrompt = `Create a comprehensive new task ${taskIdInfo} for a software development project based on this description: "${prompt}" + + const taskIdInfo = newTaskId ? `(Task #${newTaskId})` : ''; + const userPrompt = `Create a comprehensive new task ${taskIdInfo} for a software development project based on this description: "${prompt}" ${contextTasks} @@ -1078,8 +1259,8 @@ function _buildAddTaskPrompt(prompt, contextTasks, { newTaskId } = {}) { Make sure the details and test strategy are thorough and specific. IMPORTANT: Return ONLY the JSON object, nothing else.`; - - return { systemPrompt, userPrompt }; + + return { systemPrompt, userPrompt }; } /** @@ -1088,25 +1269,28 @@ function _buildAddTaskPrompt(prompt, contextTasks, { newTaskId } = {}) { * @returns {Anthropic} Anthropic client instance */ function getAnthropicClient(session) { - // If we already have a global client and no session, use the global - if (!session && anthropic) { - return anthropic; - } - - // Initialize a new client with API key from session or environment - const apiKey = session?.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY; - - if (!apiKey) { - throw new Error("ANTHROPIC_API_KEY environment variable is missing. Set it to use AI features."); - } - - return new Anthropic({ - apiKey: apiKey, - // Add beta header for 128k token output - defaultHeaders: { - 'anthropic-beta': 'output-128k-2025-02-19' - } - }); + // If we already have a global client and no session, use the global + if (!session && anthropic) { + return anthropic; + } + + // Initialize a new client with API key from session or environment + const apiKey = + session?.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY; + + if (!apiKey) { + throw new Error( + 'ANTHROPIC_API_KEY environment variable is missing. Set it to use AI features.' + ); + } + + return new Anthropic({ + apiKey: apiKey, + // Add beta header for 128k token output + defaultHeaders: { + 'anthropic-beta': 'output-128k-2025-02-19' + } + }); } /** @@ -1118,41 +1302,53 @@ function getAnthropicClient(session) { * @param {Object} options.session - Session object from MCP server * @returns {Object} - The generated task description */ -async function generateTaskDescriptionWithPerplexity(prompt, { reportProgress, mcpLog, session } = {}) { - try { - // First, perform research to get context - log('info', `Researching context for task prompt: "${prompt}"`); - const perplexityClient = getPerplexityClient(); - - const PERPLEXITY_MODEL = process.env.PERPLEXITY_MODEL || session?.env?.PERPLEXITY_MODEL || 'sonar-pro'; - const researchLoadingIndicator = startLoadingIndicator('Researching best practices with Perplexity AI...'); - - // Formulate research query based on task prompt - const researchQuery = `I need to implement: "${prompt}". +async function generateTaskDescriptionWithPerplexity( + prompt, + { reportProgress, mcpLog, session } = {} +) { + try { + // First, perform research to get context + log('info', `Researching context for task prompt: "${prompt}"`); + const perplexityClient = getPerplexityClient(); + + const PERPLEXITY_MODEL = + process.env.PERPLEXITY_MODEL || + session?.env?.PERPLEXITY_MODEL || + 'sonar-pro'; + const researchLoadingIndicator = startLoadingIndicator( + 'Researching best practices with Perplexity AI...' + ); + + // Formulate research query based on task prompt + const researchQuery = `I need to implement: "${prompt}". 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 detailed task description'); - - // Now generate task description with Claude - const loadingIndicator = startLoadingIndicator(`Generating research-backed task description...`); - let streamingInterval = null; - let responseText = ''; - - const systemPrompt = `You are an AI assistant helping with task definition for software development. + + // 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 detailed task description'); + + // Now generate task description with Claude + const loadingIndicator = startLoadingIndicator( + `Generating research-backed task description...` + ); + let streamingInterval = null; + let responseText = ''; + + const systemPrompt = `You are an AI assistant helping with task definition for software development. You need to create a detailed task definition based on a brief prompt. You have been provided with research on current best practices and implementation approaches. @@ -1164,7 +1360,7 @@ Your task description should include: 3. Detailed implementation guidelines incorporating best practices from the research 4. A testing strategy for verifying correct implementation`; - const userPrompt = `Please create a detailed task description based on this prompt: + const userPrompt = `Please create a detailed task description based on this prompt: "${prompt}" @@ -1179,148 +1375,171 @@ Return a JSON object with the following structure: "testStrategy": "A detailed approach for verifying the task has been correctly implemented" }`; - 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 task description${'.'.repeat(dotCount)}`); - dotCount = (dotCount + 1) % 4; - }, 500); - - // Use streaming API call - const stream = await anthropic.messages.create({ - model: session?.env?.ANTHROPIC_MODEL || CONFIG.model, - max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, - temperature: session?.env?.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 (reportProgress) { - await reportProgress({ progress: (responseText.length / CONFIG.maxTokens) * 100 }); - } - if (mcpLog) { - mcpLog.info(`Progress: ${responseText.length / CONFIG.maxTokens * 100}%`); - } - } - - if (streamingInterval) clearInterval(streamingInterval); - stopLoadingIndicator(loadingIndicator); - - log('info', `Completed generating research-backed task description`); - - return parseTaskJsonResponse(responseText); - } catch (error) { - if (streamingInterval) clearInterval(streamingInterval); - stopLoadingIndicator(loadingIndicator); - throw error; - } - } catch (error) { - log('error', `Error generating research-backed task description: ${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 task description${'.'.repeat(dotCount)}` + ); + dotCount = (dotCount + 1) % 4; + }, 500); + + // Use streaming API call + const stream = await anthropic.messages.create({ + model: session?.env?.ANTHROPIC_MODEL || CONFIG.model, + max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, + temperature: session?.env?.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 (reportProgress) { + await reportProgress({ + progress: (responseText.length / CONFIG.maxTokens) * 100 + }); + } + if (mcpLog) { + mcpLog.info( + `Progress: ${(responseText.length / CONFIG.maxTokens) * 100}%` + ); + } + } + + if (streamingInterval) clearInterval(streamingInterval); + stopLoadingIndicator(loadingIndicator); + + log('info', `Completed generating research-backed task description`); + + return parseTaskJsonResponse(responseText); + } catch (error) { + if (streamingInterval) clearInterval(streamingInterval); + stopLoadingIndicator(loadingIndicator); + throw error; + } + } catch (error) { + log( + 'error', + `Error generating research-backed task description: ${error.message}` + ); + throw error; + } } /** * Get a configured Anthropic client for MCP * @param {Object} session - Session object from MCP - * @param {Object} log - Logger object + * @param {Object} log - Logger object * @returns {Anthropic} - Configured Anthropic client */ function getConfiguredAnthropicClient(session = null, customEnv = null) { - // If we have a session with ANTHROPIC_API_KEY in env, use that - const apiKey = session?.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || customEnv?.ANTHROPIC_API_KEY; - - if (!apiKey) { - throw new Error("ANTHROPIC_API_KEY environment variable is missing. Set it to use AI features."); - } - - return new Anthropic({ - apiKey: apiKey, - // Add beta header for 128k token output - defaultHeaders: { - 'anthropic-beta': 'output-128k-2025-02-19' - } - }); + // If we have a session with ANTHROPIC_API_KEY in env, use that + const apiKey = + session?.env?.ANTHROPIC_API_KEY || + process.env.ANTHROPIC_API_KEY || + customEnv?.ANTHROPIC_API_KEY; + + if (!apiKey) { + throw new Error( + 'ANTHROPIC_API_KEY environment variable is missing. Set it to use AI features.' + ); + } + + return new Anthropic({ + apiKey: apiKey, + // Add beta header for 128k token output + defaultHeaders: { + 'anthropic-beta': 'output-128k-2025-02-19' + } + }); } /** - * Send a chat request to Claude with context management - * @param {Object} client - Anthropic client + * Send a chat request to Claude with context management + * @param {Object} client - Anthropic client * @param {Object} params - Chat parameters * @param {Object} options - Options containing reportProgress, mcpLog, silentMode, and session * @returns {string} - Response text */ -async function sendChatWithContext(client, params, { reportProgress, mcpLog, silentMode, session } = {}) { - // Use the streaming helper to get the response - return await _handleAnthropicStream(client, params, { reportProgress, mcpLog, silentMode }, false); +async function sendChatWithContext( + client, + params, + { reportProgress, mcpLog, silentMode, session } = {} +) { + // Use the streaming helper to get the response + return await _handleAnthropicStream( + client, + params, + { reportProgress, mcpLog, silentMode }, + false + ); } /** * Parse tasks data from Claude's completion - * @param {string} completionText - Text from Claude completion + * @param {string} completionText - Text from Claude completion * @returns {Array} - Array of parsed tasks */ function parseTasksFromCompletion(completionText) { - try { - // Find JSON in the response - const jsonMatch = completionText.match(/```(?:json)?([^`]+)```/); - let jsonContent = jsonMatch ? jsonMatch[1].trim() : completionText; - - // Find opening/closing brackets if not in code block - if (!jsonMatch) { - const startIdx = jsonContent.indexOf('['); - const endIdx = jsonContent.lastIndexOf(']'); - if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { - jsonContent = jsonContent.substring(startIdx, endIdx + 1); - } - } - - // Parse the JSON - const tasks = JSON.parse(jsonContent); - - // Validate it's an array - if (!Array.isArray(tasks)) { - throw new Error('Parsed content is not a valid task array'); - } - - return tasks; - } catch (error) { - throw new Error(`Failed to parse tasks from completion: ${error.message}`); - } + try { + // Find JSON in the response + const jsonMatch = completionText.match(/```(?:json)?([^`]+)```/); + let jsonContent = jsonMatch ? jsonMatch[1].trim() : completionText; + + // Find opening/closing brackets if not in code block + if (!jsonMatch) { + const startIdx = jsonContent.indexOf('['); + const endIdx = jsonContent.lastIndexOf(']'); + if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { + jsonContent = jsonContent.substring(startIdx, endIdx + 1); + } + } + + // Parse the JSON + const tasks = JSON.parse(jsonContent); + + // Validate it's an array + if (!Array.isArray(tasks)) { + throw new Error('Parsed content is not a valid task array'); + } + + return tasks; + } catch (error) { + throw new Error(`Failed to parse tasks from completion: ${error.message}`); + } } // Export AI service functions export { - getAnthropicClient, - getPerplexityClient, - callClaude, - handleStreamingRequest, - processClaudeResponse, - generateSubtasks, - generateSubtasksWithPerplexity, - generateTaskDescriptionWithPerplexity, - parseSubtasksFromText, - generateComplexityAnalysisPrompt, - handleClaudeError, - getAvailableAIModel, - parseTaskJsonResponse, - _buildAddTaskPrompt, - _handleAnthropicStream, - getConfiguredAnthropicClient, - sendChatWithContext, - parseTasksFromCompletion -}; \ No newline at end of file + getAnthropicClient, + getPerplexityClient, + callClaude, + handleStreamingRequest, + processClaudeResponse, + generateSubtasks, + generateSubtasksWithPerplexity, + generateTaskDescriptionWithPerplexity, + parseSubtasksFromText, + generateComplexityAnalysisPrompt, + handleClaudeError, + getAvailableAIModel, + parseTaskJsonResponse, + _buildAddTaskPrompt, + _handleAnthropicStream, + getConfiguredAnthropicClient, + sendChatWithContext, + parseTasksFromCompletion +}; diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index 7600e3a5..3e94d783 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -13,42 +13,42 @@ import inquirer from 'inquirer'; import { CONFIG, log, readJSON } from './utils.js'; import { - parsePRD, - updateTasks, - generateTaskFiles, - setTaskStatus, - listTasks, - expandTask, - expandAllTasks, - clearSubtasks, - addTask, - addSubtask, - removeSubtask, - analyzeTaskComplexity, - updateTaskById, - updateSubtaskById, - removeTask, - findTaskById, - taskExists + parsePRD, + updateTasks, + generateTaskFiles, + setTaskStatus, + listTasks, + expandTask, + expandAllTasks, + clearSubtasks, + addTask, + addSubtask, + removeSubtask, + analyzeTaskComplexity, + updateTaskById, + updateSubtaskById, + removeTask, + findTaskById, + taskExists } from './task-manager.js'; import { - addDependency, - removeDependency, - validateDependenciesCommand, - fixDependenciesCommand + addDependency, + removeDependency, + validateDependenciesCommand, + fixDependenciesCommand } from './dependency-manager.js'; import { - displayBanner, - displayHelp, - displayNextTask, - displayTaskById, - displayComplexityReport, - getStatusWithColor, - confirmTaskOverwrite, - startLoadingIndicator, - stopLoadingIndicator + displayBanner, + displayHelp, + displayNextTask, + displayTaskById, + displayComplexityReport, + getStatusWithColor, + confirmTaskOverwrite, + startLoadingIndicator, + stopLoadingIndicator } from './ui.js'; /** @@ -56,942 +56,1430 @@ 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 multiple tasks with ID >= "from" based on new information or implementation changes') - .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') - .option('--from ', 'Task ID to start updating from (tasks with ID >= this value will be updated)', '1') - .option('-p, --prompt ', 'Prompt explaining the changes or new context (required)') - .option('-r, --research', 'Use Perplexity AI for research-backed task updates') - .action(async (options) => { - const tasksPath = options.file; - const fromId = parseInt(options.from, 10); - const prompt = options.prompt; - const useResearch = options.research || false; - - // Check if there's an 'id' option which is a common mistake (instead of 'from') - if (process.argv.includes('--id') || process.argv.some(arg => arg.startsWith('--id='))) { - console.error(chalk.red('Error: The update command uses --from=, not --id=')); - console.log(chalk.yellow('\nTo update multiple tasks:')); - console.log(` task-master update --from=${fromId} --prompt="Your prompt here"`); - console.log(chalk.yellow('\nTo update a single specific task, use the update-task command instead:')); - console.log(` task-master update-task --id= --prompt="Your prompt here"`); - process.exit(1); - } - - if (!prompt) { - console.error(chalk.red('Error: --prompt parameter is required. Please provide information about the changes.')); - process.exit(1); - } - - console.log(chalk.blue(`Updating tasks from ID >= ${fromId} with prompt: "${prompt}"`)); - console.log(chalk.blue(`Tasks file: ${tasksPath}`)); - - if (useResearch) { - console.log(chalk.blue('Using Perplexity AI for research-backed task updates')); - } - - await updateTasks(tasksPath, fromId, prompt, useResearch); - }); + // Default help + programInstance.on('--help', function () { + displayHelp(); + }); - // update-task command - programInstance - .command('update-task') - .description('Update a single specific task by ID with new information (use --id parameter)') - .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') - .option('-i, --id ', 'Task ID to update (required)') - .option('-p, --prompt ', 'Prompt explaining the changes or new context (required)') - .option('-r, --research', 'Use Perplexity AI for research-backed task updates') - .action(async (options) => { - try { - const tasksPath = options.file; - - // Validate required parameters - if (!options.id) { - console.error(chalk.red('Error: --id parameter is required')); - console.log(chalk.yellow('Usage example: task-master update-task --id=23 --prompt="Update with new information"')); - process.exit(1); - } - - // Parse the task ID and validate it's a number - const taskId = parseInt(options.id, 10); - if (isNaN(taskId) || taskId <= 0) { - console.error(chalk.red(`Error: Invalid task ID: ${options.id}. Task ID must be a positive integer.`)); - console.log(chalk.yellow('Usage example: task-master update-task --id=23 --prompt="Update with new information"')); - process.exit(1); - } - - if (!options.prompt) { - console.error(chalk.red('Error: --prompt parameter is required. Please provide information about the changes.')); - console.log(chalk.yellow('Usage example: task-master update-task --id=23 --prompt="Update with new information"')); - process.exit(1); - } - - const prompt = options.prompt; - const useResearch = options.research || false; - - // Validate tasks file exists - if (!fs.existsSync(tasksPath)) { - console.error(chalk.red(`Error: Tasks file not found at path: ${tasksPath}`)); - if (tasksPath === 'tasks/tasks.json') { - console.log(chalk.yellow('Hint: Run task-master init or task-master parse-prd to create tasks.json first')); - } else { - console.log(chalk.yellow(`Hint: Check if the file path is correct: ${tasksPath}`)); - } - process.exit(1); - } - - console.log(chalk.blue(`Updating task ${taskId} with prompt: "${prompt}"`)); - console.log(chalk.blue(`Tasks file: ${tasksPath}`)); - - if (useResearch) { - // Verify Perplexity API key exists if using research - if (!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 idArg = options.id; - const numSubtasks = options.num || CONFIG.defaultSubtasks; - const useResearch = options.research || false; - const additionalContext = options.prompt || ''; - const forceFlag = options.force || false; - const tasksPath = options.file || 'tasks/tasks.json'; - - if (options.all) { - 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(tasksPath, 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(tasksPath, 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 multiple tasks with ID >= "from" based on new information or implementation changes' + ) + .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') + .option( + '--from ', + 'Task ID to start updating from (tasks with ID >= this value will be updated)', + '1' + ) + .option( + '-p, --prompt ', + 'Prompt explaining the changes or new context (required)' + ) + .option( + '-r, --research', + 'Use Perplexity AI for research-backed task updates' + ) + .action(async (options) => { + const tasksPath = options.file; + const fromId = parseInt(options.from, 10); + 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); - }); + // Check if there's an 'id' option which is a common mistake (instead of 'from') + if ( + process.argv.includes('--id') || + process.argv.some((arg) => arg.startsWith('--id=')) + ) { + console.error( + chalk.red('Error: The update command uses --from=, not --id=') + ); + console.log(chalk.yellow('\nTo update multiple tasks:')); + console.log( + ` task-master update --from=${fromId} --prompt="Your prompt here"` + ); + console.log( + chalk.yellow( + '\nTo update a single specific task, use the update-task command instead:' + ) + ); + console.log( + ` task-master update-task --id= --prompt="Your prompt here"` + ); + process.exit(1); + } - // 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); - }); + if (!prompt) { + console.error( + chalk.red( + 'Error: --prompt parameter is required. Please provide information about the changes.' + ) + ); + process.exit(1); + } - // 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); - }); + console.log( + chalk.blue( + `Updating tasks from ID >= ${fromId} with prompt: "${prompt}"` + ) + ); + console.log(chalk.blue(`Tasks file: ${tasksPath}`)); - // 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); - }); + if (useResearch) { + console.log( + chalk.blue('Using Perplexity AI for research-backed task updates') + ); + } - // 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); - }); + await updateTasks(tasksPath, fromId, prompt, useResearch); + }); - // 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); - }); + // update-task command + programInstance + .command('update-task') + .description( + 'Update a single specific task by ID with new information (use --id parameter)' + ) + .option('-f, --file ', 'Path to the tasks file', 'tasks/tasks.json') + .option('-i, --id ', 'Task ID to update (required)') + .option( + '-p, --prompt ', + 'Prompt explaining the changes or new context (required)' + ) + .option( + '-r, --research', + 'Use Perplexity AI for research-backed task updates' + ) + .action(async (options) => { + try { + const tasksPath = options.file; - // 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); - }); + // 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); + } - // 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); - }); + // 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); + } - // 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); - }); + 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); + } - // 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' } - )); - } + const prompt = options.prompt; + const useResearch = options.research || false; - // 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' } - )); - } + // 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); + } - // 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); - }); + console.log( + chalk.blue(`Updating task ${taskId} with prompt: "${prompt}"`) + ); + console.log(chalk.blue(`Tasks file: ${tasksPath}`)); - // remove-task command - programInstance - .command('remove-task') - .description('Remove a task or subtask permanently') - .option('-i, --id <id>', 'ID of the task or subtask to remove (e.g., "5" or "5.2")') - .option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json') - .option('-y, --yes', 'Skip confirmation prompt', false) - .action(async (options) => { - const tasksPath = options.file; - const taskId = options.id; - - if (!taskId) { - console.error(chalk.red('Error: Task ID is required')); - console.error(chalk.yellow('Usage: task-master remove-task --id=<taskId>')); - process.exit(1); - } - - try { - // Check if the task exists - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - console.error(chalk.red(`Error: No valid tasks found in ${tasksPath}`)); - process.exit(1); - } - - if (!taskExists(data.tasks, taskId)) { - console.error(chalk.red(`Error: Task with ID ${taskId} not found`)); - process.exit(1); - } - - // Load task for display - const task = findTaskById(data.tasks, taskId); - - // Skip confirmation if --yes flag is provided - if (!options.yes) { - // Display task information - console.log(); - console.log(chalk.red.bold('⚠️ WARNING: This will permanently delete the following task:')); - console.log(); - - if (typeof taskId === 'string' && taskId.includes('.')) { - // It's a subtask - const [parentId, subtaskId] = taskId.split('.'); - console.log(chalk.white.bold(`Subtask ${taskId}: ${task.title}`)); - console.log(chalk.gray(`Parent Task: ${task.parentTask.id} - ${task.parentTask.title}`)); - } else { - // It's a main task - console.log(chalk.white.bold(`Task ${taskId}: ${task.title}`)); - - // Show if it has subtasks - if (task.subtasks && task.subtasks.length > 0) { - console.log(chalk.yellow(`⚠️ This task has ${task.subtasks.length} subtasks that will also be deleted!`)); - } - - // Show if other tasks depend on it - const dependentTasks = data.tasks.filter(t => - t.dependencies && t.dependencies.includes(parseInt(taskId, 10))); - - if (dependentTasks.length > 0) { - console.log(chalk.yellow(`⚠️ Warning: ${dependentTasks.length} other tasks depend on this task!`)); - console.log(chalk.yellow('These dependencies will be removed:')); - dependentTasks.forEach(t => { - console.log(chalk.yellow(` - Task ${t.id}: ${t.title}`)); - }); - } - } - - console.log(); - - // Prompt for confirmation - const { confirm } = await inquirer.prompt([ - { - type: 'confirm', - name: 'confirm', - message: chalk.red.bold('Are you sure you want to permanently delete this task?'), - default: false - } - ]); - - if (!confirm) { - console.log(chalk.blue('Task deletion cancelled.')); - process.exit(0); - } - } - - const indicator = startLoadingIndicator('Removing task...'); - - // Remove the task - const result = await removeTask(tasksPath, taskId); - - stopLoadingIndicator(indicator); - - // Display success message with appropriate color based on task or subtask - if (typeof taskId === 'string' && taskId.includes('.')) { - // It was a subtask - console.log(boxen( - chalk.green(`Subtask ${taskId} has been successfully removed`), - { padding: 1, borderColor: 'green', borderStyle: 'round' } - )); - } else { - // It was a main task - console.log(boxen( - chalk.green(`Task ${taskId} has been successfully removed`), - { padding: 1, borderColor: 'green', borderStyle: 'round' } - )); - } - - } catch (error) { - console.error(chalk.red(`Error: ${error.message || 'An unknown error occurred'}`)); - process.exit(1); - } - }); + 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') + ); + } + } - // Add more commands as needed... - - return programInstance; + 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 idArg = options.id; + const numSubtasks = options.num || CONFIG.defaultSubtasks; + const useResearch = options.research || false; + const additionalContext = options.prompt || ''; + const forceFlag = options.force || false; + const tasksPath = options.file || 'tasks/tasks.json'; + + if (options.all) { + 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( + tasksPath, + 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( + tasksPath, + 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); + }); + + // remove-task command + programInstance + .command('remove-task') + .description('Remove a task or subtask permanently') + .option( + '-i, --id <id>', + 'ID of the task or subtask to remove (e.g., "5" or "5.2")' + ) + .option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json') + .option('-y, --yes', 'Skip confirmation prompt', false) + .action(async (options) => { + const tasksPath = options.file; + const taskId = options.id; + + if (!taskId) { + console.error(chalk.red('Error: Task ID is required')); + console.error( + chalk.yellow('Usage: task-master remove-task --id=<taskId>') + ); + process.exit(1); + } + + try { + // Check if the task exists + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + console.error( + chalk.red(`Error: No valid tasks found in ${tasksPath}`) + ); + process.exit(1); + } + + if (!taskExists(data.tasks, taskId)) { + console.error(chalk.red(`Error: Task with ID ${taskId} not found`)); + process.exit(1); + } + + // Load task for display + const task = findTaskById(data.tasks, taskId); + + // Skip confirmation if --yes flag is provided + if (!options.yes) { + // Display task information + console.log(); + console.log( + chalk.red.bold( + '⚠️ WARNING: This will permanently delete the following task:' + ) + ); + console.log(); + + if (typeof taskId === 'string' && taskId.includes('.')) { + // It's a subtask + const [parentId, subtaskId] = taskId.split('.'); + console.log(chalk.white.bold(`Subtask ${taskId}: ${task.title}`)); + console.log( + chalk.gray( + `Parent Task: ${task.parentTask.id} - ${task.parentTask.title}` + ) + ); + } else { + // It's a main task + console.log(chalk.white.bold(`Task ${taskId}: ${task.title}`)); + + // Show if it has subtasks + if (task.subtasks && task.subtasks.length > 0) { + console.log( + chalk.yellow( + `⚠️ This task has ${task.subtasks.length} subtasks that will also be deleted!` + ) + ); + } + + // Show if other tasks depend on it + const dependentTasks = data.tasks.filter( + (t) => + t.dependencies && t.dependencies.includes(parseInt(taskId, 10)) + ); + + if (dependentTasks.length > 0) { + console.log( + chalk.yellow( + `⚠️ Warning: ${dependentTasks.length} other tasks depend on this task!` + ) + ); + console.log(chalk.yellow('These dependencies will be removed:')); + dependentTasks.forEach((t) => { + console.log(chalk.yellow(` - Task ${t.id}: ${t.title}`)); + }); + } + } + + console.log(); + + // Prompt for confirmation + const { confirm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: chalk.red.bold( + 'Are you sure you want to permanently delete this task?' + ), + default: false + } + ]); + + if (!confirm) { + console.log(chalk.blue('Task deletion cancelled.')); + process.exit(0); + } + } + + const indicator = startLoadingIndicator('Removing task...'); + + // Remove the task + const result = await removeTask(tasksPath, taskId); + + stopLoadingIndicator(indicator); + + // Display success message with appropriate color based on task or subtask + if (typeof taskId === 'string' && taskId.includes('.')) { + // It was a subtask + console.log( + boxen( + chalk.green(`Subtask ${taskId} has been successfully removed`), + { padding: 1, borderColor: 'green', borderStyle: 'round' } + ) + ); + } else { + // It was a main task + console.log( + boxen(chalk.green(`Task ${taskId} has been successfully removed`), { + padding: 1, + borderColor: 'green', + borderStyle: 'round' + }) + ); + } + } catch (error) { + console.error( + chalk.red(`Error: ${error.message || 'An unknown error occurred'}`) + ); + process.exit(1); + } + }); + + // Add more commands as needed... + + return programInstance; } /** @@ -999,43 +1487,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; } /** @@ -1043,84 +1533,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(); + }); } /** @@ -1130,18 +1626,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; } /** @@ -1150,18 +1646,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); } /** @@ -1169,46 +1665,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 1ae19717..2aaf2a46 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,1092 +33,1261 @@ 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 - // Pass isCircularDependency explicitly to make it available - return Function('tasks', 'tasksPath', 'log', 'customLogger', 'isCircularDependency', 'taskExists', - `return (${originalValidateTaskDependencies.toString()})(tasks, tasksPath);` - )(tasks, tasksPath, logProxy, customLogger, isCircularDependency, taskExists); - })(); - - 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 + // Pass isCircularDependency explicitly to make it available + return Function( + 'tasks', + 'tasksPath', + 'log', + 'customLogger', + 'isCircularDependency', + 'taskExists', + `return (${originalValidateTaskDependencies.toString()})(tasks, tasksPath);` + )( + tasks, + tasksPath, + logProxy, + customLogger, + isCircularDependency, + taskExists + ); + })(); + + 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 0413cb9d..cd73b10a 100644 --- a/scripts/modules/task-manager.js +++ b/scripts/modules/task-manager.js @@ -13,74 +13,77 @@ import { Anthropic } from '@anthropic-ai/sdk'; import ora from 'ora'; import inquirer from 'inquirer'; -import { - CONFIG, - log, - readJSON, - writeJSON, - sanitizePrompt, - findTaskById, - readComplexityReport, - findTaskInComplexityReport, - truncate, - enableSilentMode, - disableSilentMode, - isSilentMode +import { + CONFIG, + log, + readJSON, + writeJSON, + sanitizePrompt, + findTaskById, + readComplexityReport, + findTaskInComplexityReport, + truncate, + enableSilentMode, + disableSilentMode, + isSilentMode } 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, - _handleAnthropicStream, - getConfiguredAnthropicClient, - sendChatWithContext, - parseTasksFromCompletion, - generateTaskDescriptionWithPerplexity, - parseSubtasksFromText + callClaude, + generateSubtasks, + generateSubtasksWithPerplexity, + generateComplexityAnalysisPrompt, + getAvailableAIModel, + handleClaudeError, + _handleAnthropicStream, + getConfiguredAnthropicClient, + sendChatWithContext, + parseTasksFromCompletion, + generateTaskDescriptionWithPerplexity, + parseSubtasksFromText } 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'); } /** @@ -88,90 +91,120 @@ try { * @param {string} prdPath - Path to the PRD file * @param {string} tasksPath - Path to the tasks.json file * @param {number} numTasks - Number of tasks to generate - * @param {Object} options - Additional options + * @param {Object} options - Additional options * @param {Object} options.reportProgress - Function to report progress to MCP server (optional) * @param {Object} options.mcpLog - MCP logger object (optional) * @param {Object} options.session - Session object from MCP server (optional) * @param {Object} aiClient - AI client to use (optional) * @param {Object} modelConfig - Model configuration (optional) */ -async function parsePRD(prdPath, tasksPath, numTasks, options = {}, aiClient = null, modelConfig = null) { - const { reportProgress, mcpLog, session } = options; - - // Determine output format based on mcpLog presence (simplification) - const outputFormat = mcpLog ? 'json' : 'text'; - - // Create custom reporter that checks for MCP log and silent mode - const report = (message, level = 'info') => { - if (mcpLog) { - mcpLog[level](message); - } else if (!isSilentMode() && outputFormat === 'text') { - // Only log to console if not in silent mode and outputFormat is 'text' - log(level, message); - } - }; - - try { - report(`Parsing PRD file: ${prdPath}`, 'info'); - - // Read the PRD content - const prdContent = fs.readFileSync(prdPath, 'utf8'); - - // Call Claude to generate tasks, passing the provided AI client if available - const tasksData = await callClaude(prdContent, prdPath, numTasks, 0, { reportProgress, mcpLog, session }, aiClient, modelConfig); - - // 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); - report(`Successfully generated ${tasksData.tasks.length} tasks from PRD`, 'success'); - report(`Tasks saved to: ${tasksPath}`, 'info'); - - // Generate individual task files - if (reportProgress && mcpLog) { - // Enable silent mode when being called from MCP server - enableSilentMode(); - await generateTaskFiles(tasksPath, tasksDir); - disableSilentMode(); - } else { - await generateTaskFiles(tasksPath, tasksDir); - } - - // Only show success boxes for text output (CLI) - if (outputFormat === 'text') { - 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 } } - )); - } - - return tasksData; - } catch (error) { - report(`Error parsing PRD: ${error.message}`, 'error'); - - // Only show error UI for text output (CLI) - if (outputFormat === 'text') { - console.error(chalk.red(`Error: ${error.message}`)); - - if (CONFIG.debug) { - console.error(error); - } - - process.exit(1); - } else { - throw error; // Re-throw for JSON output - } - } +async function parsePRD( + prdPath, + tasksPath, + numTasks, + options = {}, + aiClient = null, + modelConfig = null +) { + const { reportProgress, mcpLog, session } = options; + + // Determine output format based on mcpLog presence (simplification) + const outputFormat = mcpLog ? 'json' : 'text'; + + // Create custom reporter that checks for MCP log and silent mode + const report = (message, level = 'info') => { + if (mcpLog) { + mcpLog[level](message); + } else if (!isSilentMode() && outputFormat === 'text') { + // Only log to console if not in silent mode and outputFormat is 'text' + log(level, message); + } + }; + + try { + report(`Parsing PRD file: ${prdPath}`, 'info'); + + // Read the PRD content + const prdContent = fs.readFileSync(prdPath, 'utf8'); + + // Call Claude to generate tasks, passing the provided AI client if available + const tasksData = await callClaude( + prdContent, + prdPath, + numTasks, + 0, + { reportProgress, mcpLog, session }, + aiClient, + modelConfig + ); + + // 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); + report( + `Successfully generated ${tasksData.tasks.length} tasks from PRD`, + 'success' + ); + report(`Tasks saved to: ${tasksPath}`, 'info'); + + // Generate individual task files + if (reportProgress && mcpLog) { + // Enable silent mode when being called from MCP server + enableSilentMode(); + await generateTaskFiles(tasksPath, tasksDir); + disableSilentMode(); + } else { + await generateTaskFiles(tasksPath, tasksDir); + } + + // Only show success boxes for text output (CLI) + if (outputFormat === 'text') { + 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 } + } + ) + ); + } + + return tasksData; + } catch (error) { + report(`Error parsing PRD: ${error.message}`, 'error'); + + // Only show error UI for text output (CLI) + if (outputFormat === 'text') { + console.error(chalk.red(`Error: ${error.message}`)); + + if (CONFIG.debug) { + console.error(error); + } + + process.exit(1); + } else { + throw error; // Re-throw for JSON output + } + } } /** @@ -184,81 +217,116 @@ async function parsePRD(prdPath, tasksPath, numTasks, options = {}, aiClient = n * @param {Object} mcpLog - MCP logger object (optional) * @param {Object} session - Session object from MCP server (optional) */ -async function updateTasks(tasksPath, fromId, prompt, useResearch = false, { reportProgress, mcpLog, session } = {}) { - // Determine output format based on mcpLog presence (simplification) - const outputFormat = mcpLog ? 'json' : 'text'; - - // Create custom reporter that checks for MCP log and silent mode - const report = (message, level = 'info') => { - if (mcpLog) { - mcpLog[level](message); - } else if (!isSilentMode() && outputFormat === 'text') { - // Only log to console if not in silent mode and outputFormat is 'text' - log(level, message); - } - }; - - try { - report(`Updating tasks from ID ${fromId} with prompt: "${prompt}"`); - - // 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) { - report(`No tasks to update (all tasks with ID >= ${fromId} are already marked as done)`, 'info'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - console.log(chalk.yellow(`No tasks to update (all tasks with ID >= ${fromId} are already marked as done)`)); - } - return; - } - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - // 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. +async function updateTasks( + tasksPath, + fromId, + prompt, + useResearch = false, + { reportProgress, mcpLog, session } = {} +) { + // Determine output format based on mcpLog presence (simplification) + const outputFormat = mcpLog ? 'json' : 'text'; + + // Create custom reporter that checks for MCP log and silent mode + const report = (message, level = 'info') => { + if (mcpLog) { + mcpLog[level](message); + } else if (!isSilentMode() && outputFormat === 'text') { + // Only log to console if not in silent mode and outputFormat is 'text' + log(level, message); + } + }; + + try { + report(`Updating tasks from ID ${fromId} with prompt: "${prompt}"`); + + // 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) { + report( + `No tasks to update (all tasks with ID >= ${fromId} are already marked as done)`, + 'info' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + console.log( + chalk.yellow( + `No tasks to update (all tasks with ID >= ${fromId} are already marked as done)` + ) + ); + } + return; + } + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + // 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. @@ -276,64 +344,74 @@ Guidelines: The changes described in the prompt should be applied to ALL tasks in the list.`; - const taskData = JSON.stringify(tasksToUpdate, null, 2); - - // Initialize variables for model selection and fallback - let updatedTasks; - let loadingIndicator = null; - let claudeOverloaded = false; - let modelAttempts = 0; - const maxModelAttempts = 2; // Try up to 2 models before giving up - - // Only create loading indicator for text output (CLI) initially - if (outputFormat === 'text') { - loadingIndicator = startLoadingIndicator(useResearch - ? 'Updating tasks with Perplexity AI research...' - : 'Updating tasks with Claude AI...'); - } - - try { - // Import the getAvailableAIModel function - const { getAvailableAIModel } = await import('./ai-services.js'); - - // Try different models with fallback - while (modelAttempts < maxModelAttempts && !updatedTasks) { - modelAttempts++; - const isLastAttempt = modelAttempts >= maxModelAttempts; - let modelType = null; - - try { - // Get the appropriate model based on current state - const result = getAvailableAIModel({ - claudeOverloaded, - requiresResearch: useResearch - }); - modelType = result.type; - const client = result.client; - - report(`Attempt ${modelAttempts}/${maxModelAttempts}: Updating tasks using ${modelType}`, 'info'); - - // Update loading indicator - only for text output - if (outputFormat === 'text') { - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - } - loadingIndicator = startLoadingIndicator(`Attempt ${modelAttempts}: Using ${modelType.toUpperCase()}...`); - } - - if (modelType === 'perplexity') { - // Call Perplexity AI using proper format - const perplexityModel = process.env.PERPLEXITY_MODEL || session?.env?.PERPLEXITY_MODEL || 'sonar-pro'; - const result = await client.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); + + // Initialize variables for model selection and fallback + let updatedTasks; + let loadingIndicator = null; + let claudeOverloaded = false; + let modelAttempts = 0; + const maxModelAttempts = 2; // Try up to 2 models before giving up + + // Only create loading indicator for text output (CLI) initially + if (outputFormat === 'text') { + loadingIndicator = startLoadingIndicator( + useResearch + ? 'Updating tasks with Perplexity AI research...' + : 'Updating tasks with Claude AI...' + ); + } + + try { + // Import the getAvailableAIModel function + const { getAvailableAIModel } = await import('./ai-services.js'); + + // Try different models with fallback + while (modelAttempts < maxModelAttempts && !updatedTasks) { + modelAttempts++; + const isLastAttempt = modelAttempts >= maxModelAttempts; + let modelType = null; + + try { + // Get the appropriate model based on current state + const result = getAvailableAIModel({ + claudeOverloaded, + requiresResearch: useResearch + }); + modelType = result.type; + const client = result.client; + + report( + `Attempt ${modelAttempts}/${maxModelAttempts}: Updating tasks using ${modelType}`, + 'info' + ); + + // Update loading indicator - only for text output + if (outputFormat === 'text') { + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + } + loadingIndicator = startLoadingIndicator( + `Attempt ${modelAttempts}: Using ${modelType.toUpperCase()}...` + ); + } + + if (modelType === 'perplexity') { + // Call Perplexity AI using proper format + const perplexityModel = + process.env.PERPLEXITY_MODEL || + session?.env?.PERPLEXITY_MODEL || + 'sonar-pro'; + const result = await client.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: @@ -342,51 +420,63 @@ ${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 || session?.env?.TEMPERATURE || CONFIG.temperature), - max_tokens: parseInt(process.env.MAX_TOKENS || session?.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 ${modelType}'s response`); - } - - const jsonText = responseText.substring(jsonStart, jsonEnd + 1); - updatedTasks = JSON.parse(jsonText); - } else { - // Call Claude to update the tasks with streaming - let responseText = ''; - let streamingInterval = null; - - try { - // Update loading indicator to show streaming progress - only for text output - if (outputFormat === 'text') { - 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 client.messages.create({ - model: session?.env?.ANTHROPIC_MODEL || CONFIG.model, - max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, - temperature: session?.env?.TEMPERATURE || CONFIG.temperature, - system: systemPrompt, - messages: [ - { - role: 'user', - content: `Here is the task to update: + } + ], + temperature: parseFloat( + process.env.TEMPERATURE || + session?.env?.TEMPERATURE || + CONFIG.temperature + ), + max_tokens: parseInt( + process.env.MAX_TOKENS || + session?.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 ${modelType}'s response` + ); + } + + const jsonText = responseText.substring(jsonStart, jsonEnd + 1); + updatedTasks = JSON.parse(jsonText); + } else { + // Call Claude to update the tasks with streaming + let responseText = ''; + let streamingInterval = null; + + try { + // Update loading indicator to show streaming progress - only for text output + if (outputFormat === 'text') { + 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 client.messages.create({ + model: session?.env?.ANTHROPIC_MODEL || CONFIG.model, + max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, + temperature: session?.env?.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: @@ -395,169 +485,209 @@ ${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 (reportProgress) { - await reportProgress({ progress: (responseText.length / CONFIG.maxTokens) * 100 }); - } - if (mcpLog) { - mcpLog.info(`Progress: ${responseText.length / CONFIG.maxTokens * 100}%`); - } - } - - if (streamingInterval) clearInterval(streamingInterval); - - report(`Completed streaming response from ${modelType} API (Attempt ${modelAttempts})`, 'info'); - - // 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 ${modelType}'s response`); - } - - const jsonText = responseText.substring(jsonStart, jsonEnd + 1); - updatedTasks = JSON.parse(jsonText); - - } catch (streamError) { - if (streamingInterval) clearInterval(streamingInterval); - - // Process stream errors explicitly - report(`Stream error: ${streamError.message}`, 'error'); - - // Check if this is an overload error - let isOverload = false; - // Check 1: SDK specific property - if (streamError.type === 'overloaded_error') { - isOverload = true; - } - // Check 2: Check nested error property - else if (streamError.error?.type === 'overloaded_error') { - isOverload = true; - } - // Check 3: Check status code - else if (streamError.status === 429 || streamError.status === 529) { - isOverload = true; - } - // Check 4: Check message string - else if (streamError.message?.toLowerCase().includes('overloaded')) { - isOverload = true; - } - - if (isOverload) { - claudeOverloaded = true; - report('Claude overloaded. Will attempt fallback model if available.', 'warn'); - // Let the loop continue to try the next model - throw new Error('Claude overloaded'); - } else { - // Re-throw non-overload errors - throw streamError; - } - } - } - - // If we got here successfully, break out of the loop - if (updatedTasks) { - report(`Successfully updated tasks using ${modelType} on attempt ${modelAttempts}`, 'success'); - break; - } - - } catch (modelError) { - const failedModel = modelType || 'unknown model'; - report(`Attempt ${modelAttempts} failed using ${failedModel}: ${modelError.message}`, 'warn'); - - // Continue to next attempt if we have more attempts and this was an overload error - const wasOverload = modelError.message?.toLowerCase().includes('overload'); - - if (wasOverload && !isLastAttempt) { - if (modelType === 'claude') { - claudeOverloaded = true; - report('Will attempt with Perplexity AI next', 'info'); - } - continue; // Continue to next attempt - } else if (isLastAttempt) { - report(`Final attempt (${modelAttempts}/${maxModelAttempts}) failed. No fallback possible.`, 'error'); - throw modelError; // Re-throw on last attempt - } else { - throw modelError; // Re-throw for non-overload errors - } - } - } - - // If we don't have updated tasks after all attempts, throw an error - if (!updatedTasks) { - throw new Error('Failed to generate updated tasks after all model attempts'); - } - - // 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); - - report(`Successfully updated ${updatedTasks.length} tasks`, 'success'); - - // Generate individual task files - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); - - // Only show success box for text output (CLI) - if (outputFormat === 'text') { - console.log(boxen( - chalk.green(`Successfully updated ${updatedTasks.length} tasks`), - { padding: 1, borderColor: 'green', borderStyle: 'round' } - )); - } - } finally { - // Stop the loading indicator if it was created - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - loadingIndicator = null; - } - } - } catch (error) { - report(`Error updating tasks: ${error.message}`, 'error'); - - // Only show error box for text output (CLI) - if (outputFormat === 'text') { - console.error(chalk.red(`Error: ${error.message}`)); - - // Provide helpful error messages 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') && useResearch) { - 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 --from=<id> --prompt="..."'); - } else if (error.message?.includes('overloaded')) { - 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.'); - } - - if (CONFIG.debug) { - console.error(error); - } - - process.exit(1); - } else { - throw error; // Re-throw for JSON output - } - } + } + ], + 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 (reportProgress) { + await reportProgress({ + progress: (responseText.length / CONFIG.maxTokens) * 100 + }); + } + if (mcpLog) { + mcpLog.info( + `Progress: ${(responseText.length / CONFIG.maxTokens) * 100}%` + ); + } + } + + if (streamingInterval) clearInterval(streamingInterval); + + report( + `Completed streaming response from ${modelType} API (Attempt ${modelAttempts})`, + 'info' + ); + + // 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 ${modelType}'s response` + ); + } + + const jsonText = responseText.substring(jsonStart, jsonEnd + 1); + updatedTasks = JSON.parse(jsonText); + } catch (streamError) { + if (streamingInterval) clearInterval(streamingInterval); + + // Process stream errors explicitly + report(`Stream error: ${streamError.message}`, 'error'); + + // Check if this is an overload error + let isOverload = false; + // Check 1: SDK specific property + if (streamError.type === 'overloaded_error') { + isOverload = true; + } + // Check 2: Check nested error property + else if (streamError.error?.type === 'overloaded_error') { + isOverload = true; + } + // Check 3: Check status code + else if ( + streamError.status === 429 || + streamError.status === 529 + ) { + isOverload = true; + } + // Check 4: Check message string + else if ( + streamError.message?.toLowerCase().includes('overloaded') + ) { + isOverload = true; + } + + if (isOverload) { + claudeOverloaded = true; + report( + 'Claude overloaded. Will attempt fallback model if available.', + 'warn' + ); + // Let the loop continue to try the next model + throw new Error('Claude overloaded'); + } else { + // Re-throw non-overload errors + throw streamError; + } + } + } + + // If we got here successfully, break out of the loop + if (updatedTasks) { + report( + `Successfully updated tasks using ${modelType} on attempt ${modelAttempts}`, + 'success' + ); + break; + } + } catch (modelError) { + const failedModel = modelType || 'unknown model'; + report( + `Attempt ${modelAttempts} failed using ${failedModel}: ${modelError.message}`, + 'warn' + ); + + // Continue to next attempt if we have more attempts and this was an overload error + const wasOverload = modelError.message + ?.toLowerCase() + .includes('overload'); + + if (wasOverload && !isLastAttempt) { + if (modelType === 'claude') { + claudeOverloaded = true; + report('Will attempt with Perplexity AI next', 'info'); + } + continue; // Continue to next attempt + } else if (isLastAttempt) { + report( + `Final attempt (${modelAttempts}/${maxModelAttempts}) failed. No fallback possible.`, + 'error' + ); + throw modelError; // Re-throw on last attempt + } else { + throw modelError; // Re-throw for non-overload errors + } + } + } + + // If we don't have updated tasks after all attempts, throw an error + if (!updatedTasks) { + throw new Error( + 'Failed to generate updated tasks after all model attempts' + ); + } + + // 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); + + report(`Successfully updated ${updatedTasks.length} tasks`, 'success'); + + // Generate individual task files + await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + + // Only show success box for text output (CLI) + if (outputFormat === 'text') { + console.log( + boxen( + chalk.green(`Successfully updated ${updatedTasks.length} tasks`), + { padding: 1, borderColor: 'green', borderStyle: 'round' } + ) + ); + } + } finally { + // Stop the loading indicator if it was created + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + loadingIndicator = null; + } + } + } catch (error) { + report(`Error updating tasks: ${error.message}`, 'error'); + + // Only show error box for text output (CLI) + if (outputFormat === 'text') { + console.error(chalk.red(`Error: ${error.message}`)); + + // Provide helpful error messages 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') && useResearch) { + 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 --from=<id> --prompt="..."' + ); + } else if (error.message?.includes('overloaded')) { + 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.'); + } + + if (CONFIG.debug) { + console.error(error); + } + + process.exit(1); + } else { + throw error; // Re-throw for JSON output + } + } } /** @@ -571,116 +701,176 @@ Return only the updated task as a valid JSON object.` * @param {Object} session - Session object from MCP server (optional) * @returns {Object} - Updated task data or null if task wasn't updated */ -async function updateTaskById(tasksPath, taskId, prompt, useResearch = false, { reportProgress, mcpLog, session } = {}) { - // Determine output format based on mcpLog presence (simplification) - const outputFormat = mcpLog ? 'json' : 'text'; - - // Create custom reporter that checks for MCP log and silent mode - const report = (message, level = 'info') => { - if (mcpLog) { - mcpLog[level](message); - } else if (!isSilentMode() && outputFormat === 'text') { - // Only log to console if not in silent mode and outputFormat is 'text' - log(level, message); - } - }; - - try { - report(`Updating single task ${taskId} with prompt: "${prompt}"`, 'info'); - - // 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 || session?.env?.PERPLEXITY_API_KEY)) { - report('Perplexity AI is not available. Falling back to Claude AI.', 'warn'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - 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') { - report(`Task ${taskId} is already marked as done and cannot be updated`, 'warn'); - - // Only show warning box for text output (CLI) - if (outputFormat === 'text') { - 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; - } - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - // 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. +async function updateTaskById( + tasksPath, + taskId, + prompt, + useResearch = false, + { reportProgress, mcpLog, session } = {} +) { + // Determine output format based on mcpLog presence (simplification) + const outputFormat = mcpLog ? 'json' : 'text'; + + // Create custom reporter that checks for MCP log and silent mode + const report = (message, level = 'info') => { + if (mcpLog) { + mcpLog[level](message); + } else if (!isSilentMode() && outputFormat === 'text') { + // Only log to console if not in silent mode and outputFormat is 'text' + log(level, message); + } + }; + + try { + report(`Updating single task ${taskId} with prompt: "${prompt}"`, 'info'); + + // 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 || + session?.env?.PERPLEXITY_API_KEY) + ) { + report( + 'Perplexity AI is not available. Falling back to Claude AI.', + 'warn' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + 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') { + report( + `Task ${taskId} is already marked as done and cannot be updated`, + 'warn' + ); + + // Only show warning box for text output (CLI) + if (outputFormat === 'text') { + 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; + } + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + // 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. @@ -699,64 +889,74 @@ 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); - - // Initialize variables for model selection and fallback - let updatedTask; - let loadingIndicator = null; - let claudeOverloaded = false; - let modelAttempts = 0; - const maxModelAttempts = 2; // Try up to 2 models before giving up - - // Only create initial loading indicator for text output (CLI) - if (outputFormat === 'text') { - loadingIndicator = startLoadingIndicator(useResearch - ? 'Updating task with Perplexity AI research...' - : 'Updating task with Claude AI...'); - } - - try { - // Import the getAvailableAIModel function - const { getAvailableAIModel } = await import('./ai-services.js'); - - // Try different models with fallback - while (modelAttempts < maxModelAttempts && !updatedTask) { - modelAttempts++; - const isLastAttempt = modelAttempts >= maxModelAttempts; - let modelType = null; - - try { - // Get the appropriate model based on current state - const result = getAvailableAIModel({ - claudeOverloaded, - requiresResearch: useResearch - }); - modelType = result.type; - const client = result.client; - - report(`Attempt ${modelAttempts}/${maxModelAttempts}: Updating task using ${modelType}`, 'info'); - - // Update loading indicator - only for text output - if (outputFormat === 'text') { - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - } - loadingIndicator = startLoadingIndicator(`Attempt ${modelAttempts}: Using ${modelType.toUpperCase()}...`); - } - - if (modelType === 'perplexity') { - // Call Perplexity AI - const perplexityModel = process.env.PERPLEXITY_MODEL || session?.env?.PERPLEXITY_MODEL || 'sonar-pro'; - const result = await client.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); + + // Initialize variables for model selection and fallback + let updatedTask; + let loadingIndicator = null; + let claudeOverloaded = false; + let modelAttempts = 0; + const maxModelAttempts = 2; // Try up to 2 models before giving up + + // Only create initial loading indicator for text output (CLI) + if (outputFormat === 'text') { + loadingIndicator = startLoadingIndicator( + useResearch + ? 'Updating task with Perplexity AI research...' + : 'Updating task with Claude AI...' + ); + } + + try { + // Import the getAvailableAIModel function + const { getAvailableAIModel } = await import('./ai-services.js'); + + // Try different models with fallback + while (modelAttempts < maxModelAttempts && !updatedTask) { + modelAttempts++; + const isLastAttempt = modelAttempts >= maxModelAttempts; + let modelType = null; + + try { + // Get the appropriate model based on current state + const result = getAvailableAIModel({ + claudeOverloaded, + requiresResearch: useResearch + }); + modelType = result.type; + const client = result.client; + + report( + `Attempt ${modelAttempts}/${maxModelAttempts}: Updating task using ${modelType}`, + 'info' + ); + + // Update loading indicator - only for text output + if (outputFormat === 'text') { + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + } + loadingIndicator = startLoadingIndicator( + `Attempt ${modelAttempts}: Using ${modelType.toUpperCase()}...` + ); + } + + if (modelType === 'perplexity') { + // Call Perplexity AI + const perplexityModel = + process.env.PERPLEXITY_MODEL || + session?.env?.PERPLEXITY_MODEL || + 'sonar-pro'; + const result = await client.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: @@ -765,56 +965,70 @@ ${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 || session?.env?.TEMPERATURE || CONFIG.temperature), - max_tokens: parseInt(process.env.MAX_TOKENS || session?.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 ${modelType}'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 ${modelType} response as JSON: ${parseError.message}\nResponse fragment: ${jsonText.substring(0, 100)}...`); - } - } else { - // Call Claude to update the task with streaming - let responseText = ''; - let streamingInterval = null; - - try { - // Update loading indicator to show streaming progress - only for text output - if (outputFormat === 'text') { - 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 client.messages.create({ - model: session?.env?.ANTHROPIC_MODEL || CONFIG.model, - max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, - temperature: session?.env?.TEMPERATURE || CONFIG.temperature, - system: systemPrompt, - messages: [ - { - role: 'user', - content: `Here is the task to update: + } + ], + temperature: parseFloat( + process.env.TEMPERATURE || + session?.env?.TEMPERATURE || + CONFIG.temperature + ), + max_tokens: parseInt( + process.env.MAX_TOKENS || + session?.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 ${modelType}'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 ${modelType} response as JSON: ${parseError.message}\nResponse fragment: ${jsonText.substring(0, 100)}...` + ); + } + } else { + // Call Claude to update the task with streaming + let responseText = ''; + let streamingInterval = null; + + try { + // Update loading indicator to show streaming progress - only for text output + if (outputFormat === 'text') { + 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 client.messages.create({ + model: session?.env?.ANTHROPIC_MODEL || CONFIG.model, + max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, + temperature: session?.env?.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: @@ -823,249 +1037,323 @@ ${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 (reportProgress) { - await reportProgress({ progress: (responseText.length / CONFIG.maxTokens) * 100 }); - } - if (mcpLog) { - mcpLog.info(`Progress: ${responseText.length / CONFIG.maxTokens * 100}%`); - } - } - - if (streamingInterval) clearInterval(streamingInterval); - - report(`Completed streaming response from ${modelType} API (Attempt ${modelAttempts})`, 'info'); - - // 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 ${modelType}'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 ${modelType} response as JSON: ${parseError.message}\nResponse fragment: ${jsonText.substring(0, 100)}...`); - } - } catch (streamError) { - if (streamingInterval) clearInterval(streamingInterval); - - // Process stream errors explicitly - report(`Stream error: ${streamError.message}`, 'error'); - - // Check if this is an overload error - let isOverload = false; - // Check 1: SDK specific property - if (streamError.type === 'overloaded_error') { - isOverload = true; - } - // Check 2: Check nested error property - else if (streamError.error?.type === 'overloaded_error') { - isOverload = true; - } - // Check 3: Check status code - else if (streamError.status === 429 || streamError.status === 529) { - isOverload = true; - } - // Check 4: Check message string - else if (streamError.message?.toLowerCase().includes('overloaded')) { - isOverload = true; - } - - if (isOverload) { - claudeOverloaded = true; - report('Claude overloaded. Will attempt fallback model if available.', 'warn'); - // Let the loop continue to try the next model - throw new Error('Claude overloaded'); - } else { - // Re-throw non-overload errors - throw streamError; - } - } - } - - // If we got here successfully, break out of the loop - if (updatedTask) { - report(`Successfully updated task using ${modelType} on attempt ${modelAttempts}`, 'success'); - break; - } - - } catch (modelError) { - const failedModel = modelType || 'unknown model'; - report(`Attempt ${modelAttempts} failed using ${failedModel}: ${modelError.message}`, 'warn'); - - // Continue to next attempt if we have more attempts and this was an overload error - const wasOverload = modelError.message?.toLowerCase().includes('overload'); - - if (wasOverload && !isLastAttempt) { - if (modelType === 'claude') { - claudeOverloaded = true; - report('Will attempt with Perplexity AI next', 'info'); - } - continue; // Continue to next attempt - } else if (isLastAttempt) { - report(`Final attempt (${modelAttempts}/${maxModelAttempts}) failed. No fallback possible.`, 'error'); - throw modelError; // Re-throw on last attempt - } else { - throw modelError; // Re-throw for non-overload errors - } - } - } - - // If we don't have updated task after all attempts, throw an error - if (!updatedTask) { - throw new Error('Failed to generate updated task after all model attempts'); - } - - // 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) { - report(`Task ID was modified in the AI response. Restoring original ID ${taskId}.`, 'warn'); - updatedTask.id = taskId; - } - - // Ensure status is preserved unless explicitly changed in prompt - if (updatedTask.status !== taskToUpdate.status && !prompt.toLowerCase().includes('status')) { - report(`Task status was modified without explicit instruction. Restoring original status '${taskToUpdate.status}'.`, 'warn'); - updatedTask.status = taskToUpdate.status; - } - - // Ensure completed subtasks are preserved - if (taskToUpdate.subtasks && taskToUpdate.subtasks.length > 0) { - if (!updatedTask.subtasks) { - report('Subtasks were removed in the AI response. Restoring original subtasks.', 'warn'); - 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) { - report(`Completed subtask ${completedSubtask.id} was removed. Restoring it.`, 'warn'); - updatedTask.subtasks.push(completedSubtask); - } else if ( - updatedSubtask.title !== completedSubtask.title || - updatedSubtask.description !== completedSubtask.description || - updatedSubtask.details !== completedSubtask.details || - updatedSubtask.status !== completedSubtask.status - ) { - report(`Completed subtask ${completedSubtask.id} was modified. Restoring original.`, 'warn'); - // 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 { - report(`Duplicate subtask ID ${subtask.id} found. Removing duplicate.`, 'warn'); - } - } - - 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); - - report(`Successfully updated task ${taskId}`, 'success'); - - // Generate individual task files - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); - - // Only show success box for text output (CLI) - if (outputFormat === 'text') { - 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 { - // Stop the loading indicator if it was created - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - loadingIndicator = null; - } - } - } catch (error) { - report(`Error updating task: ${error.message}`, 'error'); - - // Only show error UI for text output (CLI) - if (outputFormat === 'text') { - 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); - } - } else { - throw error; // Re-throw for JSON output - } - - 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 (reportProgress) { + await reportProgress({ + progress: (responseText.length / CONFIG.maxTokens) * 100 + }); + } + if (mcpLog) { + mcpLog.info( + `Progress: ${(responseText.length / CONFIG.maxTokens) * 100}%` + ); + } + } + + if (streamingInterval) clearInterval(streamingInterval); + + report( + `Completed streaming response from ${modelType} API (Attempt ${modelAttempts})`, + 'info' + ); + + // 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 ${modelType}'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 ${modelType} response as JSON: ${parseError.message}\nResponse fragment: ${jsonText.substring(0, 100)}...` + ); + } + } catch (streamError) { + if (streamingInterval) clearInterval(streamingInterval); + + // Process stream errors explicitly + report(`Stream error: ${streamError.message}`, 'error'); + + // Check if this is an overload error + let isOverload = false; + // Check 1: SDK specific property + if (streamError.type === 'overloaded_error') { + isOverload = true; + } + // Check 2: Check nested error property + else if (streamError.error?.type === 'overloaded_error') { + isOverload = true; + } + // Check 3: Check status code + else if ( + streamError.status === 429 || + streamError.status === 529 + ) { + isOverload = true; + } + // Check 4: Check message string + else if ( + streamError.message?.toLowerCase().includes('overloaded') + ) { + isOverload = true; + } + + if (isOverload) { + claudeOverloaded = true; + report( + 'Claude overloaded. Will attempt fallback model if available.', + 'warn' + ); + // Let the loop continue to try the next model + throw new Error('Claude overloaded'); + } else { + // Re-throw non-overload errors + throw streamError; + } + } + } + + // If we got here successfully, break out of the loop + if (updatedTask) { + report( + `Successfully updated task using ${modelType} on attempt ${modelAttempts}`, + 'success' + ); + break; + } + } catch (modelError) { + const failedModel = modelType || 'unknown model'; + report( + `Attempt ${modelAttempts} failed using ${failedModel}: ${modelError.message}`, + 'warn' + ); + + // Continue to next attempt if we have more attempts and this was an overload error + const wasOverload = modelError.message + ?.toLowerCase() + .includes('overload'); + + if (wasOverload && !isLastAttempt) { + if (modelType === 'claude') { + claudeOverloaded = true; + report('Will attempt with Perplexity AI next', 'info'); + } + continue; // Continue to next attempt + } else if (isLastAttempt) { + report( + `Final attempt (${modelAttempts}/${maxModelAttempts}) failed. No fallback possible.`, + 'error' + ); + throw modelError; // Re-throw on last attempt + } else { + throw modelError; // Re-throw for non-overload errors + } + } + } + + // If we don't have updated task after all attempts, throw an error + if (!updatedTask) { + throw new Error( + 'Failed to generate updated task after all model attempts' + ); + } + + // 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) { + report( + `Task ID was modified in the AI response. Restoring original ID ${taskId}.`, + 'warn' + ); + updatedTask.id = taskId; + } + + // Ensure status is preserved unless explicitly changed in prompt + if ( + updatedTask.status !== taskToUpdate.status && + !prompt.toLowerCase().includes('status') + ) { + report( + `Task status was modified without explicit instruction. Restoring original status '${taskToUpdate.status}'.`, + 'warn' + ); + updatedTask.status = taskToUpdate.status; + } + + // Ensure completed subtasks are preserved + if (taskToUpdate.subtasks && taskToUpdate.subtasks.length > 0) { + if (!updatedTask.subtasks) { + report( + 'Subtasks were removed in the AI response. Restoring original subtasks.', + 'warn' + ); + 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) { + report( + `Completed subtask ${completedSubtask.id} was removed. Restoring it.`, + 'warn' + ); + updatedTask.subtasks.push(completedSubtask); + } else if ( + updatedSubtask.title !== completedSubtask.title || + updatedSubtask.description !== completedSubtask.description || + updatedSubtask.details !== completedSubtask.details || + updatedSubtask.status !== completedSubtask.status + ) { + report( + `Completed subtask ${completedSubtask.id} was modified. Restoring original.`, + 'warn' + ); + // 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 { + report( + `Duplicate subtask ID ${subtask.id} found. Removing duplicate.`, + 'warn' + ); + } + } + + 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); + + report(`Successfully updated task ${taskId}`, 'success'); + + // Generate individual task files + await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + + // Only show success box for text output (CLI) + if (outputFormat === 'text') { + 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 { + // Stop the loading indicator if it was created + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + loadingIndicator = null; + } + } + } catch (error) { + report(`Error updating task: ${error.message}`, 'error'); + + // Only show error UI for text output (CLI) + if (outputFormat === 'text') { + 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); + } + } else { + throw error; // Re-throw for JSON output + } + + return null; + } } /** @@ -1076,122 +1364,144 @@ Return only the updated task as a valid JSON object.` * @returns {Object|undefined} Result object in MCP mode, undefined in CLI mode */ function generateTaskFiles(tasksPath, outputDir, options = {}) { - try { - // Determine if we're in MCP mode by checking for mcpLog - const isMcpMode = !!options?.mcpLog; - - 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}'.`); - - // Return success data in MCP mode - if (isMcpMode) { - return { - success: true, - count: data.tasks.length, - directory: outputDir - }; - } - } catch (error) { - log('error', `Error generating task files: ${error.message}`); - - // Only show error UI in CLI mode - if (!options?.mcpLog) { - console.error(chalk.red(`Error generating task files: ${error.message}`)); - - if (CONFIG.debug) { - console.error(error); - } - - process.exit(1); - } else { - // In MCP mode, throw the error for the caller to handle - throw error; - } - } + try { + // Determine if we're in MCP mode by checking for mcpLog + const isMcpMode = !!options?.mcpLog; + + 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}'.` + ); + + // Return success data in MCP mode + if (isMcpMode) { + return { + success: true, + count: data.tasks.length, + directory: outputDir + }; + } + } catch (error) { + log('error', `Error generating task files: ${error.message}`); + + // Only show error UI in CLI mode + if (!options?.mcpLog) { + console.error(chalk.red(`Error generating task files: ${error.message}`)); + + if (CONFIG.debug) { + console.error(error); + } + + process.exit(1); + } else { + // In MCP mode, throw the error for the caller to handle + throw error; + } + } } /** @@ -1203,87 +1513,95 @@ function generateTaskFiles(tasksPath, outputDir, options = {}) { * @returns {Object|undefined} Result object in MCP mode, undefined in CLI mode */ async function setTaskStatus(tasksPath, taskIdInput, newStatus, options = {}) { - try { - // Determine if we're in MCP mode by checking for mcpLog - const isMcpMode = !!options?.mcpLog; - - // Only display UI elements if not in MCP mode - if (!isMcpMode) { - 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, !isMcpMode); - 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), { mcpLog: options.mcpLog }); - - // Display success message - only in CLI mode - if (!isMcpMode) { - 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' } - )); - } - } - - // Return success value for programmatic use - return { - success: true, - updatedTasks: updatedTasks.map(id => ({ - id, - status: newStatus - })) - }; - } catch (error) { - log('error', `Error setting task status: ${error.message}`); - - // Only show error UI in CLI mode - if (!options?.mcpLog) { - console.error(chalk.red(`Error: ${error.message}`)); - - if (CONFIG.debug) { - console.error(error); - } - - process.exit(1); - } else { - // In MCP mode, throw the error for the caller to handle - throw error; - } - } + try { + // Determine if we're in MCP mode by checking for mcpLog + const isMcpMode = !!options?.mcpLog; + + // Only display UI elements if not in MCP mode + if (!isMcpMode) { + 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, !isMcpMode); + 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), { + mcpLog: options.mcpLog + }); + + // Display success message - only in CLI mode + if (!isMcpMode) { + 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' } + ) + ); + } + } + + // Return success value for programmatic use + return { + success: true, + updatedTasks: updatedTasks.map((id) => ({ + id, + status: newStatus + })) + }; + } catch (error) { + log('error', `Error setting task status: ${error.message}`); + + // Only show error UI in CLI mode + if (!options?.mcpLog) { + console.error(chalk.red(`Error: ${error.message}`)); + + if (CONFIG.debug) { + console.error(error); + } + + process.exit(1); + } else { + // In MCP mode, throw the error for the caller to handle + throw error; + } + } } /** @@ -1294,79 +1612,117 @@ async function setTaskStatus(tasksPath, taskIdInput, newStatus, options = {}) { * @param {Object} data - Tasks data * @param {boolean} showUi - Whether to show UI elements */ -async function updateSingleTaskStatus(tasksPath, taskIdInput, newStatus, data, showUi = 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 = 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') { - // Only show suggestion in CLI mode - if (showUi) { - 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; - }); - } - } - } +async function updateSingleTaskStatus( + tasksPath, + taskIdInput, + newStatus, + data, + showUi = 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 = 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' + ) { + // Only show suggestion in CLI mode + if (showUi) { + 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; + }); + } + } + } } /** @@ -1377,570 +1733,676 @@ async function updateSingleTaskStatus(tasksPath, taskIdInput, newStatus, data, s * @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 for tasks - 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; - const cancelledCount = data.tasks.filter(task => task.status === 'cancelled').length; - - // Count subtasks and their statuses - let totalSubtasks = 0; - let completedSubtasks = 0; - let inProgressSubtasks = 0; - let pendingSubtasks = 0; - let blockedSubtasks = 0; - let deferredSubtasks = 0; - let cancelledSubtasks = 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; - inProgressSubtasks += task.subtasks.filter(st => st.status === 'in-progress').length; - pendingSubtasks += task.subtasks.filter(st => st.status === 'pending').length; - blockedSubtasks += task.subtasks.filter(st => st.status === 'blocked').length; - deferredSubtasks += task.subtasks.filter(st => st.status === 'deferred').length; - cancelledSubtasks += task.subtasks.filter(st => st.status === 'cancelled').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, - cancelled: cancelledCount, - completionPercentage, - subtasks: { - total: totalSubtasks, - completed: completedSubtasks, - inProgress: inProgressSubtasks, - pending: pendingSubtasks, - blocked: blockedSubtasks, - deferred: deferredSubtasks, - cancelled: cancelledSubtasks, - completionPercentage: subtaskCompletionPercentage - } - } - }; - } - - // ... existing code for text output ... - - // Calculate status breakdowns as percentages of total - const taskStatusBreakdown = { - 'in-progress': totalTasks > 0 ? (inProgressCount / totalTasks) * 100 : 0, - 'pending': totalTasks > 0 ? (pendingCount / totalTasks) * 100 : 0, - 'blocked': totalTasks > 0 ? (blockedCount / totalTasks) * 100 : 0, - 'deferred': totalTasks > 0 ? (deferredCount / totalTasks) * 100 : 0, - 'cancelled': totalTasks > 0 ? (cancelledCount / totalTasks) * 100 : 0 - }; - - const subtaskStatusBreakdown = { - 'in-progress': totalSubtasks > 0 ? (inProgressSubtasks / totalSubtasks) * 100 : 0, - 'pending': totalSubtasks > 0 ? (pendingSubtasks / totalSubtasks) * 100 : 0, - 'blocked': totalSubtasks > 0 ? (blockedSubtasks / totalSubtasks) * 100 : 0, - 'deferred': totalSubtasks > 0 ? (deferredSubtasks / totalSubtasks) * 100 : 0, - 'cancelled': totalSubtasks > 0 ? (cancelledSubtasks / totalSubtasks) * 100 : 0 - }; - - // Create progress bars with status breakdowns - const taskProgressBar = createProgressBar(completionPercentage, 30, taskStatusBreakdown); - const subtaskProgressBar = createProgressBar(subtaskCompletionPercentage, 30, subtaskStatusBreakdown); - - // 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)} Cancelled: ${chalk.gray(cancelledCount)}\n\n` + - `Subtasks Progress: ${chalk.cyan(subtaskProgressBar)} ${subtaskCompletionPercentage.toFixed(0)}%\n` + - `Completed: ${chalk.green(completedSubtasks)}/${totalSubtasks} In Progress: ${chalk.blue(inProgressSubtasks)} Pending: ${chalk.yellow(pendingSubtasks)} Blocked: ${chalk.red(blockedSubtasks)} Deferred: ${chalk.gray(deferredSubtasks)} Cancelled: ${chalk.gray(cancelledSubtasks)}\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, - 'cancelled': chalk.gray - }; - 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 for tasks + 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; + const cancelledCount = data.tasks.filter( + (task) => task.status === 'cancelled' + ).length; + + // Count subtasks and their statuses + let totalSubtasks = 0; + let completedSubtasks = 0; + let inProgressSubtasks = 0; + let pendingSubtasks = 0; + let blockedSubtasks = 0; + let deferredSubtasks = 0; + let cancelledSubtasks = 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; + inProgressSubtasks += task.subtasks.filter( + (st) => st.status === 'in-progress' + ).length; + pendingSubtasks += task.subtasks.filter( + (st) => st.status === 'pending' + ).length; + blockedSubtasks += task.subtasks.filter( + (st) => st.status === 'blocked' + ).length; + deferredSubtasks += task.subtasks.filter( + (st) => st.status === 'deferred' + ).length; + cancelledSubtasks += task.subtasks.filter( + (st) => st.status === 'cancelled' + ).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, + cancelled: cancelledCount, + completionPercentage, + subtasks: { + total: totalSubtasks, + completed: completedSubtasks, + inProgress: inProgressSubtasks, + pending: pendingSubtasks, + blocked: blockedSubtasks, + deferred: deferredSubtasks, + cancelled: cancelledSubtasks, + completionPercentage: subtaskCompletionPercentage + } + } + }; + } + + // ... existing code for text output ... + + // Calculate status breakdowns as percentages of total + const taskStatusBreakdown = { + 'in-progress': totalTasks > 0 ? (inProgressCount / totalTasks) * 100 : 0, + pending: totalTasks > 0 ? (pendingCount / totalTasks) * 100 : 0, + blocked: totalTasks > 0 ? (blockedCount / totalTasks) * 100 : 0, + deferred: totalTasks > 0 ? (deferredCount / totalTasks) * 100 : 0, + cancelled: totalTasks > 0 ? (cancelledCount / totalTasks) * 100 : 0 + }; + + const subtaskStatusBreakdown = { + 'in-progress': + totalSubtasks > 0 ? (inProgressSubtasks / totalSubtasks) * 100 : 0, + pending: totalSubtasks > 0 ? (pendingSubtasks / totalSubtasks) * 100 : 0, + blocked: totalSubtasks > 0 ? (blockedSubtasks / totalSubtasks) * 100 : 0, + deferred: + totalSubtasks > 0 ? (deferredSubtasks / totalSubtasks) * 100 : 0, + cancelled: + totalSubtasks > 0 ? (cancelledSubtasks / totalSubtasks) * 100 : 0 + }; + + // Create progress bars with status breakdowns + const taskProgressBar = createProgressBar( + completionPercentage, + 30, + taskStatusBreakdown + ); + const subtaskProgressBar = createProgressBar( + subtaskCompletionPercentage, + 30, + subtaskStatusBreakdown + ); + + // 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)} Cancelled: ${chalk.gray(cancelledCount)}\n\n` + + `Subtasks Progress: ${chalk.cyan(subtaskProgressBar)} ${subtaskCompletionPercentage.toFixed(0)}%\n` + + `Completed: ${chalk.green(completedSubtasks)}/${totalSubtasks} In Progress: ${chalk.blue(inProgressSubtasks)} Pending: ${chalk.yellow(pendingSubtasks)} Blocked: ${chalk.red(blockedSubtasks)} Deferred: ${chalk.gray(deferredSubtasks)} Cancelled: ${chalk.gray(cancelledSubtasks)}\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, + cancelled: chalk.gray + }; + 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); + } } /** @@ -1951,13 +2413,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; } /** @@ -1973,118 +2435,141 @@ function safeColor(text, colorFn, maxLength = 0) { * @param {Object} options.session - Session object from MCP * @returns {Promise<Object>} Expanded task */ -async function expandTask(tasksPath, taskId, numSubtasks, useResearch = false, additionalContext = '', { reportProgress, mcpLog, session } = {}) { - // Determine output format based on mcpLog presence (simplification) - const outputFormat = mcpLog ? 'json' : 'text'; - - // Create custom reporter that checks for MCP log and silent mode - const report = (message, level = 'info') => { - if (mcpLog) { - mcpLog[level](message); - } else if (!isSilentMode() && outputFormat === 'text') { - // Only log to console if not in silent mode and outputFormat is 'text' - log(level, message); - } - }; - - // Keep the mcpLog check for specific MCP context logging - if (mcpLog) { - mcpLog.info(`expandTask - reportProgress available: ${!!reportProgress}, session available: ${!!session}`); - } - - try { - // Read the tasks.json file - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - throw new Error("Invalid or missing tasks.json"); - } - - // Find the task - const task = data.tasks.find(t => t.id === parseInt(taskId, 10)); - if (!task) { - throw new Error(`Task with ID ${taskId} not found`); - } - - report(`Expanding task ${taskId}: ${task.title}`); - - // If the task already has subtasks and force flag is not set, return the existing subtasks - if (task.subtasks && task.subtasks.length > 0) { - report(`Task ${taskId} already has ${task.subtasks.length} subtasks`); - return task; - } - - // Determine the number of subtasks to generate - let subtaskCount = parseInt(numSubtasks, 10) || CONFIG.defaultSubtasks; - - // Check if we have a complexity analysis for this task - let taskAnalysis = null; - try { - const reportPath = 'scripts/task-complexity-report.json'; - if (fs.existsSync(reportPath)) { - const report = readJSON(reportPath); - if (report && report.complexityAnalysis) { - taskAnalysis = report.complexityAnalysis.find(a => a.taskId === task.id); - } - } - } catch (error) { - report(`Could not read complexity analysis: ${error.message}`, 'warn'); - } - - // Use recommended subtask count if available - if (taskAnalysis) { - report(`Found complexity analysis for task ${taskId}: Score ${taskAnalysis.complexityScore}/10`); - - // Use recommended number of subtasks if available - if (taskAnalysis.recommendedSubtasks && subtaskCount === CONFIG.defaultSubtasks) { - subtaskCount = taskAnalysis.recommendedSubtasks; - report(`Using recommended number of subtasks: ${subtaskCount}`); - } - - // Use the expansion prompt from analysis as additional context - if (taskAnalysis.expansionPrompt && !additionalContext) { - additionalContext = taskAnalysis.expansionPrompt; - report(`Using expansion prompt from complexity analysis`); - } - } - - // Generate subtasks with AI - let generatedSubtasks = []; - - // Only create loading indicator if not in silent mode and no mcpLog (CLI mode) - let loadingIndicator = null; - if (!isSilentMode() && !mcpLog) { - loadingIndicator = startLoadingIndicator(useResearch ? 'Generating research-backed subtasks...' : 'Generating subtasks...'); - } - - try { - // Determine the next subtask ID - const nextSubtaskId = 1; - - if (useResearch) { - // Use Perplexity for research-backed subtasks - if (!perplexity) { - report('Perplexity AI is not available. Falling back to Claude AI.', 'warn'); - useResearch = false; - } else { - report('Using Perplexity for research-backed subtasks'); - generatedSubtasks = await generateSubtasksWithPerplexity( - task, - subtaskCount, - nextSubtaskId, - additionalContext, - { reportProgress, mcpLog, silentMode: isSilentMode(), session } - ); - } - } - - if (!useResearch) { - report('Using regular Claude for generating subtasks'); - - // Use our getConfiguredAnthropicClient function instead of getAnthropicClient - const client = getConfiguredAnthropicClient(session); - - // Build the system prompt - const systemPrompt = `You are an AI assistant helping with task breakdown for software development. +async function expandTask( + tasksPath, + taskId, + numSubtasks, + useResearch = false, + additionalContext = '', + { reportProgress, mcpLog, session } = {} +) { + // Determine output format based on mcpLog presence (simplification) + const outputFormat = mcpLog ? 'json' : 'text'; + + // Create custom reporter that checks for MCP log and silent mode + const report = (message, level = 'info') => { + if (mcpLog) { + mcpLog[level](message); + } else if (!isSilentMode() && outputFormat === 'text') { + // Only log to console if not in silent mode and outputFormat is 'text' + log(level, message); + } + }; + + // Keep the mcpLog check for specific MCP context logging + if (mcpLog) { + mcpLog.info( + `expandTask - reportProgress available: ${!!reportProgress}, session available: ${!!session}` + ); + } + + try { + // Read the tasks.json file + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + throw new Error('Invalid or missing tasks.json'); + } + + // Find the task + const task = data.tasks.find((t) => t.id === parseInt(taskId, 10)); + if (!task) { + throw new Error(`Task with ID ${taskId} not found`); + } + + report(`Expanding task ${taskId}: ${task.title}`); + + // If the task already has subtasks and force flag is not set, return the existing subtasks + if (task.subtasks && task.subtasks.length > 0) { + report(`Task ${taskId} already has ${task.subtasks.length} subtasks`); + return task; + } + + // Determine the number of subtasks to generate + let subtaskCount = parseInt(numSubtasks, 10) || CONFIG.defaultSubtasks; + + // Check if we have a complexity analysis for this task + let taskAnalysis = null; + try { + const reportPath = 'scripts/task-complexity-report.json'; + if (fs.existsSync(reportPath)) { + const report = readJSON(reportPath); + if (report && report.complexityAnalysis) { + taskAnalysis = report.complexityAnalysis.find( + (a) => a.taskId === task.id + ); + } + } + } catch (error) { + report(`Could not read complexity analysis: ${error.message}`, 'warn'); + } + + // Use recommended subtask count if available + if (taskAnalysis) { + report( + `Found complexity analysis for task ${taskId}: Score ${taskAnalysis.complexityScore}/10` + ); + + // Use recommended number of subtasks if available + if ( + taskAnalysis.recommendedSubtasks && + subtaskCount === CONFIG.defaultSubtasks + ) { + subtaskCount = taskAnalysis.recommendedSubtasks; + report(`Using recommended number of subtasks: ${subtaskCount}`); + } + + // Use the expansion prompt from analysis as additional context + if (taskAnalysis.expansionPrompt && !additionalContext) { + additionalContext = taskAnalysis.expansionPrompt; + report(`Using expansion prompt from complexity analysis`); + } + } + + // Generate subtasks with AI + let generatedSubtasks = []; + + // Only create loading indicator if not in silent mode and no mcpLog (CLI mode) + let loadingIndicator = null; + if (!isSilentMode() && !mcpLog) { + loadingIndicator = startLoadingIndicator( + useResearch + ? 'Generating research-backed subtasks...' + : 'Generating subtasks...' + ); + } + + try { + // Determine the next subtask ID + const nextSubtaskId = 1; + + if (useResearch) { + // Use Perplexity for research-backed subtasks + if (!perplexity) { + report( + 'Perplexity AI is not available. Falling back to Claude AI.', + 'warn' + ); + useResearch = false; + } else { + report('Using Perplexity for research-backed subtasks'); + generatedSubtasks = await generateSubtasksWithPerplexity( + task, + subtaskCount, + nextSubtaskId, + additionalContext, + { reportProgress, mcpLog, silentMode: isSilentMode(), session } + ); + } + } + + if (!useResearch) { + report('Using regular Claude for generating subtasks'); + + // Use our getConfiguredAnthropicClient function instead of getAnthropicClient + const client = getConfiguredAnthropicClient(session); + + // Build the system prompt + const systemPrompt = `You are an AI assistant helping with task breakdown for software development. You need to break down a high-level task into ${subtaskCount} specific subtasks that can be implemented one by one. Subtasks should: @@ -2102,11 +2587,12 @@ For each subtask, provide: - Testing approach 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 ${subtaskCount} specific, actionable subtasks: + + const contextPrompt = additionalContext + ? `\n\nAdditional context to consider: ${additionalContext}` + : ''; + + const userPrompt = `Please break down this task into ${subtaskCount} specific, actionable subtasks: Task ID: ${task.id} Title: ${task.title} @@ -2127,51 +2613,56 @@ Return exactly ${subtaskCount} 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.`; - - // Prepare API parameters - const apiParams = { - model: session?.env?.ANTHROPIC_MODEL || CONFIG.model, - max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, - temperature: session?.env?.TEMPERATURE || CONFIG.temperature, - system: systemPrompt, - messages: [{ role: "user", content: userPrompt }] - }; - - // Call the streaming API using our helper - const responseText = await _handleAnthropicStream( - client, - apiParams, - { reportProgress, mcpLog, silentMode: isSilentMode() }, // Pass isSilentMode() directly - !isSilentMode() // Only use CLI mode if not in silent mode - ); - - // Parse the subtasks from the response - generatedSubtasks = parseSubtasksFromText(responseText, nextSubtaskId, subtaskCount, task.id); - } - - // Add the generated subtasks to the task - task.subtasks = generatedSubtasks; - - // Write the updated tasks back to the file - writeJSON(tasksPath, data); - - // Generate the individual task files - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); - - return task; - } catch (error) { - report(`Error expanding task: ${error.message}`, 'error'); - throw error; - } finally { - // Always stop the loading indicator if we created one - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - } - } - } catch (error) { - report(`Error expanding task: ${error.message}`, 'error'); - throw error; - } + + // Prepare API parameters + const apiParams = { + model: session?.env?.ANTHROPIC_MODEL || CONFIG.model, + max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, + temperature: session?.env?.TEMPERATURE || CONFIG.temperature, + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }] + }; + + // Call the streaming API using our helper + const responseText = await _handleAnthropicStream( + client, + apiParams, + { reportProgress, mcpLog, silentMode: isSilentMode() }, // Pass isSilentMode() directly + !isSilentMode() // Only use CLI mode if not in silent mode + ); + + // Parse the subtasks from the response + generatedSubtasks = parseSubtasksFromText( + responseText, + nextSubtaskId, + subtaskCount, + task.id + ); + } + + // Add the generated subtasks to the task + task.subtasks = generatedSubtasks; + + // Write the updated tasks back to the file + writeJSON(tasksPath, data); + + // Generate the individual task files + await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + + return task; + } catch (error) { + report(`Error expanding task: ${error.message}`, 'error'); + throw error; + } finally { + // Always stop the loading indicator if we created one + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + } + } + } catch (error) { + report(`Error expanding task: ${error.message}`, 'error'); + throw error; + } } /** @@ -2187,225 +2678,283 @@ Note on dependencies: Subtasks can depend on other subtasks with lower IDs. Use * @param {Object} options.session - Session object from MCP * @param {string} outputFormat - Output format (text or json) */ -async function expandAllTasks(tasksPath, numSubtasks = CONFIG.defaultSubtasks, useResearch = false, additionalContext = '', forceFlag = false, { reportProgress, mcpLog, session } = {}, outputFormat = 'text') { - // Create custom reporter that checks for MCP log and silent mode - const report = (message, level = 'info') => { - if (mcpLog) { - mcpLog[level](message); - } else if (!isSilentMode() && outputFormat === 'text') { - // Only log to console if not in silent mode and outputFormat is 'text' - log(level, message); - } - }; +async function expandAllTasks( + tasksPath, + numSubtasks = CONFIG.defaultSubtasks, + useResearch = false, + additionalContext = '', + forceFlag = false, + { reportProgress, mcpLog, session } = {}, + outputFormat = 'text' +) { + // Create custom reporter that checks for MCP log and silent mode + const report = (message, level = 'info') => { + if (mcpLog) { + mcpLog[level](message); + } else if (!isSilentMode() && outputFormat === 'text') { + // Only log to console if not in silent mode and outputFormat is 'text' + log(level, message); + } + }; - // Only display banner and UI elements for text output (CLI) - if (outputFormat === 'text') { - displayBanner(); - } + // Only display banner and UI elements for text output (CLI) + if (outputFormat === 'text') { + displayBanner(); + } - // Parse numSubtasks as integer if it's a string - if (typeof numSubtasks === 'string') { - numSubtasks = parseInt(numSubtasks, 10); - if (isNaN(numSubtasks)) { - numSubtasks = CONFIG.defaultSubtasks; - } - } + // Parse numSubtasks as integer if it's a string + if (typeof numSubtasks === 'string') { + numSubtasks = parseInt(numSubtasks, 10); + if (isNaN(numSubtasks)) { + numSubtasks = CONFIG.defaultSubtasks; + } + } - report(`Expanding all pending tasks with ${numSubtasks} subtasks each...`); - - // Load tasks - let data; - try { - data = readJSON(tasksPath); - if (!data || !data.tasks) { - throw new Error('No valid tasks found'); - } - } catch (error) { - report(`Error loading tasks: ${error.message}`, 'error'); - throw error; - } + report(`Expanding all pending tasks with ${numSubtasks} subtasks each...`); - // Get all tasks that are pending/in-progress and don't have subtasks (or force regeneration) - const tasksToExpand = data.tasks.filter(task => - (task.status === 'pending' || task.status === 'in-progress') && - (!task.subtasks || task.subtasks.length === 0 || forceFlag) - ); + // Load tasks + let data; + try { + data = readJSON(tasksPath); + if (!data || !data.tasks) { + throw new Error('No valid tasks found'); + } + } catch (error) { + report(`Error loading tasks: ${error.message}`, 'error'); + throw error; + } - if (tasksToExpand.length === 0) { - report('No tasks eligible for expansion. Tasks should be in pending/in-progress status and not have subtasks already.', 'info'); - - // Return structured result for MCP - return { - success: true, - expandedCount: 0, - tasksToExpand: 0, - message: 'No tasks eligible for expansion' - }; - } + // Get all tasks that are pending/in-progress and don't have subtasks (or force regeneration) + const tasksToExpand = data.tasks.filter( + (task) => + (task.status === 'pending' || task.status === 'in-progress') && + (!task.subtasks || task.subtasks.length === 0 || forceFlag) + ); - report(`Found ${tasksToExpand.length} tasks to expand`); + if (tasksToExpand.length === 0) { + report( + 'No tasks eligible for expansion. Tasks should be in pending/in-progress status and not have subtasks already.', + 'info' + ); - // Check if we have a complexity report to prioritize complex tasks - let complexityReport; - const reportPath = path.join(path.dirname(tasksPath), '../scripts/task-complexity-report.json'); - if (fs.existsSync(reportPath)) { - try { - complexityReport = readJSON(reportPath); - report('Using complexity analysis to prioritize tasks'); - } catch (error) { - report(`Could not read complexity report: ${error.message}`, 'warn'); - } - } + // Return structured result for MCP + return { + success: true, + expandedCount: 0, + tasksToExpand: 0, + message: 'No tasks eligible for expansion' + }; + } - // Only create loading indicator if not in silent mode and outputFormat is 'text' - let loadingIndicator = null; - if (!isSilentMode() && outputFormat === 'text') { - loadingIndicator = startLoadingIndicator(`Expanding ${tasksToExpand.length} tasks with ${numSubtasks} subtasks each`); - } + report(`Found ${tasksToExpand.length} tasks to expand`); - let expandedCount = 0; - try { - // Sort tasks by complexity if report exists, otherwise by ID - if (complexityReport && complexityReport.complexityAnalysis) { - report('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; - }); - } + // Check if we have a complexity report to prioritize complex tasks + let complexityReport; + const reportPath = path.join( + path.dirname(tasksPath), + '../scripts/task-complexity-report.json' + ); + if (fs.existsSync(reportPath)) { + try { + complexityReport = readJSON(reportPath); + report('Using complexity analysis to prioritize tasks'); + } catch (error) { + report(`Could not read complexity report: ${error.message}`, 'warn'); + } + } - // Process each task - for (const task of tasksToExpand) { - if (loadingIndicator && outputFormat === 'text') { - loadingIndicator.text = `Expanding task ${task.id}: ${truncate(task.title, 30)} (${expandedCount + 1}/${tasksToExpand.length})`; - } - - // Report progress to MCP if available - if (reportProgress) { - reportProgress({ - status: 'processing', - current: expandedCount + 1, - total: tasksToExpand.length, - message: `Expanding task ${task.id}: ${truncate(task.title, 30)}` - }); - } - - report(`Expanding task ${task.id}: ${truncate(task.title, 50)}`); - - // Check if task already has subtasks and forceFlag is enabled - if (task.subtasks && task.subtasks.length > 0 && forceFlag) { - report(`Task ${task.id} already has ${task.subtasks.length} subtasks. Clearing them for regeneration.`); - task.subtasks = []; - } - - try { - // Get complexity analysis for this task if available - let taskAnalysis; - if (complexityReport && complexityReport.complexityAnalysis) { - taskAnalysis = complexityReport.complexityAnalysis.find(a => a.taskId === task.id); - } - - let thisNumSubtasks = numSubtasks; - - // Use recommended number of subtasks from complexity analysis if available - if (taskAnalysis && taskAnalysis.recommendedSubtasks) { - report(`Using recommended ${taskAnalysis.recommendedSubtasks} subtasks based on complexity score ${taskAnalysis.complexityScore}/10 for task ${task.id}`); - thisNumSubtasks = taskAnalysis.recommendedSubtasks; - } - - // Generate prompt for subtask creation based on task details - const prompt = generateSubtaskPrompt(task, thisNumSubtasks, additionalContext, taskAnalysis); - - // Use AI to generate subtasks - const aiResponse = await getSubtasksFromAI(prompt, useResearch, session, mcpLog); - - if (aiResponse && aiResponse.subtasks) { - // Process and add the subtasks to the task - task.subtasks = aiResponse.subtasks.map((subtask, index) => ({ - id: index + 1, - title: subtask.title, - description: subtask.description, - status: 'pending', - dependencies: subtask.dependencies || [], - details: subtask.details || '' - })); - - report(`Added ${task.subtasks.length} subtasks to task ${task.id}`); - expandedCount++; - } else { - report(`Failed to generate subtasks for task ${task.id}`, 'error'); - } - } catch (error) { - report(`Error expanding task ${task.id}: ${error.message}`, 'error'); - } - - // Small delay to prevent rate limiting - await new Promise(resolve => setTimeout(resolve, 100)); - } - - // Save the updated tasks - writeJSON(tasksPath, data); - - // Generate task files - if (outputFormat === 'text') { - // Only perform file generation for CLI (text) mode - const outputDir = path.dirname(tasksPath); - await generateTaskFiles(tasksPath, outputDir); - } - - // Return structured result for MCP - return { - success: true, - expandedCount, - tasksToExpand: tasksToExpand.length, - message: `Successfully expanded ${expandedCount} out of ${tasksToExpand.length} tasks` - }; - } catch (error) { - report(`Error expanding tasks: ${error.message}`, 'error'); - throw error; - } finally { - // Stop the loading indicator if it was created - if (loadingIndicator && outputFormat === 'text') { - stopLoadingIndicator(loadingIndicator); - } - - // Final progress report - if (reportProgress) { - reportProgress({ - status: 'completed', - current: expandedCount, - total: tasksToExpand.length, - message: `Completed expanding ${expandedCount} out of ${tasksToExpand.length} tasks` - }); - } - - // Display completion message for CLI mode - if (outputFormat === 'text') { - console.log(boxen( - chalk.white.bold(`Task Expansion Completed`) + '\n\n' + - chalk.white(`Expanded ${expandedCount} out of ${tasksToExpand.length} tasks`) + '\n' + - chalk.white(`Each task now has detailed subtasks to guide implementation`), - { padding: 1, borderColor: 'green', borderStyle: 'round', margin: { top: 1 } } - )); - - // Suggest next actions - if (expandedCount > 0) { - console.log(chalk.bold('\nNext Steps:')); - console.log(chalk.cyan(`1. Run ${chalk.yellow('task-master list --with-subtasks')} to see all tasks with their subtasks`)); - console.log(chalk.cyan(`2. Run ${chalk.yellow('task-master next')} to find the next task to work on`)); - console.log(chalk.cyan(`3. Run ${chalk.yellow('task-master set-status --id=<taskId> --status=in-progress')} to start working on a task`)); - } - } - } + // Only create loading indicator if not in silent mode and outputFormat is 'text' + let loadingIndicator = null; + if (!isSilentMode() && outputFormat === 'text') { + loadingIndicator = startLoadingIndicator( + `Expanding ${tasksToExpand.length} tasks with ${numSubtasks} subtasks each` + ); + } + + let expandedCount = 0; + try { + // Sort tasks by complexity if report exists, otherwise by ID + if (complexityReport && complexityReport.complexityAnalysis) { + report('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; + }); + } + + // Process each task + for (const task of tasksToExpand) { + if (loadingIndicator && outputFormat === 'text') { + loadingIndicator.text = `Expanding task ${task.id}: ${truncate(task.title, 30)} (${expandedCount + 1}/${tasksToExpand.length})`; + } + + // Report progress to MCP if available + if (reportProgress) { + reportProgress({ + status: 'processing', + current: expandedCount + 1, + total: tasksToExpand.length, + message: `Expanding task ${task.id}: ${truncate(task.title, 30)}` + }); + } + + report(`Expanding task ${task.id}: ${truncate(task.title, 50)}`); + + // Check if task already has subtasks and forceFlag is enabled + if (task.subtasks && task.subtasks.length > 0 && forceFlag) { + report( + `Task ${task.id} already has ${task.subtasks.length} subtasks. Clearing them for regeneration.` + ); + task.subtasks = []; + } + + try { + // Get complexity analysis for this task if available + let taskAnalysis; + if (complexityReport && complexityReport.complexityAnalysis) { + taskAnalysis = complexityReport.complexityAnalysis.find( + (a) => a.taskId === task.id + ); + } + + let thisNumSubtasks = numSubtasks; + + // Use recommended number of subtasks from complexity analysis if available + if (taskAnalysis && taskAnalysis.recommendedSubtasks) { + report( + `Using recommended ${taskAnalysis.recommendedSubtasks} subtasks based on complexity score ${taskAnalysis.complexityScore}/10 for task ${task.id}` + ); + thisNumSubtasks = taskAnalysis.recommendedSubtasks; + } + + // Generate prompt for subtask creation based on task details + const prompt = generateSubtaskPrompt( + task, + thisNumSubtasks, + additionalContext, + taskAnalysis + ); + + // Use AI to generate subtasks + const aiResponse = await getSubtasksFromAI( + prompt, + useResearch, + session, + mcpLog + ); + + if (aiResponse && aiResponse.subtasks) { + // Process and add the subtasks to the task + task.subtasks = aiResponse.subtasks.map((subtask, index) => ({ + id: index + 1, + title: subtask.title, + description: subtask.description, + status: 'pending', + dependencies: subtask.dependencies || [], + details: subtask.details || '' + })); + + report(`Added ${task.subtasks.length} subtasks to task ${task.id}`); + expandedCount++; + } else { + report(`Failed to generate subtasks for task ${task.id}`, 'error'); + } + } catch (error) { + report(`Error expanding task ${task.id}: ${error.message}`, 'error'); + } + + // Small delay to prevent rate limiting + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + // Save the updated tasks + writeJSON(tasksPath, data); + + // Generate task files + if (outputFormat === 'text') { + // Only perform file generation for CLI (text) mode + const outputDir = path.dirname(tasksPath); + await generateTaskFiles(tasksPath, outputDir); + } + + // Return structured result for MCP + return { + success: true, + expandedCount, + tasksToExpand: tasksToExpand.length, + message: `Successfully expanded ${expandedCount} out of ${tasksToExpand.length} tasks` + }; + } catch (error) { + report(`Error expanding tasks: ${error.message}`, 'error'); + throw error; + } finally { + // Stop the loading indicator if it was created + if (loadingIndicator && outputFormat === 'text') { + stopLoadingIndicator(loadingIndicator); + } + + // Final progress report + if (reportProgress) { + reportProgress({ + status: 'completed', + current: expandedCount, + total: tasksToExpand.length, + message: `Completed expanding ${expandedCount} out of ${tasksToExpand.length} tasks` + }); + } + + // Display completion message for CLI mode + if (outputFormat === 'text') { + console.log( + boxen( + chalk.white.bold(`Task Expansion Completed`) + + '\n\n' + + chalk.white( + `Expanded ${expandedCount} out of ${tasksToExpand.length} tasks` + ) + + '\n' + + chalk.white( + `Each task now has detailed subtasks to guide implementation` + ), + { + padding: 1, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + + // Suggest next actions + if (expandedCount > 0) { + console.log(chalk.bold('\nNext Steps:')); + console.log( + chalk.cyan( + `1. Run ${chalk.yellow('task-master list --with-subtasks')} to see all tasks with their subtasks` + ) + ); + console.log( + chalk.cyan( + `2. Run ${chalk.yellow('task-master next')} to find the next task to work on` + ) + ); + console.log( + chalk.cyan( + `3. Run ${chalk.yellow('task-master set-status --id=<taskId> --status=in-progress')} to start working on a task` + ) + ); + } + } + } } /** @@ -2414,104 +2963,132 @@ async function expandAllTasks(tasksPath, numSubtasks = CONFIG.defaultSubtasks, u * @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 } + }) + ); + } } /** @@ -2527,288 +3104,394 @@ function clearSubtasks(tasksPath, taskIds) { * @param {Object} customEnv - Custom environment variables (optional) * @returns {number} The new task ID */ -async function addTask(tasksPath, prompt, dependencies = [], priority = 'medium', { reportProgress, mcpLog, session } = {}, outputFormat = 'text', customEnv = null) { - let loadingIndicator = null; // Keep indicator variable accessible +async function addTask( + tasksPath, + prompt, + dependencies = [], + priority = 'medium', + { reportProgress, mcpLog, session } = {}, + outputFormat = 'text', + customEnv = null +) { + let loadingIndicator = null; // Keep indicator variable accessible - try { - // Only display banner and UI elements for text output (CLI) - if (outputFormat === 'text') { - displayBanner(); - - console.log(boxen( - chalk.white.bold(`Creating New Task`), - { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 1 } } - )); - } - - // Read the existing tasks - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - log('error', "Invalid or missing tasks.json."); - throw new Error("Invalid or missing tasks.json."); - } - - // Find the highest task ID to determine the next ID - const highestId = Math.max(...data.tasks.map(t => t.id)); - const newTaskId = highestId + 1; - - // Only show UI box for CLI mode - if (outputFormat === 'text') { - 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 context string for task creation prompt - 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')}`; - } - - // Start the loading indicator - only for text mode - if (outputFormat === 'text') { - loadingIndicator = startLoadingIndicator('Generating new task with Claude AI...'); - } + try { + // Only display banner and UI elements for text output (CLI) + if (outputFormat === 'text') { + displayBanner(); - try { - // Import the AI services - explicitly importing here to avoid circular dependencies - const { _handleAnthropicStream, _buildAddTaskPrompt, parseTaskJsonResponse, getAvailableAIModel } = await import('./ai-services.js'); - - // Initialize model state variables - let claudeOverloaded = false; - let modelAttempts = 0; - const maxModelAttempts = 2; // Try up to 2 models before giving up - let taskData = null; - - // Loop through model attempts - while (modelAttempts < maxModelAttempts && !taskData) { - modelAttempts++; // Increment attempt counter - const isLastAttempt = modelAttempts >= maxModelAttempts; - let modelType = null; // Track which model we're using - - try { - // Get the best available model based on our current state - const result = getAvailableAIModel({ - claudeOverloaded, - requiresResearch: false // We're not using the research flag here - }); - modelType = result.type; - const client = result.client; - - log('info', `Attempt ${modelAttempts}/${maxModelAttempts}: Generating task using ${modelType}`); - - // Update loading indicator text - only for text output - if (outputFormat === 'text') { - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); // Stop previous indicator - } - loadingIndicator = startLoadingIndicator(`Attempt ${modelAttempts}: Using ${modelType.toUpperCase()}...`); - } - - // Build the prompts using the helper - const { systemPrompt, userPrompt } = _buildAddTaskPrompt(prompt, contextTasks, { newTaskId }); - - if (modelType === 'perplexity') { - // Use Perplexity AI - const perplexityModel = process.env.PERPLEXITY_MODEL || session?.env?.PERPLEXITY_MODEL || 'sonar-pro'; - const response = await client.chat.completions.create({ - model: perplexityModel, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt } - ], - temperature: parseFloat(process.env.TEMPERATURE || session?.env?.TEMPERATURE || CONFIG.temperature), - max_tokens: parseInt(process.env.MAX_TOKENS || session?.env?.MAX_TOKENS || CONFIG.maxTokens), - }); - - const responseText = response.choices[0].message.content; - taskData = parseTaskJsonResponse(responseText); - } else { - // Use Claude (default) - // Prepare API parameters - const apiParams = { - model: session?.env?.ANTHROPIC_MODEL || CONFIG.model || customEnv?.ANTHROPIC_MODEL, - max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens || customEnv?.MAX_TOKENS, - temperature: session?.env?.TEMPERATURE || CONFIG.temperature || customEnv?.TEMPERATURE, - system: systemPrompt, - messages: [{ role: "user", content: userPrompt }] - }; - - // Call the streaming API using our helper - try { - const fullResponse = await _handleAnthropicStream( - client, - apiParams, - { reportProgress, mcpLog }, - outputFormat === 'text' // CLI mode flag - ); - - log('debug', `Streaming response length: ${fullResponse.length} characters`); - - // Parse the response using our helper - taskData = parseTaskJsonResponse(fullResponse); - } catch (streamError) { - // Process stream errors explicitly - log('error', `Stream error: ${streamError.message}`); - - // Check if this is an overload error - let isOverload = false; - // Check 1: SDK specific property - if (streamError.type === 'overloaded_error') { - isOverload = true; - } - // Check 2: Check nested error property - else if (streamError.error?.type === 'overloaded_error') { - isOverload = true; - } - // Check 3: Check status code - else if (streamError.status === 429 || streamError.status === 529) { - isOverload = true; - } - // Check 4: Check message string - else if (streamError.message?.toLowerCase().includes('overloaded')) { - isOverload = true; - } - - if (isOverload) { - claudeOverloaded = true; - log('warn', 'Claude overloaded. Will attempt fallback model if available.'); - // Throw to continue to next model attempt - throw new Error('Claude overloaded'); - } else { - // Re-throw non-overload errors - throw streamError; - } - } - } - - // If we got here without errors and have task data, we're done - if (taskData) { - log('info', `Successfully generated task data using ${modelType} on attempt ${modelAttempts}`); - break; - } - - } catch (modelError) { - const failedModel = modelType || 'unknown model'; - log('warn', `Attempt ${modelAttempts} failed using ${failedModel}: ${modelError.message}`); - - // Continue to next attempt if we have more attempts and this was specifically an overload error - const wasOverload = modelError.message?.toLowerCase().includes('overload'); - - if (wasOverload && !isLastAttempt) { - if (modelType === 'claude') { - claudeOverloaded = true; - log('info', 'Will attempt with Perplexity AI next'); - } - continue; // Continue to next attempt - } else if (isLastAttempt) { - log('error', `Final attempt (${modelAttempts}/${maxModelAttempts}) failed. No fallback possible.`); - throw modelError; // Re-throw on last attempt - } else { - throw modelError; // Re-throw for non-overload errors - } - } - } - - // If we don't have task data after all attempts, throw an error - if (!taskData) { - throw new Error('Failed to generate task data after all model attempts'); - } - - // 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); - - // Only show success messages for text mode (CLI) - if (outputFormat === 'text') { - // 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) { - // Log the specific error during generation/processing - log('error', "Error generating or processing task:", error.message); - // Re-throw the error to be caught by the outer catch block - throw error; - } finally { - // **** THIS IS THE KEY CHANGE **** - // Ensure the loading indicator is stopped if it was started - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - // Optional: Clear the line in CLI mode for a cleaner output - if (outputFormat === 'text' && process.stdout.isTTY) { - try { - // Use dynamic import for readline as it might not always be needed - const readline = await import('readline'); - readline.clearLine(process.stdout, 0); - readline.cursorTo(process.stdout, 0); - } catch (readlineError) { - log('debug', 'Could not clear readline for indicator cleanup:', readlineError.message); - } - } - loadingIndicator = null; // Reset indicator variable - } - } - } catch (error) { - // General error handling for the whole function - // The finally block above already handled the indicator if it was started - log('error', "Error adding task:", error.message); - throw error; // Throw error instead of exiting the process - } + console.log( + boxen(chalk.white.bold(`Creating New Task`), { + padding: 1, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + }) + ); + } + + // Read the existing tasks + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + log('error', 'Invalid or missing tasks.json.'); + throw new Error('Invalid or missing tasks.json.'); + } + + // Find the highest task ID to determine the next ID + const highestId = Math.max(...data.tasks.map((t) => t.id)); + const newTaskId = highestId + 1; + + // Only show UI box for CLI mode + if (outputFormat === 'text') { + 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 context string for task creation prompt + 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')}`; + } + + // Start the loading indicator - only for text mode + if (outputFormat === 'text') { + loadingIndicator = startLoadingIndicator( + 'Generating new task with Claude AI...' + ); + } + + try { + // Import the AI services - explicitly importing here to avoid circular dependencies + const { + _handleAnthropicStream, + _buildAddTaskPrompt, + parseTaskJsonResponse, + getAvailableAIModel + } = await import('./ai-services.js'); + + // Initialize model state variables + let claudeOverloaded = false; + let modelAttempts = 0; + const maxModelAttempts = 2; // Try up to 2 models before giving up + let taskData = null; + + // Loop through model attempts + while (modelAttempts < maxModelAttempts && !taskData) { + modelAttempts++; // Increment attempt counter + const isLastAttempt = modelAttempts >= maxModelAttempts; + let modelType = null; // Track which model we're using + + try { + // Get the best available model based on our current state + const result = getAvailableAIModel({ + claudeOverloaded, + requiresResearch: false // We're not using the research flag here + }); + modelType = result.type; + const client = result.client; + + log( + 'info', + `Attempt ${modelAttempts}/${maxModelAttempts}: Generating task using ${modelType}` + ); + + // Update loading indicator text - only for text output + if (outputFormat === 'text') { + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); // Stop previous indicator + } + loadingIndicator = startLoadingIndicator( + `Attempt ${modelAttempts}: Using ${modelType.toUpperCase()}...` + ); + } + + // Build the prompts using the helper + const { systemPrompt, userPrompt } = _buildAddTaskPrompt( + prompt, + contextTasks, + { newTaskId } + ); + + if (modelType === 'perplexity') { + // Use Perplexity AI + const perplexityModel = + process.env.PERPLEXITY_MODEL || + session?.env?.PERPLEXITY_MODEL || + 'sonar-pro'; + const response = await client.chat.completions.create({ + model: perplexityModel, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + temperature: parseFloat( + process.env.TEMPERATURE || + session?.env?.TEMPERATURE || + CONFIG.temperature + ), + max_tokens: parseInt( + process.env.MAX_TOKENS || + session?.env?.MAX_TOKENS || + CONFIG.maxTokens + ) + }); + + const responseText = response.choices[0].message.content; + taskData = parseTaskJsonResponse(responseText); + } else { + // Use Claude (default) + // Prepare API parameters + const apiParams = { + model: + session?.env?.ANTHROPIC_MODEL || + CONFIG.model || + customEnv?.ANTHROPIC_MODEL, + max_tokens: + session?.env?.MAX_TOKENS || + CONFIG.maxTokens || + customEnv?.MAX_TOKENS, + temperature: + session?.env?.TEMPERATURE || + CONFIG.temperature || + customEnv?.TEMPERATURE, + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }] + }; + + // Call the streaming API using our helper + try { + const fullResponse = await _handleAnthropicStream( + client, + apiParams, + { reportProgress, mcpLog }, + outputFormat === 'text' // CLI mode flag + ); + + log( + 'debug', + `Streaming response length: ${fullResponse.length} characters` + ); + + // Parse the response using our helper + taskData = parseTaskJsonResponse(fullResponse); + } catch (streamError) { + // Process stream errors explicitly + log('error', `Stream error: ${streamError.message}`); + + // Check if this is an overload error + let isOverload = false; + // Check 1: SDK specific property + if (streamError.type === 'overloaded_error') { + isOverload = true; + } + // Check 2: Check nested error property + else if (streamError.error?.type === 'overloaded_error') { + isOverload = true; + } + // Check 3: Check status code + else if ( + streamError.status === 429 || + streamError.status === 529 + ) { + isOverload = true; + } + // Check 4: Check message string + else if ( + streamError.message?.toLowerCase().includes('overloaded') + ) { + isOverload = true; + } + + if (isOverload) { + claudeOverloaded = true; + log( + 'warn', + 'Claude overloaded. Will attempt fallback model if available.' + ); + // Throw to continue to next model attempt + throw new Error('Claude overloaded'); + } else { + // Re-throw non-overload errors + throw streamError; + } + } + } + + // If we got here without errors and have task data, we're done + if (taskData) { + log( + 'info', + `Successfully generated task data using ${modelType} on attempt ${modelAttempts}` + ); + break; + } + } catch (modelError) { + const failedModel = modelType || 'unknown model'; + log( + 'warn', + `Attempt ${modelAttempts} failed using ${failedModel}: ${modelError.message}` + ); + + // Continue to next attempt if we have more attempts and this was specifically an overload error + const wasOverload = modelError.message + ?.toLowerCase() + .includes('overload'); + + if (wasOverload && !isLastAttempt) { + if (modelType === 'claude') { + claudeOverloaded = true; + log('info', 'Will attempt with Perplexity AI next'); + } + continue; // Continue to next attempt + } else if (isLastAttempt) { + log( + 'error', + `Final attempt (${modelAttempts}/${maxModelAttempts}) failed. No fallback possible.` + ); + throw modelError; // Re-throw on last attempt + } else { + throw modelError; // Re-throw for non-overload errors + } + } + } + + // If we don't have task data after all attempts, throw an error + if (!taskData) { + throw new Error( + 'Failed to generate task data after all model attempts' + ); + } + + // 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); + + // Only show success messages for text mode (CLI) + if (outputFormat === 'text') { + // 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) { + // Log the specific error during generation/processing + log('error', 'Error generating or processing task:', error.message); + // Re-throw the error to be caught by the outer catch block + throw error; + } finally { + // **** THIS IS THE KEY CHANGE **** + // Ensure the loading indicator is stopped if it was started + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + // Optional: Clear the line in CLI mode for a cleaner output + if (outputFormat === 'text' && process.stdout.isTTY) { + try { + // Use dynamic import for readline as it might not always be needed + const readline = await import('readline'); + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + } catch (readlineError) { + log( + 'debug', + 'Could not clear readline for indicator cleanup:', + readlineError.message + ); + } + } + loadingIndicator = null; // Reset indicator variable + } + } + } catch (error) { + // General error handling for the whole function + // The finally block above already handled the indicator if it was started + log('error', 'Error adding task:', error.message); + throw error; // Throw error instead of exiting the process + } } /** @@ -2818,126 +3501,150 @@ async function addTask(tasksPath, prompt, dependencies = [], priority = 'medium' * @param {Object} mcpLog - MCP logger object (optional) * @param {Object} session - Session object from MCP server (optional) */ -async function analyzeTaskComplexity(options, { reportProgress, mcpLog, session } = {}) { - 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; - - // Determine output format based on mcpLog presence (simplification) - const outputFormat = mcpLog ? 'json' : 'text'; - - // Create custom reporter that checks for MCP log and silent mode - const reportLog = (message, level = 'info') => { - if (mcpLog) { - mcpLog[level](message); - } else if (!isSilentMode() && outputFormat === 'text') { - // Only log to console if not in silent mode and outputFormat is 'text' - log(level, message); - } - }; - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - console.log(chalk.blue(`Analyzing task complexity and generating expansion recommendations...`)); - } - - try { - // Read tasks.json - reportLog(`Reading tasks from ${tasksPath}...`, 'info'); - - // Use either the filtered tasks data provided by the direct function or read from file - let tasksData; - let originalTaskCount = 0; - - if (options._filteredTasksData) { - // If we have pre-filtered data from the direct function, use it - tasksData = options._filteredTasksData; - originalTaskCount = options._filteredTasksData.tasks.length; - - // Get the original task count from the full tasks array - if (options._filteredTasksData._originalTaskCount) { - originalTaskCount = options._filteredTasksData._originalTaskCount; - } else { - // Try to read the original file to get the count - try { - const originalData = readJSON(tasksPath); - if (originalData && originalData.tasks) { - originalTaskCount = originalData.tasks.length; - } - } catch (e) { - // If we can't read the original file, just use the filtered count - log('warn', `Could not read original tasks file: ${e.message}`); - } - } - } else { - // No filtered data provided, read from file - 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'); - } - - originalTaskCount = tasksData.tasks.length; - - // Filter out tasks with status done/cancelled/deferred - const activeStatuses = ['pending', 'blocked', 'in-progress']; - const filteredTasks = tasksData.tasks.filter(task => - activeStatuses.includes(task.status?.toLowerCase() || 'pending') - ); - - // Store original data before filtering - const skippedCount = originalTaskCount - filteredTasks.length; - - // Update tasksData with filtered tasks - tasksData = { - ...tasksData, - tasks: filteredTasks, - _originalTaskCount: originalTaskCount - }; - } - - // Calculate how many tasks we're skipping (done/cancelled/deferred) - const skippedCount = originalTaskCount - tasksData.tasks.length; - - reportLog(`Found ${originalTaskCount} total tasks in the task file.`, 'info'); - - if (skippedCount > 0) { - const skipMessage = `Skipping ${skippedCount} tasks marked as done/cancelled/deferred. Analyzing ${tasksData.tasks.length} active tasks.`; - reportLog(skipMessage, 'info'); - - // For CLI output, make this more visible - if (outputFormat === 'text') { - console.log(chalk.yellow(skipMessage)); - } - } - - // Prepare the prompt for the LLM - const prompt = generateComplexityAnalysisPrompt(tasksData); - - // Only start loading indicator for text output (CLI) - let loadingIndicator = null; - if (outputFormat === 'text') { - 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 { - reportLog('Using Perplexity AI for research-backed complexity analysis...', 'info'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - 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. +async function analyzeTaskComplexity( + options, + { reportProgress, mcpLog, session } = {} +) { + 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; + + // Determine output format based on mcpLog presence (simplification) + const outputFormat = mcpLog ? 'json' : 'text'; + + // Create custom reporter that checks for MCP log and silent mode + const reportLog = (message, level = 'info') => { + if (mcpLog) { + mcpLog[level](message); + } else if (!isSilentMode() && outputFormat === 'text') { + // Only log to console if not in silent mode and outputFormat is 'text' + log(level, message); + } + }; + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + console.log( + chalk.blue( + `Analyzing task complexity and generating expansion recommendations...` + ) + ); + } + + try { + // Read tasks.json + reportLog(`Reading tasks from ${tasksPath}...`, 'info'); + + // Use either the filtered tasks data provided by the direct function or read from file + let tasksData; + let originalTaskCount = 0; + + if (options._filteredTasksData) { + // If we have pre-filtered data from the direct function, use it + tasksData = options._filteredTasksData; + originalTaskCount = options._filteredTasksData.tasks.length; + + // Get the original task count from the full tasks array + if (options._filteredTasksData._originalTaskCount) { + originalTaskCount = options._filteredTasksData._originalTaskCount; + } else { + // Try to read the original file to get the count + try { + const originalData = readJSON(tasksPath); + if (originalData && originalData.tasks) { + originalTaskCount = originalData.tasks.length; + } + } catch (e) { + // If we can't read the original file, just use the filtered count + log('warn', `Could not read original tasks file: ${e.message}`); + } + } + } else { + // No filtered data provided, read from file + 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'); + } + + originalTaskCount = tasksData.tasks.length; + + // Filter out tasks with status done/cancelled/deferred + const activeStatuses = ['pending', 'blocked', 'in-progress']; + const filteredTasks = tasksData.tasks.filter((task) => + activeStatuses.includes(task.status?.toLowerCase() || 'pending') + ); + + // Store original data before filtering + const skippedCount = originalTaskCount - filteredTasks.length; + + // Update tasksData with filtered tasks + tasksData = { + ...tasksData, + tasks: filteredTasks, + _originalTaskCount: originalTaskCount + }; + } + + // Calculate how many tasks we're skipping (done/cancelled/deferred) + const skippedCount = originalTaskCount - tasksData.tasks.length; + + reportLog( + `Found ${originalTaskCount} total tasks in the task file.`, + 'info' + ); + + if (skippedCount > 0) { + const skipMessage = `Skipping ${skippedCount} tasks marked as done/cancelled/deferred. Analyzing ${tasksData.tasks.length} active tasks.`; + reportLog(skipMessage, 'info'); + + // For CLI output, make this more visible + if (outputFormat === 'text') { + console.log(chalk.yellow(skipMessage)); + } + } + + // Prepare the prompt for the LLM + const prompt = generateComplexityAnalysisPrompt(tasksData); + + // Only start loading indicator for text output (CLI) + let loadingIndicator = null; + if (outputFormat === 'text') { + 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 { + reportLog( + 'Using Perplexity AI for research-backed complexity analysis...', + 'info' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + 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. @@ -2959,552 +3666,759 @@ 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 || session?.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: session?.env?.TEMPERATURE || CONFIG.temperature, - max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, - }); - - // Extract the response text - fullResponse = result.choices[0].message.content; - reportLog('Successfully generated complexity analysis with Perplexity AI', 'success'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - console.log(chalk.green('Successfully generated complexity analysis with Perplexity AI')); - } - - if (streamingInterval) clearInterval(streamingInterval); - - // Stop loading indicator if it was created - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - loadingIndicator = null; - } - - // ALWAYS log the first part of the response for debugging - if (outputFormat === 'text') { - console.log(chalk.gray('Response first 200 chars:')); - console.log(chalk.gray(fullResponse.substring(0, 200))); - } - } catch (perplexityError) { - reportLog(`Falling back to Claude for complexity analysis: ${perplexityError.message}`, 'warn'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - 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() { - // Initialize retry variables for handling Claude overload - let retryAttempt = 0; - const maxRetryAttempts = 2; - let claudeOverloaded = false; - - // Retry loop for Claude API calls - while (retryAttempt < maxRetryAttempts) { - retryAttempt++; - const isLastAttempt = retryAttempt >= maxRetryAttempts; - - try { - reportLog(`Claude API attempt ${retryAttempt}/${maxRetryAttempts}`, 'info'); - - // Update loading indicator for CLI - if (outputFormat === 'text' && loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - loadingIndicator = startLoadingIndicator(`Claude API attempt ${retryAttempt}/${maxRetryAttempts}...`); - } - - // Call the LLM API with streaming - const stream = await anthropic.messages.create({ - max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, - model: modelOverride || CONFIG.model || session?.env?.ANTHROPIC_MODEL, - temperature: session?.env?.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 - only for text output (CLI) - if (outputFormat === 'text') { - 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 (reportProgress) { - await reportProgress({ progress: (fullResponse.length / CONFIG.maxTokens) * 100 }); - } - if (mcpLog) { - mcpLog.info(`Progress: ${fullResponse.length / CONFIG.maxTokens * 100}%`); - } - } - - if (streamingInterval) clearInterval(streamingInterval); - - // Stop loading indicator if it was created - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - loadingIndicator = null; - } - - reportLog("Completed streaming response from Claude API!", 'success'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - console.log(chalk.green("Completed streaming response from Claude API!")); - } - - // Successfully received response, break the retry loop - break; - - } catch (claudeError) { - if (streamingInterval) clearInterval(streamingInterval); - - // Process error to check if it's an overload condition - reportLog(`Error in Claude API call: ${claudeError.message}`, 'error'); - - // Check if this is an overload error - let isOverload = false; - // Check 1: SDK specific property - if (claudeError.type === 'overloaded_error') { - isOverload = true; - } - // Check 2: Check nested error property - else if (claudeError.error?.type === 'overloaded_error') { - isOverload = true; - } - // Check 3: Check status code - else if (claudeError.status === 429 || claudeError.status === 529) { - isOverload = true; - } - // Check 4: Check message string - else if (claudeError.message?.toLowerCase().includes('overloaded')) { - isOverload = true; - } - - if (isOverload) { - claudeOverloaded = true; - reportLog(`Claude overloaded (attempt ${retryAttempt}/${maxRetryAttempts})`, 'warn'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - console.log(chalk.yellow(`Claude overloaded (attempt ${retryAttempt}/${maxRetryAttempts})`)); - } - - if (isLastAttempt) { - reportLog("Maximum retry attempts reached for Claude API", 'error'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - console.log(chalk.red("Maximum retry attempts reached for Claude API")); - } - - // Let the outer error handling take care of it - throw new Error(`Claude API overloaded after ${maxRetryAttempts} attempts`); - } - - // Wait a bit before retrying - adds backoff delay - const retryDelay = 1000 * retryAttempt; // Increases with each retry - reportLog(`Waiting ${retryDelay/1000} seconds before retry...`, 'info'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - console.log(chalk.blue(`Waiting ${retryDelay/1000} seconds before retry...`)); - } - - await new Promise(resolve => setTimeout(resolve, retryDelay)); - continue; // Try again - } else { - // Non-overload error - don't retry - reportLog(`Non-overload Claude API error: ${claudeError.message}`, 'error'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - console.log(chalk.red(`Claude API error: ${claudeError.message}`)); - } - - throw claudeError; // Let the outer error handling take care of it - } - } - } - } - - // Parse the JSON response - reportLog(`Parsing complexity analysis...`, 'info'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - 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]; - reportLog("Extracted JSON from code block", 'info'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - 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]; - reportLog("Extracted JSON array pattern", 'info'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - 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]; - } - reportLog("Extracted JSON from start of array to end", 'info'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - console.log(chalk.blue("Extracted JSON from start of array to end")); - } - } - } - } - - // Log the cleaned response for debugging - only for text output (CLI) - if (outputFormat === 'text') { - 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]; - reportLog("Applied strict JSON array extraction", 'info'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - console.log(chalk.blue("Applied strict JSON array extraction")); - } - } - - try { - complexityAnalysis = JSON.parse(cleanedResponse); - } catch (jsonError) { - reportLog("Initial JSON parsing failed, attempting to fix common JSON issues...", 'warn'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - 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); - reportLog("Successfully parsed JSON after fixing common issues", 'success'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - console.log(chalk.green("Successfully parsed JSON after fixing common issues")); - } - } catch (fixedJsonError) { - reportLog("Failed to parse JSON even after fixes, attempting more aggressive cleanup...", 'error'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - 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) { - reportLog(`Found ${taskMatches.length} task objects, attempting to process individually`, 'info'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - 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) { - reportLog(`Could not parse individual task: ${taskMatch.substring(0, 30)}...`, 'warn'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - console.log(chalk.yellow(`Could not parse individual task: ${taskMatch.substring(0, 30)}...`)); - } - } - } - - if (complexityAnalysis.length > 0) { - reportLog(`Successfully parsed ${complexityAnalysis.length} tasks individually`, 'success'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - 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) { - reportLog("All parsing attempts failed", 'error'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - console.log(chalk.red("All parsing attempts failed")); - } - throw jsonError; // throw the original error - } - } - } - - // Ensure complexityAnalysis is an array - if (!Array.isArray(complexityAnalysis)) { - reportLog('Response is not an array, checking if it contains an array property...', 'warn'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - 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) { - reportLog('Converting object to array...', 'warn'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - 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)); - - // Only show missing task warnings for text output (CLI) - if (missingTaskIds.length > 0 && outputFormat === 'text') { - reportLog(`Missing analysis for ${missingTaskIds.length} tasks: ${missingTaskIds.join(', ')}`, 'warn'); - - if (outputFormat === 'text') { - console.log(chalk.yellow(`Missing analysis for ${missingTaskIds.length} tasks: ${missingTaskIds.join(', ')}`)); - console.log(chalk.blue(`Attempting to analyze missing tasks...`)); - } - - // Handle missing tasks with a basic default analysis - for (const missingId of missingTaskIds) { - const missingTask = tasksData.tasks.find(t => t.id === missingId); - if (missingTask) { - reportLog(`Adding default analysis for task ${missingId}`, 'info'); - - // Create a basic analysis for the missing task - complexityAnalysis.push({ - taskId: missingId, - taskTitle: missingTask.title, - complexityScore: 5, // Default middle complexity - recommendedSubtasks: 3, // Default recommended subtasks - expansionPrompt: `Break down this task with a focus on ${missingTask.title.toLowerCase()}.`, - reasoning: "Automatically added due to missing analysis in API response." - }); - } - } - } - // Create the final report - const finalReport = { - 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 - reportLog(`Writing complexity report to ${outputPath}...`, 'info'); - writeJSON(outputPath, finalReport); - - reportLog(`Task complexity analysis complete. Report written to ${outputPath}`, 'success'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - 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.`); - - // Show next steps suggestions - console.log(boxen( - chalk.white.bold('Suggested Next Steps:') + '\n\n' + - `${chalk.cyan('1.')} Run ${chalk.yellow('task-master complexity-report')} to review detailed findings\n` + - `${chalk.cyan('2.')} Run ${chalk.yellow('task-master expand --id=<id>')} to break down complex tasks\n` + - `${chalk.cyan('3.')} Run ${chalk.yellow('task-master expand --all')} to expand all pending tasks based on complexity`, - { padding: 1, borderColor: 'cyan', borderStyle: 'round', margin: { top: 1 } } - )); - } - - return finalReport; - } catch (error) { - if (streamingInterval) clearInterval(streamingInterval); - - // Stop loading indicator if it was created - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - } - - reportLog(`Error parsing complexity analysis: ${error.message}`, 'error'); - - if (outputFormat === 'text') { - console.error(chalk.red(`Error parsing complexity analysis: ${error.message}`)); - if (CONFIG.debug) { - console.debug(chalk.gray(`Raw response: ${fullResponse.substring(0, 500)}...`)); - } - } - - throw error; - } - } catch (error) { - if (streamingInterval) clearInterval(streamingInterval); - - // Stop loading indicator if it was created - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - } - - reportLog(`Error during AI analysis: ${error.message}`, 'error'); - throw error; - } - } catch (error) { - reportLog(`Error analyzing task complexity: ${error.message}`, 'error'); - - // Only show error UI for text output (CLI) - if (outputFormat === 'text') { - console.error(chalk.red(`Error analyzing task complexity: ${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 analyze-complexity'); - } - - if (CONFIG.debug) { - console.error(error); - } - - process.exit(1); - } else { - throw error; // Re-throw for JSON output - } - } + const result = await perplexity.chat.completions.create({ + model: + process.env.PERPLEXITY_MODEL || + session?.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: session?.env?.TEMPERATURE || CONFIG.temperature, + max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens + }); + + // Extract the response text + fullResponse = result.choices[0].message.content; + reportLog( + 'Successfully generated complexity analysis with Perplexity AI', + 'success' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + console.log( + chalk.green( + 'Successfully generated complexity analysis with Perplexity AI' + ) + ); + } + + if (streamingInterval) clearInterval(streamingInterval); + + // Stop loading indicator if it was created + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + loadingIndicator = null; + } + + // ALWAYS log the first part of the response for debugging + if (outputFormat === 'text') { + console.log(chalk.gray('Response first 200 chars:')); + console.log(chalk.gray(fullResponse.substring(0, 200))); + } + } catch (perplexityError) { + reportLog( + `Falling back to Claude for complexity analysis: ${perplexityError.message}`, + 'warn' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + 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() { + // Initialize retry variables for handling Claude overload + let retryAttempt = 0; + const maxRetryAttempts = 2; + let claudeOverloaded = false; + + // Retry loop for Claude API calls + while (retryAttempt < maxRetryAttempts) { + retryAttempt++; + const isLastAttempt = retryAttempt >= maxRetryAttempts; + + try { + reportLog( + `Claude API attempt ${retryAttempt}/${maxRetryAttempts}`, + 'info' + ); + + // Update loading indicator for CLI + if (outputFormat === 'text' && loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + loadingIndicator = startLoadingIndicator( + `Claude API attempt ${retryAttempt}/${maxRetryAttempts}...` + ); + } + + // Call the LLM API with streaming + const stream = await anthropic.messages.create({ + max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, + model: + modelOverride || CONFIG.model || session?.env?.ANTHROPIC_MODEL, + temperature: session?.env?.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 - only for text output (CLI) + if (outputFormat === 'text') { + 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 (reportProgress) { + await reportProgress({ + progress: (fullResponse.length / CONFIG.maxTokens) * 100 + }); + } + if (mcpLog) { + mcpLog.info( + `Progress: ${(fullResponse.length / CONFIG.maxTokens) * 100}%` + ); + } + } + + if (streamingInterval) clearInterval(streamingInterval); + + // Stop loading indicator if it was created + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + loadingIndicator = null; + } + + reportLog( + 'Completed streaming response from Claude API!', + 'success' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + console.log( + chalk.green('Completed streaming response from Claude API!') + ); + } + + // Successfully received response, break the retry loop + break; + } catch (claudeError) { + if (streamingInterval) clearInterval(streamingInterval); + + // Process error to check if it's an overload condition + reportLog( + `Error in Claude API call: ${claudeError.message}`, + 'error' + ); + + // Check if this is an overload error + let isOverload = false; + // Check 1: SDK specific property + if (claudeError.type === 'overloaded_error') { + isOverload = true; + } + // Check 2: Check nested error property + else if (claudeError.error?.type === 'overloaded_error') { + isOverload = true; + } + // Check 3: Check status code + else if (claudeError.status === 429 || claudeError.status === 529) { + isOverload = true; + } + // Check 4: Check message string + else if ( + claudeError.message?.toLowerCase().includes('overloaded') + ) { + isOverload = true; + } + + if (isOverload) { + claudeOverloaded = true; + reportLog( + `Claude overloaded (attempt ${retryAttempt}/${maxRetryAttempts})`, + 'warn' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + console.log( + chalk.yellow( + `Claude overloaded (attempt ${retryAttempt}/${maxRetryAttempts})` + ) + ); + } + + if (isLastAttempt) { + reportLog( + 'Maximum retry attempts reached for Claude API', + 'error' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + console.log( + chalk.red('Maximum retry attempts reached for Claude API') + ); + } + + // Let the outer error handling take care of it + throw new Error( + `Claude API overloaded after ${maxRetryAttempts} attempts` + ); + } + + // Wait a bit before retrying - adds backoff delay + const retryDelay = 1000 * retryAttempt; // Increases with each retry + reportLog( + `Waiting ${retryDelay / 1000} seconds before retry...`, + 'info' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + console.log( + chalk.blue( + `Waiting ${retryDelay / 1000} seconds before retry...` + ) + ); + } + + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + continue; // Try again + } else { + // Non-overload error - don't retry + reportLog( + `Non-overload Claude API error: ${claudeError.message}`, + 'error' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + console.log( + chalk.red(`Claude API error: ${claudeError.message}`) + ); + } + + throw claudeError; // Let the outer error handling take care of it + } + } + } + } + + // Parse the JSON response + reportLog(`Parsing complexity analysis...`, 'info'); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + 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]; + reportLog('Extracted JSON from code block', 'info'); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + 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]; + reportLog('Extracted JSON array pattern', 'info'); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + 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]; + } + reportLog('Extracted JSON from start of array to end', 'info'); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + console.log( + chalk.blue('Extracted JSON from start of array to end') + ); + } + } + } + } + + // Log the cleaned response for debugging - only for text output (CLI) + if (outputFormat === 'text') { + 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]; + reportLog('Applied strict JSON array extraction', 'info'); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + console.log(chalk.blue('Applied strict JSON array extraction')); + } + } + + try { + complexityAnalysis = JSON.parse(cleanedResponse); + } catch (jsonError) { + reportLog( + 'Initial JSON parsing failed, attempting to fix common JSON issues...', + 'warn' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + 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); + reportLog( + 'Successfully parsed JSON after fixing common issues', + 'success' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + console.log( + chalk.green( + 'Successfully parsed JSON after fixing common issues' + ) + ); + } + } catch (fixedJsonError) { + reportLog( + 'Failed to parse JSON even after fixes, attempting more aggressive cleanup...', + 'error' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + 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) { + reportLog( + `Found ${taskMatches.length} task objects, attempting to process individually`, + 'info' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + 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) { + reportLog( + `Could not parse individual task: ${taskMatch.substring(0, 30)}...`, + 'warn' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + console.log( + chalk.yellow( + `Could not parse individual task: ${taskMatch.substring(0, 30)}...` + ) + ); + } + } + } + + if (complexityAnalysis.length > 0) { + reportLog( + `Successfully parsed ${complexityAnalysis.length} tasks individually`, + 'success' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + 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) { + reportLog('All parsing attempts failed', 'error'); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + console.log(chalk.red('All parsing attempts failed')); + } + throw jsonError; // throw the original error + } + } + } + + // Ensure complexityAnalysis is an array + if (!Array.isArray(complexityAnalysis)) { + reportLog( + 'Response is not an array, checking if it contains an array property...', + 'warn' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + 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 + ) { + reportLog('Converting object to array...', 'warn'); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + 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) + ); + + // Only show missing task warnings for text output (CLI) + if (missingTaskIds.length > 0 && outputFormat === 'text') { + reportLog( + `Missing analysis for ${missingTaskIds.length} tasks: ${missingTaskIds.join(', ')}`, + 'warn' + ); + + if (outputFormat === 'text') { + console.log( + chalk.yellow( + `Missing analysis for ${missingTaskIds.length} tasks: ${missingTaskIds.join(', ')}` + ) + ); + console.log(chalk.blue(`Attempting to analyze missing tasks...`)); + } + + // Handle missing tasks with a basic default analysis + for (const missingId of missingTaskIds) { + const missingTask = tasksData.tasks.find((t) => t.id === missingId); + if (missingTask) { + reportLog( + `Adding default analysis for task ${missingId}`, + 'info' + ); + + // Create a basic analysis for the missing task + complexityAnalysis.push({ + taskId: missingId, + taskTitle: missingTask.title, + complexityScore: 5, // Default middle complexity + recommendedSubtasks: 3, // Default recommended subtasks + expansionPrompt: `Break down this task with a focus on ${missingTask.title.toLowerCase()}.`, + reasoning: + 'Automatically added due to missing analysis in API response.' + }); + } + } + } + + // Create the final report + const finalReport = { + 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 + reportLog(`Writing complexity report to ${outputPath}...`, 'info'); + writeJSON(outputPath, finalReport); + + reportLog( + `Task complexity analysis complete. Report written to ${outputPath}`, + 'success' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + 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.` + ); + + // Show next steps suggestions + console.log( + boxen( + chalk.white.bold('Suggested Next Steps:') + + '\n\n' + + `${chalk.cyan('1.')} Run ${chalk.yellow('task-master complexity-report')} to review detailed findings\n` + + `${chalk.cyan('2.')} Run ${chalk.yellow('task-master expand --id=<id>')} to break down complex tasks\n` + + `${chalk.cyan('3.')} Run ${chalk.yellow('task-master expand --all')} to expand all pending tasks based on complexity`, + { + padding: 1, + borderColor: 'cyan', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + } + + return finalReport; + } catch (error) { + if (streamingInterval) clearInterval(streamingInterval); + + // Stop loading indicator if it was created + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + } + + reportLog( + `Error parsing complexity analysis: ${error.message}`, + 'error' + ); + + if (outputFormat === 'text') { + console.error( + chalk.red(`Error parsing complexity analysis: ${error.message}`) + ); + if (CONFIG.debug) { + console.debug( + chalk.gray(`Raw response: ${fullResponse.substring(0, 500)}...`) + ); + } + } + + throw error; + } + } catch (error) { + if (streamingInterval) clearInterval(streamingInterval); + + // Stop loading indicator if it was created + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + } + + reportLog(`Error during AI analysis: ${error.message}`, 'error'); + throw error; + } + } catch (error) { + reportLog(`Error analyzing task complexity: ${error.message}`, 'error'); + + // Only show error UI for text output (CLI) + if (outputFormat === 'text') { + console.error( + chalk.red(`Error analyzing task complexity: ${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 analyze-complexity' + ); + } + + if (CONFIG.debug) { + console.error(error); + } + + process.exit(1); + } else { + throw error; // Re-throw for JSON output + } + } } /** @@ -3513,49 +4427,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; } /** @@ -3567,118 +4486,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; + } } /** @@ -3690,36 +4632,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; } /** @@ -3730,101 +4672,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; + } } /** @@ -3838,428 +4789,563 @@ async function removeSubtask(tasksPath, subtaskId, convertToTask = false, genera * @param {Object} session - Session object from MCP server (optional) * @returns {Object|null} - The updated subtask or null if update failed */ -async function updateSubtaskById(tasksPath, subtaskId, prompt, useResearch = false, { reportProgress, mcpLog, session } = {} ) { - // Determine output format based on mcpLog presence (simplification) - const outputFormat = mcpLog ? 'json' : 'text'; - - // Create custom reporter that checks for MCP log and silent mode - const report = (message, level = 'info') => { - if (mcpLog) { - mcpLog[level](message); - } else if (!isSilentMode() && outputFormat === 'text') { - // Only log to console if not in silent mode and outputFormat is 'text' - log(level, message); - } - }; +async function updateSubtaskById( + tasksPath, + subtaskId, + prompt, + useResearch = false, + { reportProgress, mcpLog, session } = {} +) { + // Determine output format based on mcpLog presence (simplification) + const outputFormat = mcpLog ? 'json' : 'text'; - let loadingIndicator = null; - try { - report(`Updating subtask ${subtaskId} with prompt: "${prompt}"`, 'info'); - - // 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') { - report(`Subtask ${subtaskId} is already marked as done and cannot be updated`, 'warn'); - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - 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; - } - - // Only show UI elements for text output (CLI) - if (outputFormat === 'text') { - // 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 - only for text output - loadingIndicator = startLoadingIndicator('Generating additional information with AI...'); - } + // Create custom reporter that checks for MCP log and silent mode + const report = (message, level = 'info') => { + if (mcpLog) { + mcpLog[level](message); + } else if (!isSilentMode() && outputFormat === 'text') { + // Only log to console if not in silent mode and outputFormat is 'text' + log(level, message); + } + }; - // Create the system prompt (as before) - const systemPrompt = `You are an AI assistant helping to update software development subtasks with additional information. + let loadingIndicator = null; + try { + report(`Updating subtask ${subtaskId} with prompt: "${prompt}"`, 'info'); + + // 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') { + report( + `Subtask ${subtaskId} is already marked as done and cannot be updated`, + 'warn' + ); + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + 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; + } + + // Only show UI elements for text output (CLI) + if (outputFormat === 'text') { + // 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 - only for text output + 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; - - report(`Attempt ${modelAttempts}/${maxModelAttempts}: Generating subtask info using ${modelType}`, 'info'); - - // Update loading indicator text - only for text output - if (outputFormat === 'text') { - if (loadingIndicator) { - 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 || session?.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 || session?.env?.TEMPERATURE || CONFIG.temperature), - max_tokens: parseInt(process.env.MAX_TOKENS || session?.env?.MAX_TOKENS || CONFIG.maxTokens), - }); - additionalInformation = response.choices[0].message.content.trim(); - } else { // Claude - let responseText = ''; - let streamingInterval = null; - - try { - // Only update streaming indicator for text output - if (outputFormat === 'text') { - 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); - } + report( + `Attempt ${modelAttempts}/${maxModelAttempts}: Generating subtask info using ${modelType}`, + 'info' + ); - // 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 - }); + // Update loading indicator text - only for text output + if (outputFormat === 'text') { + if (loadingIndicator) { + stopLoadingIndicator(loadingIndicator); // Stop previous indicator + } + loadingIndicator = startLoadingIndicator( + `Attempt ${modelAttempts}: Using ${modelType.toUpperCase()}...` + ); + } - for await (const chunk of stream) { - if (chunk.type === 'content_block_delta' && chunk.delta.text) { - responseText += chunk.delta.text; - } - if (reportProgress) { - await reportProgress({ progress: (responseText.length / CONFIG.maxTokens) * 100 }); - } - if (mcpLog) { - mcpLog.info(`Progress: ${responseText.length / CONFIG.maxTokens * 100}%`); - } - } - } finally { - if (streamingInterval) clearInterval(streamingInterval); - // Clear the loading dots line - only for text output - if (outputFormat === 'text') { - const readline = await import('readline'); - readline.cursorTo(process.stdout, 0); - process.stdout.clearLine(0); - } - } + 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.`; - report(`Completed streaming response from Claude API! (Attempt ${modelAttempts})`, 'info'); - additionalInformation = responseText.trim(); - } + if (modelType === 'perplexity') { + // Construct Perplexity payload + const perplexityModel = + process.env.PERPLEXITY_MODEL || + session?.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 || + session?.env?.TEMPERATURE || + CONFIG.temperature + ), + max_tokens: parseInt( + process.env.MAX_TOKENS || + session?.env?.MAX_TOKENS || + CONFIG.maxTokens + ) + }); + additionalInformation = response.choices[0].message.content.trim(); + } else { + // Claude + let responseText = ''; + let streamingInterval = null; - // Success - break the loop - if (additionalInformation) { - report(`Successfully generated information using ${modelType} on attempt ${modelAttempts}.`, 'info'); - break; - } else { - // Handle case where AI gave empty response without erroring - report(`AI (${modelType}) returned empty response on attempt ${modelAttempts}.`, 'warn'); - if (isLastAttempt) { - throw new Error('AI returned empty response after maximum attempts.'); - } - // Allow loop to continue to try another model/attempt if possible - } + try { + // Only update streaming indicator for text output + if (outputFormat === 'text') { + 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); + } - } catch (modelError) { - const failedModel = modelType || (modelError.modelType || 'unknown model'); - report(`Attempt ${modelAttempts} failed using ${failedModel}: ${modelError.message}`, 'warn'); + // 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 + }); - // --- 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 --- + for await (const chunk of stream) { + if (chunk.type === 'content_block_delta' && chunk.delta.text) { + responseText += chunk.delta.text; + } + if (reportProgress) { + await reportProgress({ + progress: (responseText.length / CONFIG.maxTokens) * 100 + }); + } + if (mcpLog) { + mcpLog.info( + `Progress: ${(responseText.length / CONFIG.maxTokens) * 100}%` + ); + } + } + } finally { + if (streamingInterval) clearInterval(streamingInterval); + // Clear the loading dots line - only for text output + if (outputFormat === 'text') { + const readline = await import('readline'); + readline.cursorTo(process.stdout, 0); + process.stdout.clearLine(0); + } + } - if (isOverload) { // Use the result of the check - claudeOverloaded = true; // Mark Claude as overloaded for the *next* potential attempt - if (!isLastAttempt) { - report('Claude overloaded. Will attempt fallback model if available.', 'info'); - // Stop the current indicator before continuing - only for text output - if (outputFormat === 'text' && 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 - report(`Overload error on final attempt (${modelAttempts}/${maxModelAttempts}). No fallback possible.`, 'error'); - // 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. - } - } else { // Error was NOT an overload - // If it's not an overload, throw it immediately to be caught by the outer catch. - report(`Non-overload error on attempt ${modelAttempts}: ${modelError.message}`, 'error'); - throw modelError; // Re-throw non-overload errors immediately. - } - } // End inner catch - } // End while loop + report( + `Completed streaming response from Claude API! (Attempt ${modelAttempts})`, + 'info' + ); + additionalInformation = responseText.trim(); + } - // If loop finished without getting information - if (!additionalInformation) { - // Only show debug info for text output (CLI) - if (outputFormat === 'text') { - console.log('>>> DEBUG: additionalInformation is falsy! Value:', additionalInformation); - } - throw new Error('Failed to generate additional information after all attempts.'); - } + // Success - break the loop + if (additionalInformation) { + report( + `Successfully generated information using ${modelType} on attempt ${modelAttempts}.`, + 'info' + ); + break; + } else { + // Handle case where AI gave empty response without erroring + report( + `AI (${modelType}) returned empty response on attempt ${modelAttempts}.`, + 'warn' + ); + 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'; + report( + `Attempt ${modelAttempts} failed using ${failedModel}: ${modelError.message}`, + 'warn' + ); - // Only show debug info for text output (CLI) - if (outputFormat === 'text') { - console.log('>>> DEBUG: Got additionalInformation:', additionalInformation.substring(0, 50) + '...'); - } + // --- 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 --- - // Create timestamp - const currentDate = new Date(); - const timestamp = currentDate.toISOString(); + if (isOverload) { + // Use the result of the check + claudeOverloaded = true; // Mark Claude as overloaded for the *next* potential attempt + if (!isLastAttempt) { + report( + 'Claude overloaded. Will attempt fallback model if available.', + 'info' + ); + // Stop the current indicator before continuing - only for text output + if (outputFormat === 'text' && 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 + report( + `Overload error on final attempt (${modelAttempts}/${maxModelAttempts}). No fallback possible.`, + 'error' + ); + // 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. + } + } else { + // Error was NOT an overload + // If it's not an overload, throw it immediately to be caught by the outer catch. + report( + `Non-overload error on attempt ${modelAttempts}: ${modelError.message}`, + 'error' + ); + throw modelError; // Re-throw non-overload errors immediately. + } + } // End inner catch + } // End while loop - // Format the additional information with timestamp - const formattedInformation = `\n\n<info added on ${timestamp}>\n${additionalInformation}\n</info added on ${timestamp}>`; - - // Only show debug info for text output (CLI) - if (outputFormat === 'text') { - console.log('>>> DEBUG: formattedInformation:', formattedInformation.substring(0, 70) + '...'); - } + // If loop finished without getting information + if (!additionalInformation) { + // Only show debug info for text output (CLI) + if (outputFormat === 'text') { + console.log( + '>>> DEBUG: additionalInformation is falsy! Value:', + additionalInformation + ); + } + throw new Error( + 'Failed to generate additional information after all attempts.' + ); + } - // Append to subtask details and description - // Only show debug info for text output (CLI) - if (outputFormat === 'text') { - console.log('>>> DEBUG: Subtask details BEFORE append:', subtask.details); - } - - if (subtask.details) { - subtask.details += formattedInformation; - } else { - subtask.details = `${formattedInformation}`; - } - - // Only show debug info for text output (CLI) - if (outputFormat === 'text') { - console.log('>>> DEBUG: Subtask details AFTER append:', subtask.details); - } + // Only show debug info for text output (CLI) + if (outputFormat === 'text') { + console.log( + '>>> DEBUG: Got additionalInformation:', + additionalInformation.substring(0, 50) + '...' + ); + } - if (subtask.description) { - // Only append to description if it makes sense (for shorter updates) - if (additionalInformation.length < 200) { - // Only show debug info for text output (CLI) - if (outputFormat === 'text') { - console.log('>>> DEBUG: Subtask description BEFORE append:', subtask.description); - } - subtask.description += ` [Updated: ${currentDate.toLocaleDateString()}]`; - // Only show debug info for text output (CLI) - if (outputFormat === 'text') { - console.log('>>> DEBUG: Subtask description AFTER append:', subtask.description); - } - } - } + // Create timestamp + const currentDate = new Date(); + const timestamp = currentDate.toISOString(); - // Only show debug info for text output (CLI) - if (outputFormat === 'text') { - console.log('>>> DEBUG: About to call writeJSON with updated data...'); - } - - // Write the updated tasks to the file - writeJSON(tasksPath, data); - - // Only show debug info for text output (CLI) - if (outputFormat === 'text') { - console.log('>>> DEBUG: writeJSON call completed.'); - } + // Format the additional information with timestamp + const formattedInformation = `\n\n<info added on ${timestamp}>\n${additionalInformation}\n</info added on ${timestamp}>`; - report(`Successfully updated subtask ${subtaskId}`, 'success'); + // Only show debug info for text output (CLI) + if (outputFormat === 'text') { + console.log( + '>>> DEBUG: formattedInformation:', + formattedInformation.substring(0, 70) + '...' + ); + } - // Generate individual task files - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + // Append to subtask details and description + // Only show debug info for text output (CLI) + if (outputFormat === 'text') { + console.log('>>> DEBUG: Subtask details BEFORE append:', subtask.details); + } - // Stop indicator before final console output - only for text output (CLI) - if (outputFormat === 'text') { - if (loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - loadingIndicator = null; - } + if (subtask.details) { + subtask.details += formattedInformation; + } else { + subtask.details = `${formattedInformation}`; + } - 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' } - )); - } + // Only show debug info for text output (CLI) + if (outputFormat === 'text') { + console.log('>>> DEBUG: Subtask details AFTER append:', subtask.details); + } - return subtask; + if (subtask.description) { + // Only append to description if it makes sense (for shorter updates) + if (additionalInformation.length < 200) { + // Only show debug info for text output (CLI) + if (outputFormat === 'text') { + console.log( + '>>> DEBUG: Subtask description BEFORE append:', + subtask.description + ); + } + subtask.description += ` [Updated: ${currentDate.toLocaleDateString()}]`; + // Only show debug info for text output (CLI) + if (outputFormat === 'text') { + console.log( + '>>> DEBUG: Subtask description AFTER append:', + subtask.description + ); + } + } + } - } catch (error) { - // Outer catch block handles final errors after loop/attempts - // Stop indicator on error - only for text output (CLI) - if (outputFormat === 'text' && loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - loadingIndicator = null; - } - - report(`Error updating subtask: ${error.message}`, 'error'); - - // Only show error UI for text output (CLI) - if (outputFormat === 'text') { - console.error(chalk.red(`Error: ${error.message}`)); + // Only show debug info for text output (CLI) + if (outputFormat === 'text') { + console.log('>>> DEBUG: About to call writeJSON with updated data...'); + } - // Provide helpful error messages 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.')); - } + // Write the updated tasks to the file + writeJSON(tasksPath, data); - if (CONFIG.debug) { - console.error(error); - } - } else { - throw error; // Re-throw for JSON output - } + // Only show debug info for text output (CLI) + if (outputFormat === 'text') { + console.log('>>> DEBUG: writeJSON call completed.'); + } - return null; - } finally { - // Final cleanup check for the indicator, although it should be stopped by now - if (outputFormat === 'text' && loadingIndicator) { - stopLoadingIndicator(loadingIndicator); - } - } + report(`Successfully updated subtask ${subtaskId}`, 'success'); + + // Generate individual task files + await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + + // Stop indicator before final console output - only for text output (CLI) + if (outputFormat === 'text') { + if (loadingIndicator) { + 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' } + ) + ); + } + + return subtask; + } catch (error) { + // Outer catch block handles final errors after loop/attempts + // Stop indicator on error - only for text output (CLI) + if (outputFormat === 'text' && loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + loadingIndicator = null; + } + + report(`Error updating subtask: ${error.message}`, 'error'); + + // Only show error UI for text output (CLI) + if (outputFormat === 'text') { + console.error(chalk.red(`Error: ${error.message}`)); + + // Provide helpful error messages 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); + } + } else { + throw error; // Re-throw for JSON output + } + + return null; + } finally { + // Final cleanup check for the indicator, although it should be stopped by now + if (outputFormat === 'text' && loadingIndicator) { + stopLoadingIndicator(loadingIndicator); + } + } } /** @@ -4269,120 +5355,147 @@ Provide concrete examples, code snippets, or implementation details when relevan * @returns {Object} Result object with success message and removed task info */ async function removeTask(tasksPath, taskId) { - try { - // Read the tasks file - const data = readJSON(tasksPath); - if (!data || !data.tasks) { - throw new Error(`No valid tasks found in ${tasksPath}`); - } - - // Check if the task ID exists - if (!taskExists(data.tasks, taskId)) { - throw new Error(`Task with ID ${taskId} not found`); - } - - // Handle subtask removal (e.g., '5.2') - if (typeof taskId === 'string' && taskId.includes('.')) { - const [parentTaskId, subtaskId] = taskId.split('.').map(id => parseInt(id, 10)); - - // Find the parent task - const parentTask = data.tasks.find(t => t.id === parentTaskId); - if (!parentTask || !parentTask.subtasks) { - throw new Error(`Parent task with ID ${parentTaskId} or its subtasks not found`); - } - - // Find the subtask to remove - const subtaskIndex = parentTask.subtasks.findIndex(st => st.id === subtaskId); - if (subtaskIndex === -1) { - throw new Error(`Subtask with ID ${subtaskId} not found in parent task ${parentTaskId}`); - } - - // Store the subtask info before removal for the result - const removedSubtask = parentTask.subtasks[subtaskIndex]; - - // Remove the subtask - parentTask.subtasks.splice(subtaskIndex, 1); - - // Remove references to this subtask in other subtasks' dependencies - if (parentTask.subtasks && parentTask.subtasks.length > 0) { - parentTask.subtasks.forEach(subtask => { - if (subtask.dependencies && subtask.dependencies.includes(subtaskId)) { - subtask.dependencies = subtask.dependencies.filter(depId => depId !== subtaskId); - } - }); - } - - // Save the updated tasks - writeJSON(tasksPath, data); - - // Generate updated task files - try { - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); - } catch (genError) { - log('warn', `Successfully removed subtask but failed to regenerate task files: ${genError.message}`); - } - - return { - success: true, - message: `Successfully removed subtask ${subtaskId} from task ${parentTaskId}`, - removedTask: removedSubtask, - parentTaskId: parentTaskId - }; - } - - // Handle main task removal - const taskIdNum = parseInt(taskId, 10); - const taskIndex = data.tasks.findIndex(t => t.id === taskIdNum); - if (taskIndex === -1) { - throw new Error(`Task with ID ${taskId} not found`); - } - - // Store the task info before removal for the result - const removedTask = data.tasks[taskIndex]; - - // Remove the task - data.tasks.splice(taskIndex, 1); - - // Remove references to this task in other tasks' dependencies - data.tasks.forEach(task => { - if (task.dependencies && task.dependencies.includes(taskIdNum)) { - task.dependencies = task.dependencies.filter(depId => depId !== taskIdNum); - } - }); - - // Save the updated tasks - writeJSON(tasksPath, data); - - // Delete the task file if it exists - const taskFileName = path.join(path.dirname(tasksPath), `task_${taskIdNum.toString().padStart(3, '0')}.txt`); - if (fs.existsSync(taskFileName)) { - try { - fs.unlinkSync(taskFileName); - } catch (unlinkError) { - log('warn', `Successfully removed task from tasks.json but failed to delete task file: ${unlinkError.message}`); - } - } - - // Generate updated task files - try { - await generateTaskFiles(tasksPath, path.dirname(tasksPath)); - } catch (genError) { - log('warn', `Successfully removed task but failed to regenerate task files: ${genError.message}`); - } - - return { - success: true, - message: `Successfully removed task ${taskId}`, - removedTask: removedTask - }; - } catch (error) { - log('error', `Error removing task: ${error.message}`); - throw { - code: 'REMOVE_TASK_ERROR', - message: error.message, - details: error.stack - }; - } + try { + // Read the tasks file + const data = readJSON(tasksPath); + if (!data || !data.tasks) { + throw new Error(`No valid tasks found in ${tasksPath}`); + } + + // Check if the task ID exists + if (!taskExists(data.tasks, taskId)) { + throw new Error(`Task with ID ${taskId} not found`); + } + + // Handle subtask removal (e.g., '5.2') + if (typeof taskId === 'string' && taskId.includes('.')) { + const [parentTaskId, subtaskId] = taskId + .split('.') + .map((id) => parseInt(id, 10)); + + // Find the parent task + const parentTask = data.tasks.find((t) => t.id === parentTaskId); + if (!parentTask || !parentTask.subtasks) { + throw new Error( + `Parent task with ID ${parentTaskId} or its subtasks not found` + ); + } + + // Find the subtask to remove + const subtaskIndex = parentTask.subtasks.findIndex( + (st) => st.id === subtaskId + ); + if (subtaskIndex === -1) { + throw new Error( + `Subtask with ID ${subtaskId} not found in parent task ${parentTaskId}` + ); + } + + // Store the subtask info before removal for the result + const removedSubtask = parentTask.subtasks[subtaskIndex]; + + // Remove the subtask + parentTask.subtasks.splice(subtaskIndex, 1); + + // Remove references to this subtask in other subtasks' dependencies + if (parentTask.subtasks && parentTask.subtasks.length > 0) { + parentTask.subtasks.forEach((subtask) => { + if ( + subtask.dependencies && + subtask.dependencies.includes(subtaskId) + ) { + subtask.dependencies = subtask.dependencies.filter( + (depId) => depId !== subtaskId + ); + } + }); + } + + // Save the updated tasks + writeJSON(tasksPath, data); + + // Generate updated task files + try { + await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + } catch (genError) { + log( + 'warn', + `Successfully removed subtask but failed to regenerate task files: ${genError.message}` + ); + } + + return { + success: true, + message: `Successfully removed subtask ${subtaskId} from task ${parentTaskId}`, + removedTask: removedSubtask, + parentTaskId: parentTaskId + }; + } + + // Handle main task removal + const taskIdNum = parseInt(taskId, 10); + const taskIndex = data.tasks.findIndex((t) => t.id === taskIdNum); + if (taskIndex === -1) { + throw new Error(`Task with ID ${taskId} not found`); + } + + // Store the task info before removal for the result + const removedTask = data.tasks[taskIndex]; + + // Remove the task + data.tasks.splice(taskIndex, 1); + + // Remove references to this task in other tasks' dependencies + data.tasks.forEach((task) => { + if (task.dependencies && task.dependencies.includes(taskIdNum)) { + task.dependencies = task.dependencies.filter( + (depId) => depId !== taskIdNum + ); + } + }); + + // Save the updated tasks + writeJSON(tasksPath, data); + + // Delete the task file if it exists + const taskFileName = path.join( + path.dirname(tasksPath), + `task_${taskIdNum.toString().padStart(3, '0')}.txt` + ); + if (fs.existsSync(taskFileName)) { + try { + fs.unlinkSync(taskFileName); + } catch (unlinkError) { + log( + 'warn', + `Successfully removed task from tasks.json but failed to delete task file: ${unlinkError.message}` + ); + } + } + + // Generate updated task files + try { + await generateTaskFiles(tasksPath, path.dirname(tasksPath)); + } catch (genError) { + log( + 'warn', + `Successfully removed task but failed to regenerate task files: ${genError.message}` + ); + } + + return { + success: true, + message: `Successfully removed task ${taskId}`, + removedTask: removedTask + }; + } catch (error) { + log('error', `Error removing task: ${error.message}`); + throw { + code: 'REMOVE_TASK_ERROR', + message: error.message, + details: error.stack + }; + } } /** @@ -4392,24 +5505,26 @@ async function removeTask(tasksPath, taskId) { * @returns {boolean} Whether the task exists */ function taskExists(tasks, taskId) { - // Handle subtask IDs (e.g., "1.2") - if (typeof taskId === 'string' && taskId.includes('.')) { - const [parentIdStr, subtaskIdStr] = taskId.split('.'); - const parentId = parseInt(parentIdStr, 10); - const subtaskId = parseInt(subtaskIdStr, 10); - - // Find the parent task - const parentTask = tasks.find(t => t.id === parentId); - - // If parent exists, check if subtask exists - return parentTask && - parentTask.subtasks && - parentTask.subtasks.some(st => st.id === subtaskId); - } - - // Handle regular task IDs - const id = parseInt(taskId, 10); - return tasks.some(t => t.id === id); + // Handle subtask IDs (e.g., "1.2") + if (typeof taskId === 'string' && taskId.includes('.')) { + const [parentIdStr, subtaskIdStr] = taskId.split('.'); + const parentId = parseInt(parentIdStr, 10); + const subtaskId = parseInt(subtaskIdStr, 10); + + // Find the parent task + const parentTask = tasks.find((t) => t.id === parentId); + + // If parent exists, check if subtask exists + return ( + parentTask && + parentTask.subtasks && + parentTask.subtasks.some((st) => st.id === subtaskId) + ); + } + + // Handle regular task IDs + const id = parseInt(taskId, 10); + return tasks.some((t) => t.id === id); } /** @@ -4420,9 +5535,14 @@ function taskExists(tasks, taskId) { * @param {Object} taskAnalysis - Optional complexity analysis for the task * @returns {string} - The generated prompt */ -function generateSubtaskPrompt(task, numSubtasks, additionalContext = '', taskAnalysis = null) { - // Build the system prompt - const basePrompt = `You need to break down the following task into ${numSubtasks} specific subtasks that can be implemented one by one. +function generateSubtaskPrompt( + task, + numSubtasks, + additionalContext = '', + taskAnalysis = null +) { + // Build the system prompt + const basePrompt = `You need to break down the following task into ${numSubtasks} specific subtasks that can be implemented one by one. Task ID: ${task.id} Title: ${task.title} @@ -4454,7 +5574,7 @@ 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.`; - return basePrompt; + return basePrompt; } /** @@ -4465,93 +5585,103 @@ Note on dependencies: Subtasks can depend on other subtasks with lower IDs. Use * @param {Object} mcpLog - MCP logger object * @returns {Object} - Object containing generated subtasks */ -async function getSubtasksFromAI(prompt, useResearch = false, session = null, mcpLog = null) { - try { - // Get the configured client - const client = getConfiguredAnthropicClient(session); - - // Prepare API parameters - const apiParams = { - model: session?.env?.ANTHROPIC_MODEL || CONFIG.model, - max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, - temperature: session?.env?.TEMPERATURE || CONFIG.temperature, - system: "You are an AI assistant helping with task breakdown for software development.", - messages: [{ role: "user", content: prompt }] - }; - - if (mcpLog) { - mcpLog.info("Calling AI to generate subtasks"); - } - - // Call the AI - with research if requested - if (useResearch && perplexity) { - if (mcpLog) { - mcpLog.info("Using Perplexity AI for research-backed subtasks"); - } - - const perplexityModel = process.env.PERPLEXITY_MODEL || session?.env?.PERPLEXITY_MODEL || 'sonar-pro'; - const result = await perplexity.chat.completions.create({ - model: perplexityModel, - messages: [ - { - role: "system", - content: "You are an AI assistant helping with task breakdown for software development. Research implementation details and provide comprehensive subtasks." - }, - { role: "user", content: prompt } - ], - temperature: session?.env?.TEMPERATURE || CONFIG.temperature, - max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, - }); - - const responseText = result.choices[0].message.content; - return parseSubtasksFromText(responseText); - } else { - // Use regular Claude - if (mcpLog) { - mcpLog.info("Using Claude for generating subtasks"); - } - - // Call the streaming API - const responseText = await _handleAnthropicStream( - client, - apiParams, - { mcpLog, silentMode: isSilentMode() }, - !isSilentMode() - ); - - return parseSubtasksFromText(responseText); - } - } catch (error) { - if (mcpLog) { - mcpLog.error(`Error generating subtasks: ${error.message}`); - } else { - log('error', `Error generating subtasks: ${error.message}`); - } - throw error; - } +async function getSubtasksFromAI( + prompt, + useResearch = false, + session = null, + mcpLog = null +) { + try { + // Get the configured client + const client = getConfiguredAnthropicClient(session); + + // Prepare API parameters + const apiParams = { + model: session?.env?.ANTHROPIC_MODEL || CONFIG.model, + max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens, + temperature: session?.env?.TEMPERATURE || CONFIG.temperature, + system: + 'You are an AI assistant helping with task breakdown for software development.', + messages: [{ role: 'user', content: prompt }] + }; + + if (mcpLog) { + mcpLog.info('Calling AI to generate subtasks'); + } + + // Call the AI - with research if requested + if (useResearch && perplexity) { + if (mcpLog) { + mcpLog.info('Using Perplexity AI for research-backed subtasks'); + } + + const perplexityModel = + process.env.PERPLEXITY_MODEL || + session?.env?.PERPLEXITY_MODEL || + 'sonar-pro'; + const result = await perplexity.chat.completions.create({ + model: perplexityModel, + messages: [ + { + role: 'system', + content: + 'You are an AI assistant helping with task breakdown for software development. Research implementation details and provide comprehensive subtasks.' + }, + { role: 'user', content: prompt } + ], + temperature: session?.env?.TEMPERATURE || CONFIG.temperature, + max_tokens: session?.env?.MAX_TOKENS || CONFIG.maxTokens + }); + + const responseText = result.choices[0].message.content; + return parseSubtasksFromText(responseText); + } else { + // Use regular Claude + if (mcpLog) { + mcpLog.info('Using Claude for generating subtasks'); + } + + // Call the streaming API + const responseText = await _handleAnthropicStream( + client, + apiParams, + { mcpLog, silentMode: isSilentMode() }, + !isSilentMode() + ); + + return parseSubtasksFromText(responseText); + } + } catch (error) { + if (mcpLog) { + mcpLog.error(`Error generating subtasks: ${error.message}`); + } else { + log('error', `Error generating subtasks: ${error.message}`); + } + throw error; + } } // Export task manager functions export { - parsePRD, - updateTasks, - updateTaskById, - updateSubtaskById, - generateTaskFiles, - setTaskStatus, - updateSingleTaskStatus, - listTasks, - expandTask, - expandAllTasks, - clearSubtasks, - addTask, - addSubtask, - removeSubtask, - findNextTask, - analyzeTaskComplexity, - removeTask, - findTaskById, - taskExists, - generateSubtaskPrompt, - getSubtasksFromAI -}; \ No newline at end of file + parsePRD, + updateTasks, + updateTaskById, + updateSubtaskById, + generateTaskFiles, + setTaskStatus, + updateSingleTaskStatus, + listTasks, + expandTask, + expandAllTasks, + clearSubtasks, + addTask, + addSubtask, + removeSubtask, + findNextTask, + analyzeTaskComplexity, + removeTask, + findTaskById, + taskExists, + generateSubtaskPrompt, + getSubtasksFromAI +}; diff --git a/scripts/modules/ui.js b/scripts/modules/ui.js index 974d3cb8..cca71055 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(); + } } /** @@ -86,105 +102,120 @@ function stopLoadingIndicator(spinner) { * @returns {string} The formatted progress bar */ function createProgressBar(percent, length = 30, statusBreakdown = null) { - // Adjust the percent to treat deferred and cancelled as complete - const effectivePercent = statusBreakdown ? - Math.min(100, percent + (statusBreakdown.deferred || 0) + (statusBreakdown.cancelled || 0)) : - percent; - - // Calculate how many characters to fill for "true completion" - const trueCompletedFilled = Math.round(percent * length / 100); - - // Calculate how many characters to fill for "effective completion" (including deferred/cancelled) - const effectiveCompletedFilled = Math.round(effectivePercent * length / 100); - - // The "deferred/cancelled" section (difference between true and effective) - const deferredCancelledFilled = effectiveCompletedFilled - trueCompletedFilled; - - // Set the empty section (remaining after effective completion) - const empty = length - effectiveCompletedFilled; - - // Determine color based on percentage for the completed section - let completedColor; - if (percent < 25) { - completedColor = chalk.red; - } else if (percent < 50) { - completedColor = chalk.hex('#FFA500'); // Orange - } else if (percent < 75) { - completedColor = chalk.yellow; - } else if (percent < 100) { - completedColor = chalk.green; - } else { - completedColor = chalk.hex('#006400'); // Dark green - } - - // Create colored sections - const completedSection = completedColor('█'.repeat(trueCompletedFilled)); - - // Gray section for deferred/cancelled items - const deferredCancelledSection = chalk.gray('█'.repeat(deferredCancelledFilled)); - - // If we have a status breakdown, create a multi-colored remaining section - let remainingSection = ''; - - if (statusBreakdown && empty > 0) { - // Status colors (matching the statusConfig colors in getStatusWithColor) - const statusColors = { - 'pending': chalk.yellow, - 'in-progress': chalk.hex('#FFA500'), // Orange - 'blocked': chalk.red, - 'review': chalk.magenta, - // Deferred and cancelled are treated as part of the completed section - }; - - // Calculate proportions for each status - const totalRemaining = Object.entries(statusBreakdown) - .filter(([status]) => !['deferred', 'cancelled', 'done', 'completed'].includes(status)) - .reduce((sum, [_, val]) => sum + val, 0); - - // If no remaining tasks with tracked statuses, just use gray - if (totalRemaining <= 0) { - remainingSection = chalk.gray('░'.repeat(empty)); - } else { - // Track how many characters we've added - let addedChars = 0; - - // Add each status section proportionally - for (const [status, percentage] of Object.entries(statusBreakdown)) { - // Skip statuses that are considered complete - if (['deferred', 'cancelled', 'done', 'completed'].includes(status)) continue; - - // Calculate how many characters this status should fill - const statusChars = Math.round((percentage / totalRemaining) * empty); - - // Make sure we don't exceed the total length due to rounding - const actualChars = Math.min(statusChars, empty - addedChars); - - // Add colored section for this status - const colorFn = statusColors[status] || chalk.gray; - remainingSection += colorFn('░'.repeat(actualChars)); - - addedChars += actualChars; - } - - // If we have any remaining space due to rounding, fill with gray - if (addedChars < empty) { - remainingSection += chalk.gray('░'.repeat(empty - addedChars)); - } - } - } else { - // Default to gray for the empty section if no breakdown provided - remainingSection = chalk.gray('░'.repeat(empty)); - } - - // Effective percentage text color should reflect the highest category - const percentTextColor = percent === 100 ? - chalk.hex('#006400') : // Dark green for 100% - (effectivePercent === 100 ? - chalk.gray : // Gray for 100% with deferred/cancelled - completedColor); // Otherwise match the completed color - - // Build the complete progress bar - return `${completedSection}${deferredCancelledSection}${remainingSection} ${percentTextColor(`${effectivePercent.toFixed(0)}%`)}`; + // Adjust the percent to treat deferred and cancelled as complete + const effectivePercent = statusBreakdown + ? Math.min( + 100, + percent + + (statusBreakdown.deferred || 0) + + (statusBreakdown.cancelled || 0) + ) + : percent; + + // Calculate how many characters to fill for "true completion" + const trueCompletedFilled = Math.round((percent * length) / 100); + + // Calculate how many characters to fill for "effective completion" (including deferred/cancelled) + const effectiveCompletedFilled = Math.round( + (effectivePercent * length) / 100 + ); + + // The "deferred/cancelled" section (difference between true and effective) + const deferredCancelledFilled = + effectiveCompletedFilled - trueCompletedFilled; + + // Set the empty section (remaining after effective completion) + const empty = length - effectiveCompletedFilled; + + // Determine color based on percentage for the completed section + let completedColor; + if (percent < 25) { + completedColor = chalk.red; + } else if (percent < 50) { + completedColor = chalk.hex('#FFA500'); // Orange + } else if (percent < 75) { + completedColor = chalk.yellow; + } else if (percent < 100) { + completedColor = chalk.green; + } else { + completedColor = chalk.hex('#006400'); // Dark green + } + + // Create colored sections + const completedSection = completedColor('█'.repeat(trueCompletedFilled)); + + // Gray section for deferred/cancelled items + const deferredCancelledSection = chalk.gray( + '█'.repeat(deferredCancelledFilled) + ); + + // If we have a status breakdown, create a multi-colored remaining section + let remainingSection = ''; + + if (statusBreakdown && empty > 0) { + // Status colors (matching the statusConfig colors in getStatusWithColor) + const statusColors = { + pending: chalk.yellow, + 'in-progress': chalk.hex('#FFA500'), // Orange + blocked: chalk.red, + review: chalk.magenta + // Deferred and cancelled are treated as part of the completed section + }; + + // Calculate proportions for each status + const totalRemaining = Object.entries(statusBreakdown) + .filter( + ([status]) => + !['deferred', 'cancelled', 'done', 'completed'].includes(status) + ) + .reduce((sum, [_, val]) => sum + val, 0); + + // If no remaining tasks with tracked statuses, just use gray + if (totalRemaining <= 0) { + remainingSection = chalk.gray('░'.repeat(empty)); + } else { + // Track how many characters we've added + let addedChars = 0; + + // Add each status section proportionally + for (const [status, percentage] of Object.entries(statusBreakdown)) { + // Skip statuses that are considered complete + if (['deferred', 'cancelled', 'done', 'completed'].includes(status)) + continue; + + // Calculate how many characters this status should fill + const statusChars = Math.round((percentage / totalRemaining) * empty); + + // Make sure we don't exceed the total length due to rounding + const actualChars = Math.min(statusChars, empty - addedChars); + + // Add colored section for this status + const colorFn = statusColors[status] || chalk.gray; + remainingSection += colorFn('░'.repeat(actualChars)); + + addedChars += actualChars; + } + + // If we have any remaining space due to rounding, fill with gray + if (addedChars < empty) { + remainingSection += chalk.gray('░'.repeat(empty - addedChars)); + } + } + } else { + // Default to gray for the empty section if no breakdown provided + remainingSection = chalk.gray('░'.repeat(empty)); + } + + // Effective percentage text color should reflect the highest category + const percentTextColor = + percent === 100 + ? chalk.hex('#006400') // Dark green for 100% + : effectivePercent === 100 + ? chalk.gray // Gray for 100% with deferred/cancelled + : completedColor; // Otherwise match the completed color + + // Build the complete progress bar + return `${completedSection}${deferredCancelledSection}${remainingSection} ${percentTextColor(`${effectivePercent.toFixed(0)}%`)}`; } /** @@ -194,40 +225,44 @@ function createProgressBar(percent, length = 30, statusBreakdown = null) { * @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: '👁' }, - 'cancelled': { color: chalk.gray, 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: '👁' }, + cancelled: { color: chalk.gray, 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}`); } /** @@ -237,265 +272,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(''); } /** @@ -504,9 +649,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}`); } /** @@ -516,9 +661,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) + '...'; } /** @@ -526,189 +671,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 } + } + ) + ); } /** @@ -717,363 +920,492 @@ 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 } } - )); - - // Calculate and display subtask completion progress - if (task.subtasks && task.subtasks.length > 0) { - const totalSubtasks = task.subtasks.length; - const completedSubtasks = task.subtasks.filter(st => - st.status === 'done' || st.status === 'completed' - ).length; - - // Count other statuses for the subtasks - const inProgressSubtasks = task.subtasks.filter(st => st.status === 'in-progress').length; - const pendingSubtasks = task.subtasks.filter(st => st.status === 'pending').length; - const blockedSubtasks = task.subtasks.filter(st => st.status === 'blocked').length; - const deferredSubtasks = task.subtasks.filter(st => st.status === 'deferred').length; - const cancelledSubtasks = task.subtasks.filter(st => st.status === 'cancelled').length; - - // Calculate status breakdown as percentages - const statusBreakdown = { - 'in-progress': (inProgressSubtasks / totalSubtasks) * 100, - 'pending': (pendingSubtasks / totalSubtasks) * 100, - 'blocked': (blockedSubtasks / totalSubtasks) * 100, - 'deferred': (deferredSubtasks / totalSubtasks) * 100, - 'cancelled': (cancelledSubtasks / totalSubtasks) * 100 - }; - - const completionPercentage = (completedSubtasks / totalSubtasks) * 100; - - // Calculate appropriate progress bar length based on terminal width - // Subtract padding (2), borders (2), and the percentage text (~5) - const availableWidth = process.stdout.columns || 80; // Default to 80 if can't detect - const boxPadding = 2; // 1 on each side - const boxBorders = 2; // 1 on each side - const percentTextLength = 5; // ~5 chars for " 100%" - // Reduce the length by adjusting the subtraction value from 20 to 35 - const progressBarLength = Math.max(20, Math.min(60, availableWidth - boxPadding - boxBorders - percentTextLength - 35)); // Min 20, Max 60 - - // Status counts for display - const statusCounts = - `${chalk.green('✓ Done:')} ${completedSubtasks} ${chalk.hex('#FFA500')('► In Progress:')} ${inProgressSubtasks} ${chalk.yellow('○ Pending:')} ${pendingSubtasks}\n` + - `${chalk.red('! Blocked:')} ${blockedSubtasks} ${chalk.gray('⏱ Deferred:')} ${deferredSubtasks} ${chalk.gray('✗ Cancelled:')} ${cancelledSubtasks}`; - - console.log(boxen( - chalk.white.bold('Subtask Progress:') + '\n\n' + - `${chalk.cyan('Completed:')} ${completedSubtasks}/${totalSubtasks} (${completionPercentage.toFixed(1)}%)\n` + - `${statusCounts}\n` + - `${chalk.cyan('Progress:')} ${createProgressBar(completionPercentage, progressBarLength, statusBreakdown)}`, - { - padding: { top: 0, bottom: 0, left: 1, right: 1 }, - borderColor: 'blue', - borderStyle: 'round', - margin: { top: 1, bottom: 0 }, - width: Math.min(availableWidth - 10, 100), // Add width constraint to limit the box width - textAlignment: 'left' - } - )); - } - - 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()); - - // Calculate and display subtask completion progress - if (task.subtasks && task.subtasks.length > 0) { - const totalSubtasks = task.subtasks.length; - const completedSubtasks = task.subtasks.filter(st => - st.status === 'done' || st.status === 'completed' - ).length; - - // Count other statuses for the subtasks - const inProgressSubtasks = task.subtasks.filter(st => st.status === 'in-progress').length; - const pendingSubtasks = task.subtasks.filter(st => st.status === 'pending').length; - const blockedSubtasks = task.subtasks.filter(st => st.status === 'blocked').length; - const deferredSubtasks = task.subtasks.filter(st => st.status === 'deferred').length; - const cancelledSubtasks = task.subtasks.filter(st => st.status === 'cancelled').length; - - // Calculate status breakdown as percentages - const statusBreakdown = { - 'in-progress': (inProgressSubtasks / totalSubtasks) * 100, - 'pending': (pendingSubtasks / totalSubtasks) * 100, - 'blocked': (blockedSubtasks / totalSubtasks) * 100, - 'deferred': (deferredSubtasks / totalSubtasks) * 100, - 'cancelled': (cancelledSubtasks / totalSubtasks) * 100 - }; - - const completionPercentage = (completedSubtasks / totalSubtasks) * 100; - - // Calculate appropriate progress bar length based on terminal width - // Subtract padding (2), borders (2), and the percentage text (~5) - const availableWidth = process.stdout.columns || 80; // Default to 80 if can't detect - const boxPadding = 2; // 1 on each side - const boxBorders = 2; // 1 on each side - const percentTextLength = 5; // ~5 chars for " 100%" - // Reduce the length by adjusting the subtraction value from 20 to 35 - const progressBarLength = Math.max(20, Math.min(60, availableWidth - boxPadding - boxBorders - percentTextLength - 35)); // Min 20, Max 60 - - // Status counts for display - const statusCounts = - `${chalk.green('✓ Done:')} ${completedSubtasks} ${chalk.hex('#FFA500')('► In Progress:')} ${inProgressSubtasks} ${chalk.yellow('○ Pending:')} ${pendingSubtasks}\n` + - `${chalk.red('! Blocked:')} ${blockedSubtasks} ${chalk.gray('⏱ Deferred:')} ${deferredSubtasks} ${chalk.gray('✗ Cancelled:')} ${cancelledSubtasks}`; - - console.log(boxen( - chalk.white.bold('Subtask Progress:') + '\n\n' + - `${chalk.cyan('Completed:')} ${completedSubtasks}/${totalSubtasks} (${completionPercentage.toFixed(1)}%)\n` + - `${statusCounts}\n` + - `${chalk.cyan('Progress:')} ${createProgressBar(completionPercentage, progressBarLength, statusBreakdown)}`, - { - padding: { top: 0, bottom: 0, left: 1, right: 1 }, - borderColor: 'blue', - borderStyle: 'round', - margin: { top: 1, bottom: 0 }, - width: Math.min(availableWidth - 10, 100), // Add width constraint to limit the box width - textAlignment: 'left' - } - )); - } - } 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 } + } + ) + ); + + // Calculate and display subtask completion progress + if (task.subtasks && task.subtasks.length > 0) { + const totalSubtasks = task.subtasks.length; + const completedSubtasks = task.subtasks.filter( + (st) => st.status === 'done' || st.status === 'completed' + ).length; + + // Count other statuses for the subtasks + const inProgressSubtasks = task.subtasks.filter( + (st) => st.status === 'in-progress' + ).length; + const pendingSubtasks = task.subtasks.filter( + (st) => st.status === 'pending' + ).length; + const blockedSubtasks = task.subtasks.filter( + (st) => st.status === 'blocked' + ).length; + const deferredSubtasks = task.subtasks.filter( + (st) => st.status === 'deferred' + ).length; + const cancelledSubtasks = task.subtasks.filter( + (st) => st.status === 'cancelled' + ).length; + + // Calculate status breakdown as percentages + const statusBreakdown = { + 'in-progress': (inProgressSubtasks / totalSubtasks) * 100, + pending: (pendingSubtasks / totalSubtasks) * 100, + blocked: (blockedSubtasks / totalSubtasks) * 100, + deferred: (deferredSubtasks / totalSubtasks) * 100, + cancelled: (cancelledSubtasks / totalSubtasks) * 100 + }; + + const completionPercentage = (completedSubtasks / totalSubtasks) * 100; + + // Calculate appropriate progress bar length based on terminal width + // Subtract padding (2), borders (2), and the percentage text (~5) + const availableWidth = process.stdout.columns || 80; // Default to 80 if can't detect + const boxPadding = 2; // 1 on each side + const boxBorders = 2; // 1 on each side + const percentTextLength = 5; // ~5 chars for " 100%" + // Reduce the length by adjusting the subtraction value from 20 to 35 + const progressBarLength = Math.max( + 20, + Math.min( + 60, + availableWidth - boxPadding - boxBorders - percentTextLength - 35 + ) + ); // Min 20, Max 60 + + // Status counts for display + const statusCounts = + `${chalk.green('✓ Done:')} ${completedSubtasks} ${chalk.hex('#FFA500')('► In Progress:')} ${inProgressSubtasks} ${chalk.yellow('○ Pending:')} ${pendingSubtasks}\n` + + `${chalk.red('! Blocked:')} ${blockedSubtasks} ${chalk.gray('⏱ Deferred:')} ${deferredSubtasks} ${chalk.gray('✗ Cancelled:')} ${cancelledSubtasks}`; + + console.log( + boxen( + chalk.white.bold('Subtask Progress:') + + '\n\n' + + `${chalk.cyan('Completed:')} ${completedSubtasks}/${totalSubtasks} (${completionPercentage.toFixed(1)}%)\n` + + `${statusCounts}\n` + + `${chalk.cyan('Progress:')} ${createProgressBar(completionPercentage, progressBarLength, statusBreakdown)}`, + { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, bottom: 0 }, + width: Math.min(availableWidth - 10, 100), // Add width constraint to limit the box width + textAlignment: 'left' + } + ) + ); + } + + 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()); + + // Calculate and display subtask completion progress + if (task.subtasks && task.subtasks.length > 0) { + const totalSubtasks = task.subtasks.length; + const completedSubtasks = task.subtasks.filter( + (st) => st.status === 'done' || st.status === 'completed' + ).length; + + // Count other statuses for the subtasks + const inProgressSubtasks = task.subtasks.filter( + (st) => st.status === 'in-progress' + ).length; + const pendingSubtasks = task.subtasks.filter( + (st) => st.status === 'pending' + ).length; + const blockedSubtasks = task.subtasks.filter( + (st) => st.status === 'blocked' + ).length; + const deferredSubtasks = task.subtasks.filter( + (st) => st.status === 'deferred' + ).length; + const cancelledSubtasks = task.subtasks.filter( + (st) => st.status === 'cancelled' + ).length; + + // Calculate status breakdown as percentages + const statusBreakdown = { + 'in-progress': (inProgressSubtasks / totalSubtasks) * 100, + pending: (pendingSubtasks / totalSubtasks) * 100, + blocked: (blockedSubtasks / totalSubtasks) * 100, + deferred: (deferredSubtasks / totalSubtasks) * 100, + cancelled: (cancelledSubtasks / totalSubtasks) * 100 + }; + + const completionPercentage = (completedSubtasks / totalSubtasks) * 100; + + // Calculate appropriate progress bar length based on terminal width + // Subtract padding (2), borders (2), and the percentage text (~5) + const availableWidth = process.stdout.columns || 80; // Default to 80 if can't detect + const boxPadding = 2; // 1 on each side + const boxBorders = 2; // 1 on each side + const percentTextLength = 5; // ~5 chars for " 100%" + // Reduce the length by adjusting the subtraction value from 20 to 35 + const progressBarLength = Math.max( + 20, + Math.min( + 60, + availableWidth - boxPadding - boxBorders - percentTextLength - 35 + ) + ); // Min 20, Max 60 + + // Status counts for display + const statusCounts = + `${chalk.green('✓ Done:')} ${completedSubtasks} ${chalk.hex('#FFA500')('► In Progress:')} ${inProgressSubtasks} ${chalk.yellow('○ Pending:')} ${pendingSubtasks}\n` + + `${chalk.red('! Blocked:')} ${blockedSubtasks} ${chalk.gray('⏱ Deferred:')} ${deferredSubtasks} ${chalk.gray('✗ Cancelled:')} ${cancelledSubtasks}`; + + console.log( + boxen( + chalk.white.bold('Subtask Progress:') + + '\n\n' + + `${chalk.cyan('Completed:')} ${completedSubtasks}/${totalSubtasks} (${completionPercentage.toFixed(1)}%)\n` + + `${statusCounts}\n` + + `${chalk.cyan('Progress:')} ${createProgressBar(completionPercentage, progressBarLength, statusBreakdown)}`, + { + padding: { top: 0, bottom: 0, left: 1, right: 1 }, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, bottom: 0 }, + width: Math.min(availableWidth - 10, 100), // Add width constraint to limit the box width + textAlignment: 'left' + } + ) + ); + } + } 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 } + } + ) + ); } /** @@ -1081,188 +1413,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 } + } + ) + ); } /** @@ -1271,39 +1658,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 d77b25e4..9ca00000 100644 --- a/scripts/modules/utils.js +++ b/scripts/modules/utils.js @@ -9,15 +9,15 @@ 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 }; // Global silent mode flag @@ -25,25 +25,25 @@ let silentMode = false; // Set up logging based on log level const LOG_LEVELS = { - debug: 0, - info: 1, - warn: 2, - error: 3, - success: 1 // Treat success like info level + debug: 0, + info: 1, + warn: 2, + error: 3, + success: 1 // Treat success like info level }; /** * Enable silent logging mode */ function enableSilentMode() { - silentMode = true; + silentMode = true; } /** * Disable silent logging mode */ function disableSilentMode() { - silentMode = false; + silentMode = false; } /** @@ -51,7 +51,7 @@ function disableSilentMode() { * @returns {boolean} True if silent mode is enabled */ function isSilentMode() { - return silentMode; + return silentMode; } /** @@ -60,32 +60,36 @@ function isSilentMode() { * @param {...any} args - Arguments to log */ function log(level, ...args) { - // Immediately return if silentMode is enabled - if (silentMode) { - return; - } - - // Use text prefixes instead of emojis - const prefixes = { - debug: chalk.gray("[DEBUG]"), - info: chalk.blue("[INFO]"), - warn: chalk.yellow("[WARN]"), - error: chalk.red("[ERROR]"), - success: chalk.green("[SUCCESS]") - }; - - // Ensure level exists, default to info if not - const currentLevel = LOG_LEVELS.hasOwnProperty(level) ? level : 'info'; - const configLevel = CONFIG.logLevel || 'info'; // Ensure configLevel has a default - - // Check log level configuration - if (LOG_LEVELS[currentLevel] >= (LOG_LEVELS[configLevel] ?? LOG_LEVELS.info)) { - const prefix = prefixes[currentLevel] || ''; - // Use console.log for all levels, let chalk handle coloring - // Construct the message properly - const message = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : arg).join(' '); - console.log(`${prefix} ${message}`); - } + // Immediately return if silentMode is enabled + if (silentMode) { + return; + } + + // Use text prefixes instead of emojis + const prefixes = { + debug: chalk.gray('[DEBUG]'), + info: chalk.blue('[INFO]'), + warn: chalk.yellow('[WARN]'), + error: chalk.red('[ERROR]'), + success: chalk.green('[SUCCESS]') + }; + + // Ensure level exists, default to info if not + const currentLevel = LOG_LEVELS.hasOwnProperty(level) ? level : 'info'; + const configLevel = CONFIG.logLevel || 'info'; // Ensure configLevel has a default + + // Check log level configuration + if ( + LOG_LEVELS[currentLevel] >= (LOG_LEVELS[configLevel] ?? LOG_LEVELS.info) + ) { + const prefix = prefixes[currentLevel] || ''; + // Use console.log for all levels, let chalk handle coloring + // Construct the message properly + const message = args + .map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : arg)) + .join(' '); + console.log(`${prefix} ${message}`); + } } /** @@ -94,17 +98,17 @@ function log(level, ...args) { * @returns {Object|null} Parsed JSON data or null if error occurs */ 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) { - // Use log utility for debug output too - log('error', 'Full error details:', 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) { + // Use log utility for debug output too + log('error', 'Full error details:', error); + } + return null; + } } /** @@ -113,19 +117,19 @@ function readJSON(filepath) { * @param {Object} data - Data to write */ function writeJSON(filepath, data) { - try { - const dir = path.dirname(filepath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf8'); - } catch (error) { - log('error', `Error writing JSON file ${filepath}:`, error.message); - if (CONFIG.debug) { - // Use log utility for debug output too - log('error', 'Full error details:', error); - } - } + try { + const dir = path.dirname(filepath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf8'); + } catch (error) { + log('error', `Error writing JSON file ${filepath}:`, error.message); + if (CONFIG.debug) { + // Use log utility for debug output too + log('error', 'Full error details:', error); + } + } } /** @@ -134,8 +138,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, '\\"'); } /** @@ -144,18 +148,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; + } } /** @@ -165,11 +171,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); } /** @@ -179,24 +189,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); } /** @@ -205,15 +217,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; } /** @@ -223,35 +235,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; } /** @@ -261,11 +275,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) + '...'; } /** @@ -276,39 +290,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; } /** @@ -317,23 +339,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 }; /** @@ -342,49 +364,49 @@ 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, - enableSilentMode, - disableSilentMode, - isSilentMode -}; \ No newline at end of file + CONFIG, + LOG_LEVELS, + log, + readJSON, + writeJSON, + sanitizePrompt, + readComplexityReport, + findTaskInComplexityReport, + taskExists, + formatTaskId, + findTaskById, + truncate, + findCycles, + toKebabCase, + detectCamelCaseFlags, + enableSilentMode, + disableSilentMode, + isSilentMode +}; diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js index 1ae09407..4d1d2d2d 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,176 +22,190 @@ 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/taskmaster.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/taskmaster.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/fixture/test-tasks.json b/tests/fixture/test-tasks.json index 6b99c177..a1ef13d7 100644 --- a/tests/fixture/test-tasks.json +++ b/tests/fixture/test-tasks.json @@ -1,14 +1,14 @@ { - "tasks": [ - { - "id": 1, - "dependencies": [], - "subtasks": [ - { - "id": 1, - "dependencies": [] - } - ] - } - ] -} \ No newline at end of file + "tasks": [ + { + "id": 1, + "dependencies": [], + "subtasks": [ + { + "id": 1, + "dependencies": [] + } + ] + } + ] +} 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 0f347b37..e1fb53c3 100644 --- a/tests/fixtures/sample-tasks.js +++ b/tests/fixtures/sample-tasks.js @@ -3,86 +3,88 @@ */ 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", - subtasks: [ - { - id: 1, - title: "Implement Authentication", - description: "Create user authentication system", - status: "done", - dependencies: [] - }, - { - id: 2, - title: "Set Up Database", - description: "Configure database connection and models", - status: "pending", - dependencies: [1] - } - ] - }, - { - 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', + subtasks: [ + { + id: 1, + title: 'Implement Authentication', + description: 'Create user authentication system', + status: 'done', + dependencies: [] + }, + { + id: 2, + title: 'Set Up Database', + description: 'Configure database connection and models', + status: 'pending', + dependencies: [1] + } + ] + }, + { + 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 dd43157c..3d2b6a14 100644 --- a/tests/integration/mcp-server/direct-functions.test.js +++ b/tests/integration/mcp-server/direct-functions.test.js @@ -30,78 +30,86 @@ const mockDisableSilentMode = jest.fn(); const mockGetAnthropicClient = jest.fn().mockReturnValue({}); const mockGetConfiguredAnthropicClient = jest.fn().mockReturnValue({}); -const mockHandleAnthropicStream = jest.fn().mockResolvedValue(JSON.stringify([ - { - "id": 1, - "title": "Mock Subtask 1", - "description": "First mock subtask", - "dependencies": [], - "details": "Implementation details for mock subtask 1" - }, - { - "id": 2, - "title": "Mock Subtask 2", - "description": "Second mock subtask", - "dependencies": [1], - "details": "Implementation details for mock subtask 2" - } -])); +const mockHandleAnthropicStream = jest.fn().mockResolvedValue( + JSON.stringify([ + { + id: 1, + title: 'Mock Subtask 1', + description: 'First mock subtask', + dependencies: [], + details: 'Implementation details for mock subtask 1' + }, + { + id: 2, + title: 'Mock Subtask 2', + description: 'Second mock subtask', + dependencies: [1], + details: 'Implementation details for mock subtask 2' + } + ]) +); const mockParseSubtasksFromText = jest.fn().mockReturnValue([ - { - id: 1, - title: "Mock Subtask 1", - description: "First mock subtask", - status: "pending", - dependencies: [] - }, - { - id: 2, - title: "Mock Subtask 2", - description: "Second mock subtask", - status: "pending", - dependencies: [1] - } + { + id: 1, + title: 'Mock Subtask 1', + description: 'First mock subtask', + status: 'pending', + dependencies: [] + }, + { + id: 2, + title: 'Mock Subtask 2', + description: 'Second mock subtask', + status: 'pending', + dependencies: [1] + } ]); // Create a mock for expandTask that returns predefined responses instead of making real calls -const mockExpandTask = jest.fn().mockImplementation((taskId, numSubtasks, useResearch, additionalContext, options) => { - const task = { - ...sampleTasks.tasks.find(t => t.id === taskId) || {}, - subtasks: useResearch ? [ - { - id: 1, - title: "Research-Backed Subtask 1", - description: "First research-backed subtask", - status: "pending", - dependencies: [] - }, - { - id: 2, - title: "Research-Backed Subtask 2", - description: "Second research-backed subtask", - status: "pending", - dependencies: [1] - } - ] : [ - { - id: 1, - title: "Mock Subtask 1", - description: "First mock subtask", - status: "pending", - dependencies: [] - }, - { - id: 2, - title: "Mock Subtask 2", - description: "Second mock subtask", - status: "pending", - dependencies: [1] - } - ] - }; - - return Promise.resolve(task); -}); +const mockExpandTask = jest + .fn() + .mockImplementation( + (taskId, numSubtasks, useResearch, additionalContext, options) => { + const task = { + ...(sampleTasks.tasks.find((t) => t.id === taskId) || {}), + subtasks: useResearch + ? [ + { + id: 1, + title: 'Research-Backed Subtask 1', + description: 'First research-backed subtask', + status: 'pending', + dependencies: [] + }, + { + id: 2, + title: 'Research-Backed Subtask 2', + description: 'Second research-backed subtask', + status: 'pending', + dependencies: [1] + } + ] + : [ + { + id: 1, + title: 'Mock Subtask 1', + description: 'First mock subtask', + status: 'pending', + dependencies: [] + }, + { + id: 2, + title: 'Mock Subtask 2', + description: 'Second mock subtask', + status: 'pending', + dependencies: [1] + } + ] + }; + + return Promise.resolve(task); + } + ); const mockGenerateTaskFiles = jest.fn().mockResolvedValue(true); const mockFindTaskById = jest.fn(); @@ -109,542 +117,579 @@ const mockTaskExists = jest.fn().mockReturnValue(true); // Mock fs module to avoid file system operations jest.mock('fs', () => ({ - existsSync: mockExistsSync, - writeFileSync: mockWriteFileSync, - readFileSync: mockReadFileSync, - unlinkSync: mockUnlinkSync, - mkdirSync: mockMkdirSync + existsSync: mockExistsSync, + writeFileSync: mockWriteFileSync, + readFileSync: mockReadFileSync, + unlinkSync: mockUnlinkSync, + mkdirSync: mockMkdirSync })); // Mock utils functions to avoid actual file operations jest.mock('../../../scripts/modules/utils.js', () => ({ - readJSON: mockReadJSON, - writeJSON: mockWriteJSON, - enableSilentMode: mockEnableSilentMode, - disableSilentMode: mockDisableSilentMode, - CONFIG: { - model: 'claude-3-sonnet-20240229', - maxTokens: 64000, - temperature: 0.2, - defaultSubtasks: 5 - } + readJSON: mockReadJSON, + writeJSON: mockWriteJSON, + enableSilentMode: mockEnableSilentMode, + disableSilentMode: mockDisableSilentMode, + CONFIG: { + model: 'claude-3-sonnet-20240229', + maxTokens: 64000, + temperature: 0.2, + defaultSubtasks: 5 + } })); // Mock path-utils with findTasksJsonPath jest.mock('../../../mcp-server/src/core/utils/path-utils.js', () => ({ - findTasksJsonPath: mockFindTasksJsonPath + findTasksJsonPath: mockFindTasksJsonPath })); // Mock the AI module to prevent any real API calls jest.mock('../../../scripts/modules/ai-services.js', () => ({ - getAnthropicClient: mockGetAnthropicClient, - getConfiguredAnthropicClient: mockGetConfiguredAnthropicClient, - _handleAnthropicStream: mockHandleAnthropicStream, - parseSubtasksFromText: mockParseSubtasksFromText + getAnthropicClient: mockGetAnthropicClient, + getConfiguredAnthropicClient: mockGetConfiguredAnthropicClient, + _handleAnthropicStream: mockHandleAnthropicStream, + parseSubtasksFromText: mockParseSubtasksFromText })); // Mock task-manager.js to avoid real operations jest.mock('../../../scripts/modules/task-manager.js', () => ({ - expandTask: mockExpandTask, - generateTaskFiles: mockGenerateTaskFiles, - findTaskById: mockFindTaskById, - taskExists: mockTaskExists + expandTask: mockExpandTask, + generateTaskFiles: mockGenerateTaskFiles, + findTaskById: mockFindTaskById, + taskExists: mockTaskExists })); // Import dependencies after mocks are set up import fs from 'fs'; -import { readJSON, writeJSON, enableSilentMode, disableSilentMode } from '../../../scripts/modules/utils.js'; +import { + readJSON, + writeJSON, + enableSilentMode, + disableSilentMode +} from '../../../scripts/modules/utils.js'; import { expandTask } from '../../../scripts/modules/task-manager.js'; import { findTasksJsonPath } from '../../../mcp-server/src/core/utils/path-utils.js'; import { sampleTasks } from '../../fixtures/sample-tasks.js'; // 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() }; // Mock session const mockSession = { - env: { - ANTHROPIC_API_KEY: 'mock-api-key', - MODEL: 'claude-3-sonnet-20240229', - MAX_TOKENS: 4000, - TEMPERATURE: '0.2' - } + env: { + ANTHROPIC_API_KEY: 'mock-api-key', + MODEL: 'claude-3-sonnet-20240229', + MAX_TOKENS: 4000, + TEMPERATURE: '0.2' + } }; describe('MCP Server Direct Functions', () => { - // Set up before each test - beforeEach(() => { - jest.clearAllMocks(); - - // Default mockReadJSON implementation - mockReadJSON.mockReturnValue(JSON.parse(JSON.stringify(sampleTasks))); - - // Default mockFindTaskById implementation - mockFindTaskById.mockImplementation((tasks, taskId) => { - const id = parseInt(taskId, 10); - return tasks.find(t => t.id === id); - }); - - // Default mockTaskExists implementation - mockTaskExists.mockImplementation((tasks, taskId) => { - const id = parseInt(taskId, 10); - return tasks.some(t => t.id === id); - }); - - // Default findTasksJsonPath implementation - mockFindTasksJsonPath.mockImplementation((args) => { - // Mock returning null for non-existent files - if (args.file === 'non-existent-file.json') { - return null; - } - return testTasksPath; - }); - }); - - describe('listTasksDirect', () => { - // Test wrapper function that doesn't rely on the actual implementation - async function testListTasks(args, mockLogger) { - // File not found case - if (args.file === 'non-existent-file.json') { - mockLogger.error('Tasks file not found'); - return { - success: false, - error: { - code: 'FILE_NOT_FOUND_ERROR', - message: 'Tasks file not found' - }, - fromCache: false - }; - } - - // Success case - if (!args.status && !args.withSubtasks) { - return { - success: true, - data: { - tasks: sampleTasks.tasks, - stats: { - total: sampleTasks.tasks.length, - completed: sampleTasks.tasks.filter(t => t.status === 'done').length, - inProgress: sampleTasks.tasks.filter(t => t.status === 'in-progress').length, - pending: sampleTasks.tasks.filter(t => t.status === 'pending').length - } - }, - fromCache: false - }; - } - - // Status filter case - if (args.status) { - const filteredTasks = sampleTasks.tasks.filter(t => t.status === args.status); - return { - success: true, - data: { - tasks: filteredTasks, - filter: args.status, - stats: { - total: sampleTasks.tasks.length, - filtered: filteredTasks.length - } - }, - fromCache: false - }; - } - - // Include subtasks case - if (args.withSubtasks) { - return { - success: true, - data: { - tasks: sampleTasks.tasks, - includeSubtasks: true, - stats: { - total: sampleTasks.tasks.length - } - }, - fromCache: false - }; - } - - // Default case - return { - success: true, - data: { tasks: [] } - }; - } - - test('should return all tasks when no filter is provided', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: testTasksPath - }; - - // Act - const result = await testListTasks(args, mockLogger); - - // Assert - expect(result.success).toBe(true); - expect(result.data.tasks.length).toBe(sampleTasks.tasks.length); - expect(result.data.stats.total).toBe(sampleTasks.tasks.length); - }); - - test('should filter tasks by status', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: testTasksPath, - status: 'pending' - }; - - // Act - const result = await testListTasks(args, mockLogger); - - // Assert - expect(result.success).toBe(true); - expect(result.data.filter).toBe('pending'); - // Should only include pending tasks - result.data.tasks.forEach(task => { - expect(task.status).toBe('pending'); - }); - }); - - test('should include subtasks when requested', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: testTasksPath, - withSubtasks: true - }; - - // Act - const result = await testListTasks(args, mockLogger); - - // Assert - expect(result.success).toBe(true); - expect(result.data.includeSubtasks).toBe(true); - - // Verify subtasks are included for tasks that have them - const tasksWithSubtasks = result.data.tasks.filter(t => t.subtasks && t.subtasks.length > 0); - expect(tasksWithSubtasks.length).toBeGreaterThan(0); - }); - - test('should handle file not found errors', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: 'non-existent-file.json' - }; - - // Act - const result = await testListTasks(args, mockLogger); - - // Assert - expect(result.success).toBe(false); - expect(result.error.code).toBe('FILE_NOT_FOUND_ERROR'); - expect(mockLogger.error).toHaveBeenCalled(); - }); - }); - - describe('expandTaskDirect', () => { - // Test wrapper function that returns appropriate results based on the test case - async function testExpandTask(args, mockLogger, options = {}) { - // Missing task ID case - if (!args.id) { - mockLogger.error('Task ID is required'); - return { - success: false, - error: { - code: 'INPUT_VALIDATION_ERROR', - message: 'Task ID is required' - }, - fromCache: false - }; - } - - // Non-existent task ID case - if (args.id === '999') { - mockLogger.error(`Task with ID ${args.id} not found`); - return { - success: false, - error: { - code: 'TASK_NOT_FOUND', - message: `Task with ID ${args.id} not found` - }, - fromCache: false - }; - } - - // Completed task case - if (args.id === '1') { - mockLogger.error(`Task ${args.id} is already marked as done and cannot be expanded`); - return { - success: false, - error: { - code: 'TASK_COMPLETED', - message: `Task ${args.id} is already marked as done and cannot be expanded` - }, - fromCache: false - }; - } - - // For successful cases, record that functions were called but don't make real calls - mockEnableSilentMode(); - - // This is just a mock call that won't make real API requests - // We're using mockExpandTask which is already a mock function - const expandedTask = await mockExpandTask( - parseInt(args.id, 10), - args.num, - args.research || false, - args.prompt || '', - { mcpLog: mockLogger, session: options.session } - ); - - mockDisableSilentMode(); - - return { - success: true, - data: { - task: expandedTask, - subtasksAdded: expandedTask.subtasks.length, - hasExistingSubtasks: false - }, - fromCache: false - }; - } - - test('should expand a task with subtasks', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: testTasksPath, - id: '3', // ID 3 exists in sampleTasks with status 'pending' - num: 2 - }; - - // Act - const result = await testExpandTask(args, mockLogger, { session: mockSession }); - - // Assert - expect(result.success).toBe(true); - expect(result.data.task).toBeDefined(); - expect(result.data.task.subtasks).toBeDefined(); - expect(result.data.task.subtasks.length).toBe(2); - expect(mockExpandTask).toHaveBeenCalledWith( - 3, // Task ID as number - 2, // num parameter - false, // useResearch - '', // prompt - expect.objectContaining({ - mcpLog: mockLogger, - session: mockSession - }) - ); - expect(mockEnableSilentMode).toHaveBeenCalled(); - expect(mockDisableSilentMode).toHaveBeenCalled(); - }); - - test('should handle missing task ID', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: testTasksPath - // id is intentionally missing - }; - - // Act - const result = await testExpandTask(args, mockLogger, { session: mockSession }); - - // Assert - expect(result.success).toBe(false); - expect(result.error.code).toBe('INPUT_VALIDATION_ERROR'); - expect(mockLogger.error).toHaveBeenCalled(); - // Make sure no real expand calls were made - expect(mockExpandTask).not.toHaveBeenCalled(); - }); - - test('should handle non-existent task ID', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: testTasksPath, - id: '999' // Non-existent task ID - }; - - // Act - const result = await testExpandTask(args, mockLogger, { session: mockSession }); - - // Assert - expect(result.success).toBe(false); - expect(result.error.code).toBe('TASK_NOT_FOUND'); - expect(mockLogger.error).toHaveBeenCalled(); - // Make sure no real expand calls were made - expect(mockExpandTask).not.toHaveBeenCalled(); - }); - - test('should handle completed tasks', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: testTasksPath, - id: '1' // Task with 'done' status in sampleTasks - }; - - // Act - const result = await testExpandTask(args, mockLogger, { session: mockSession }); - - // Assert - expect(result.success).toBe(false); - expect(result.error.code).toBe('TASK_COMPLETED'); - expect(mockLogger.error).toHaveBeenCalled(); - // Make sure no real expand calls were made - expect(mockExpandTask).not.toHaveBeenCalled(); - }); - - test('should use AI client when research flag is set', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: testTasksPath, - id: '3', - research: true - }; - - // Act - const result = await testExpandTask(args, mockLogger, { session: mockSession }); - - // Assert - expect(result.success).toBe(true); - expect(mockExpandTask).toHaveBeenCalledWith( - 3, // Task ID as number - undefined, // args.num is undefined - true, // useResearch should be true - '', // prompt - expect.objectContaining({ - mcpLog: mockLogger, - session: mockSession - }) - ); - // Verify the result includes research-backed subtasks - expect(result.data.task.subtasks[0].title).toContain("Research-Backed"); - }); - }); - - describe('expandAllTasksDirect', () => { - // Test wrapper function that returns appropriate results based on the test case - async function testExpandAllTasks(args, mockLogger, options = {}) { - // For successful cases, record that functions were called but don't make real calls - mockEnableSilentMode(); - - // Mock expandAllTasks - const mockExpandAll = jest.fn().mockImplementation(async () => { - // Just simulate success without any real operations - return undefined; // expandAllTasks doesn't return anything - }); - - // Call mock expandAllTasks - await mockExpandAll( - args.num, - args.research || false, - args.prompt || '', - args.force || false, - { mcpLog: mockLogger, session: options.session } - ); - - mockDisableSilentMode(); - - return { - success: true, - data: { - message: "Successfully expanded all pending tasks with subtasks", - details: { - numSubtasks: args.num, - research: args.research || false, - prompt: args.prompt || '', - force: args.force || false - } - } - }; - } - - test('should expand all pending tasks with subtasks', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: testTasksPath, - num: 3 - }; - - // Act - const result = await testExpandAllTasks(args, mockLogger, { session: mockSession }); - - // Assert - expect(result.success).toBe(true); - expect(result.data.message).toBe("Successfully expanded all pending tasks with subtasks"); - expect(result.data.details.numSubtasks).toBe(3); - expect(mockEnableSilentMode).toHaveBeenCalled(); - expect(mockDisableSilentMode).toHaveBeenCalled(); - }); - - test('should handle research flag', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: testTasksPath, - research: true, - num: 2 - }; - - // Act - const result = await testExpandAllTasks(args, mockLogger, { session: mockSession }); - - // Assert - expect(result.success).toBe(true); - expect(result.data.details.research).toBe(true); - expect(mockEnableSilentMode).toHaveBeenCalled(); - expect(mockDisableSilentMode).toHaveBeenCalled(); - }); - - test('should handle force flag', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: testTasksPath, - force: true - }; - - // Act - const result = await testExpandAllTasks(args, mockLogger, { session: mockSession }); - - // Assert - expect(result.success).toBe(true); - expect(result.data.details.force).toBe(true); - expect(mockEnableSilentMode).toHaveBeenCalled(); - expect(mockDisableSilentMode).toHaveBeenCalled(); - }); - - test('should handle additional context/prompt', async () => { - // Arrange - const args = { - projectRoot: testProjectRoot, - file: testTasksPath, - prompt: "Additional context for subtasks" - }; - - // Act - const result = await testExpandAllTasks(args, mockLogger, { session: mockSession }); - - // Assert - expect(result.success).toBe(true); - expect(result.data.details.prompt).toBe("Additional context for subtasks"); - expect(mockEnableSilentMode).toHaveBeenCalled(); - expect(mockDisableSilentMode).toHaveBeenCalled(); - }); - }); -}); \ No newline at end of file + // Set up before each test + beforeEach(() => { + jest.clearAllMocks(); + + // Default mockReadJSON implementation + mockReadJSON.mockReturnValue(JSON.parse(JSON.stringify(sampleTasks))); + + // Default mockFindTaskById implementation + mockFindTaskById.mockImplementation((tasks, taskId) => { + const id = parseInt(taskId, 10); + return tasks.find((t) => t.id === id); + }); + + // Default mockTaskExists implementation + mockTaskExists.mockImplementation((tasks, taskId) => { + const id = parseInt(taskId, 10); + return tasks.some((t) => t.id === id); + }); + + // Default findTasksJsonPath implementation + mockFindTasksJsonPath.mockImplementation((args) => { + // Mock returning null for non-existent files + if (args.file === 'non-existent-file.json') { + return null; + } + return testTasksPath; + }); + }); + + describe('listTasksDirect', () => { + // Test wrapper function that doesn't rely on the actual implementation + async function testListTasks(args, mockLogger) { + // File not found case + if (args.file === 'non-existent-file.json') { + mockLogger.error('Tasks file not found'); + return { + success: false, + error: { + code: 'FILE_NOT_FOUND_ERROR', + message: 'Tasks file not found' + }, + fromCache: false + }; + } + + // Success case + if (!args.status && !args.withSubtasks) { + return { + success: true, + data: { + tasks: sampleTasks.tasks, + stats: { + total: sampleTasks.tasks.length, + completed: sampleTasks.tasks.filter((t) => t.status === 'done') + .length, + inProgress: sampleTasks.tasks.filter( + (t) => t.status === 'in-progress' + ).length, + pending: sampleTasks.tasks.filter((t) => t.status === 'pending') + .length + } + }, + fromCache: false + }; + } + + // Status filter case + if (args.status) { + const filteredTasks = sampleTasks.tasks.filter( + (t) => t.status === args.status + ); + return { + success: true, + data: { + tasks: filteredTasks, + filter: args.status, + stats: { + total: sampleTasks.tasks.length, + filtered: filteredTasks.length + } + }, + fromCache: false + }; + } + + // Include subtasks case + if (args.withSubtasks) { + return { + success: true, + data: { + tasks: sampleTasks.tasks, + includeSubtasks: true, + stats: { + total: sampleTasks.tasks.length + } + }, + fromCache: false + }; + } + + // Default case + return { + success: true, + data: { tasks: [] } + }; + } + + test('should return all tasks when no filter is provided', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: testTasksPath + }; + + // Act + const result = await testListTasks(args, mockLogger); + + // Assert + expect(result.success).toBe(true); + expect(result.data.tasks.length).toBe(sampleTasks.tasks.length); + expect(result.data.stats.total).toBe(sampleTasks.tasks.length); + }); + + test('should filter tasks by status', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: testTasksPath, + status: 'pending' + }; + + // Act + const result = await testListTasks(args, mockLogger); + + // Assert + expect(result.success).toBe(true); + expect(result.data.filter).toBe('pending'); + // Should only include pending tasks + result.data.tasks.forEach((task) => { + expect(task.status).toBe('pending'); + }); + }); + + test('should include subtasks when requested', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: testTasksPath, + withSubtasks: true + }; + + // Act + const result = await testListTasks(args, mockLogger); + + // Assert + expect(result.success).toBe(true); + expect(result.data.includeSubtasks).toBe(true); + + // Verify subtasks are included for tasks that have them + const tasksWithSubtasks = result.data.tasks.filter( + (t) => t.subtasks && t.subtasks.length > 0 + ); + expect(tasksWithSubtasks.length).toBeGreaterThan(0); + }); + + test('should handle file not found errors', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: 'non-existent-file.json' + }; + + // Act + const result = await testListTasks(args, mockLogger); + + // Assert + expect(result.success).toBe(false); + expect(result.error.code).toBe('FILE_NOT_FOUND_ERROR'); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); + + describe('expandTaskDirect', () => { + // Test wrapper function that returns appropriate results based on the test case + async function testExpandTask(args, mockLogger, options = {}) { + // Missing task ID case + if (!args.id) { + mockLogger.error('Task ID is required'); + return { + success: false, + error: { + code: 'INPUT_VALIDATION_ERROR', + message: 'Task ID is required' + }, + fromCache: false + }; + } + + // Non-existent task ID case + if (args.id === '999') { + mockLogger.error(`Task with ID ${args.id} not found`); + return { + success: false, + error: { + code: 'TASK_NOT_FOUND', + message: `Task with ID ${args.id} not found` + }, + fromCache: false + }; + } + + // Completed task case + if (args.id === '1') { + mockLogger.error( + `Task ${args.id} is already marked as done and cannot be expanded` + ); + return { + success: false, + error: { + code: 'TASK_COMPLETED', + message: `Task ${args.id} is already marked as done and cannot be expanded` + }, + fromCache: false + }; + } + + // For successful cases, record that functions were called but don't make real calls + mockEnableSilentMode(); + + // This is just a mock call that won't make real API requests + // We're using mockExpandTask which is already a mock function + const expandedTask = await mockExpandTask( + parseInt(args.id, 10), + args.num, + args.research || false, + args.prompt || '', + { mcpLog: mockLogger, session: options.session } + ); + + mockDisableSilentMode(); + + return { + success: true, + data: { + task: expandedTask, + subtasksAdded: expandedTask.subtasks.length, + hasExistingSubtasks: false + }, + fromCache: false + }; + } + + test('should expand a task with subtasks', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: testTasksPath, + id: '3', // ID 3 exists in sampleTasks with status 'pending' + num: 2 + }; + + // Act + const result = await testExpandTask(args, mockLogger, { + session: mockSession + }); + + // Assert + expect(result.success).toBe(true); + expect(result.data.task).toBeDefined(); + expect(result.data.task.subtasks).toBeDefined(); + expect(result.data.task.subtasks.length).toBe(2); + expect(mockExpandTask).toHaveBeenCalledWith( + 3, // Task ID as number + 2, // num parameter + false, // useResearch + '', // prompt + expect.objectContaining({ + mcpLog: mockLogger, + session: mockSession + }) + ); + expect(mockEnableSilentMode).toHaveBeenCalled(); + expect(mockDisableSilentMode).toHaveBeenCalled(); + }); + + test('should handle missing task ID', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: testTasksPath + // id is intentionally missing + }; + + // Act + const result = await testExpandTask(args, mockLogger, { + session: mockSession + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error.code).toBe('INPUT_VALIDATION_ERROR'); + expect(mockLogger.error).toHaveBeenCalled(); + // Make sure no real expand calls were made + expect(mockExpandTask).not.toHaveBeenCalled(); + }); + + test('should handle non-existent task ID', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: testTasksPath, + id: '999' // Non-existent task ID + }; + + // Act + const result = await testExpandTask(args, mockLogger, { + session: mockSession + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error.code).toBe('TASK_NOT_FOUND'); + expect(mockLogger.error).toHaveBeenCalled(); + // Make sure no real expand calls were made + expect(mockExpandTask).not.toHaveBeenCalled(); + }); + + test('should handle completed tasks', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: testTasksPath, + id: '1' // Task with 'done' status in sampleTasks + }; + + // Act + const result = await testExpandTask(args, mockLogger, { + session: mockSession + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error.code).toBe('TASK_COMPLETED'); + expect(mockLogger.error).toHaveBeenCalled(); + // Make sure no real expand calls were made + expect(mockExpandTask).not.toHaveBeenCalled(); + }); + + test('should use AI client when research flag is set', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: testTasksPath, + id: '3', + research: true + }; + + // Act + const result = await testExpandTask(args, mockLogger, { + session: mockSession + }); + + // Assert + expect(result.success).toBe(true); + expect(mockExpandTask).toHaveBeenCalledWith( + 3, // Task ID as number + undefined, // args.num is undefined + true, // useResearch should be true + '', // prompt + expect.objectContaining({ + mcpLog: mockLogger, + session: mockSession + }) + ); + // Verify the result includes research-backed subtasks + expect(result.data.task.subtasks[0].title).toContain('Research-Backed'); + }); + }); + + describe('expandAllTasksDirect', () => { + // Test wrapper function that returns appropriate results based on the test case + async function testExpandAllTasks(args, mockLogger, options = {}) { + // For successful cases, record that functions were called but don't make real calls + mockEnableSilentMode(); + + // Mock expandAllTasks + const mockExpandAll = jest.fn().mockImplementation(async () => { + // Just simulate success without any real operations + return undefined; // expandAllTasks doesn't return anything + }); + + // Call mock expandAllTasks + await mockExpandAll( + args.num, + args.research || false, + args.prompt || '', + args.force || false, + { mcpLog: mockLogger, session: options.session } + ); + + mockDisableSilentMode(); + + return { + success: true, + data: { + message: 'Successfully expanded all pending tasks with subtasks', + details: { + numSubtasks: args.num, + research: args.research || false, + prompt: args.prompt || '', + force: args.force || false + } + } + }; + } + + test('should expand all pending tasks with subtasks', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: testTasksPath, + num: 3 + }; + + // Act + const result = await testExpandAllTasks(args, mockLogger, { + session: mockSession + }); + + // Assert + expect(result.success).toBe(true); + expect(result.data.message).toBe( + 'Successfully expanded all pending tasks with subtasks' + ); + expect(result.data.details.numSubtasks).toBe(3); + expect(mockEnableSilentMode).toHaveBeenCalled(); + expect(mockDisableSilentMode).toHaveBeenCalled(); + }); + + test('should handle research flag', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: testTasksPath, + research: true, + num: 2 + }; + + // Act + const result = await testExpandAllTasks(args, mockLogger, { + session: mockSession + }); + + // Assert + expect(result.success).toBe(true); + expect(result.data.details.research).toBe(true); + expect(mockEnableSilentMode).toHaveBeenCalled(); + expect(mockDisableSilentMode).toHaveBeenCalled(); + }); + + test('should handle force flag', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: testTasksPath, + force: true + }; + + // Act + const result = await testExpandAllTasks(args, mockLogger, { + session: mockSession + }); + + // Assert + expect(result.success).toBe(true); + expect(result.data.details.force).toBe(true); + expect(mockEnableSilentMode).toHaveBeenCalled(); + expect(mockDisableSilentMode).toHaveBeenCalled(); + }); + + test('should handle additional context/prompt', async () => { + // Arrange + const args = { + projectRoot: testProjectRoot, + file: testTasksPath, + prompt: 'Additional context for subtasks' + }; + + // Act + const result = await testExpandAllTasks(args, mockLogger, { + session: mockSession + }); + + // Assert + expect(result.success).toBe(true); + expect(result.data.details.prompt).toBe( + 'Additional context for subtasks' + ); + expect(mockEnableSilentMode).toHaveBeenCalled(); + expect(mockDisableSilentMode).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-client-utils.test.js b/tests/unit/ai-client-utils.test.js index b924b094..b1c8ae06 100644 --- a/tests/unit/ai-client-utils.test.js +++ b/tests/unit/ai-client-utils.test.js @@ -4,331 +4,347 @@ */ import { jest } from '@jest/globals'; -import { - getAnthropicClientForMCP, - getPerplexityClientForMCP, - getModelConfig, - getBestAvailableAIModel, - handleClaudeError +import { + getAnthropicClientForMCP, + getPerplexityClientForMCP, + getModelConfig, + getBestAvailableAIModel, + handleClaudeError } from '../../mcp-server/src/core/utils/ai-client-utils.js'; // Mock the Anthropic constructor jest.mock('@anthropic-ai/sdk', () => { - return { - Anthropic: jest.fn().mockImplementation(() => { - return { - messages: { - create: jest.fn().mockResolvedValue({}) - } - }; - }) - }; + return { + Anthropic: jest.fn().mockImplementation(() => { + return { + messages: { + create: jest.fn().mockResolvedValue({}) + } + }; + }) + }; }); // Mock the OpenAI dynamic import jest.mock('openai', () => { - return { - default: jest.fn().mockImplementation(() => { - return { - chat: { - completions: { - create: jest.fn().mockResolvedValue({}) - } - } - }; - }) - }; + return { + default: jest.fn().mockImplementation(() => { + return { + chat: { + completions: { + create: jest.fn().mockResolvedValue({}) + } + } + }; + }) + }; }); describe('AI Client Utilities', () => { - const originalEnv = process.env; + const originalEnv = process.env; - beforeEach(() => { - // Reset process.env before each test - process.env = { ...originalEnv }; - - // Clear all mocks - jest.clearAllMocks(); - }); + beforeEach(() => { + // Reset process.env before each test + process.env = { ...originalEnv }; - afterAll(() => { - // Restore process.env - process.env = originalEnv; - }); + // Clear all mocks + jest.clearAllMocks(); + }); - describe('getAnthropicClientForMCP', () => { - it('should initialize client with API key from session', () => { - // Setup - const session = { - env: { - ANTHROPIC_API_KEY: 'test-key-from-session' - } - }; - const mockLog = { error: jest.fn() }; + afterAll(() => { + // Restore process.env + process.env = originalEnv; + }); - // Execute - const client = getAnthropicClientForMCP(session, mockLog); + describe('getAnthropicClientForMCP', () => { + it('should initialize client with API key from session', () => { + // Setup + const session = { + env: { + ANTHROPIC_API_KEY: 'test-key-from-session' + } + }; + const mockLog = { error: jest.fn() }; - // Verify - expect(client).toBeDefined(); - expect(client.messages.create).toBeDefined(); - expect(mockLog.error).not.toHaveBeenCalled(); - }); + // Execute + const client = getAnthropicClientForMCP(session, mockLog); - it('should fall back to process.env when session key is missing', () => { - // Setup - process.env.ANTHROPIC_API_KEY = 'test-key-from-env'; - const session = { env: {} }; - const mockLog = { error: jest.fn() }; + // Verify + expect(client).toBeDefined(); + expect(client.messages.create).toBeDefined(); + expect(mockLog.error).not.toHaveBeenCalled(); + }); - // Execute - const client = getAnthropicClientForMCP(session, mockLog); + it('should fall back to process.env when session key is missing', () => { + // Setup + process.env.ANTHROPIC_API_KEY = 'test-key-from-env'; + const session = { env: {} }; + const mockLog = { error: jest.fn() }; - // Verify - expect(client).toBeDefined(); - expect(mockLog.error).not.toHaveBeenCalled(); - }); + // Execute + const client = getAnthropicClientForMCP(session, mockLog); - it('should throw error when API key is missing', () => { - // Setup - delete process.env.ANTHROPIC_API_KEY; - const session = { env: {} }; - const mockLog = { error: jest.fn() }; + // Verify + expect(client).toBeDefined(); + expect(mockLog.error).not.toHaveBeenCalled(); + }); - // Execute & Verify - expect(() => getAnthropicClientForMCP(session, mockLog)).toThrow(); - expect(mockLog.error).toHaveBeenCalled(); - }); - }); + it('should throw error when API key is missing', () => { + // Setup + delete process.env.ANTHROPIC_API_KEY; + const session = { env: {} }; + const mockLog = { error: jest.fn() }; - describe('getPerplexityClientForMCP', () => { - it('should initialize client with API key from session', async () => { - // Setup - const session = { - env: { - PERPLEXITY_API_KEY: 'test-perplexity-key' - } - }; - const mockLog = { error: jest.fn() }; + // Execute & Verify + expect(() => getAnthropicClientForMCP(session, mockLog)).toThrow(); + expect(mockLog.error).toHaveBeenCalled(); + }); + }); - // Execute - const client = await getPerplexityClientForMCP(session, mockLog); + describe('getPerplexityClientForMCP', () => { + it('should initialize client with API key from session', async () => { + // Setup + const session = { + env: { + PERPLEXITY_API_KEY: 'test-perplexity-key' + } + }; + const mockLog = { error: jest.fn() }; - // Verify - expect(client).toBeDefined(); - expect(client.chat.completions.create).toBeDefined(); - expect(mockLog.error).not.toHaveBeenCalled(); - }); + // Execute + const client = await getPerplexityClientForMCP(session, mockLog); - it('should throw error when API key is missing', async () => { - // Setup - delete process.env.PERPLEXITY_API_KEY; - const session = { env: {} }; - const mockLog = { error: jest.fn() }; + // Verify + expect(client).toBeDefined(); + expect(client.chat.completions.create).toBeDefined(); + expect(mockLog.error).not.toHaveBeenCalled(); + }); - // Execute & Verify - await expect(getPerplexityClientForMCP(session, mockLog)).rejects.toThrow(); - expect(mockLog.error).toHaveBeenCalled(); - }); - }); + it('should throw error when API key is missing', async () => { + // Setup + delete process.env.PERPLEXITY_API_KEY; + const session = { env: {} }; + const mockLog = { error: jest.fn() }; - describe('getModelConfig', () => { - it('should get model config from session', () => { - // Setup - const session = { - env: { - MODEL: 'claude-3-opus', - MAX_TOKENS: '8000', - TEMPERATURE: '0.5' - } - }; + // Execute & Verify + await expect( + getPerplexityClientForMCP(session, mockLog) + ).rejects.toThrow(); + expect(mockLog.error).toHaveBeenCalled(); + }); + }); - // Execute - const config = getModelConfig(session); + describe('getModelConfig', () => { + it('should get model config from session', () => { + // Setup + const session = { + env: { + MODEL: 'claude-3-opus', + MAX_TOKENS: '8000', + TEMPERATURE: '0.5' + } + }; - // Verify - expect(config).toEqual({ - model: 'claude-3-opus', - maxTokens: 8000, - temperature: 0.5 - }); - }); + // Execute + const config = getModelConfig(session); - it('should use default values when session values are missing', () => { - // Setup - const session = { - env: { - // No values - } - }; + // Verify + expect(config).toEqual({ + model: 'claude-3-opus', + maxTokens: 8000, + temperature: 0.5 + }); + }); - // Execute - const config = getModelConfig(session); + it('should use default values when session values are missing', () => { + // Setup + const session = { + env: { + // No values + } + }; - // Verify - expect(config).toEqual({ - model: 'claude-3-7-sonnet-20250219', - maxTokens: 64000, - temperature: 0.2 - }); - }); + // Execute + const config = getModelConfig(session); - it('should allow custom defaults', () => { - // Setup - const session = { env: {} }; - const customDefaults = { - model: 'custom-model', - maxTokens: 2000, - temperature: 0.3 - }; + // Verify + expect(config).toEqual({ + model: 'claude-3-7-sonnet-20250219', + maxTokens: 64000, + temperature: 0.2 + }); + }); - // Execute - const config = getModelConfig(session, customDefaults); + it('should allow custom defaults', () => { + // Setup + const session = { env: {} }; + const customDefaults = { + model: 'custom-model', + maxTokens: 2000, + temperature: 0.3 + }; - // Verify - expect(config).toEqual(customDefaults); - }); - }); + // Execute + const config = getModelConfig(session, customDefaults); - describe('getBestAvailableAIModel', () => { - it('should return Perplexity for research when available', async () => { - // Setup - const session = { - env: { - PERPLEXITY_API_KEY: 'test-perplexity-key', - ANTHROPIC_API_KEY: 'test-anthropic-key' - } - }; - const mockLog = { warn: jest.fn(), info: jest.fn(), error: jest.fn() }; + // Verify + expect(config).toEqual(customDefaults); + }); + }); - // Execute - const result = await getBestAvailableAIModel(session, { requiresResearch: true }, mockLog); + describe('getBestAvailableAIModel', () => { + it('should return Perplexity for research when available', async () => { + // Setup + const session = { + env: { + PERPLEXITY_API_KEY: 'test-perplexity-key', + ANTHROPIC_API_KEY: 'test-anthropic-key' + } + }; + const mockLog = { warn: jest.fn(), info: jest.fn(), error: jest.fn() }; - // Verify - expect(result.type).toBe('perplexity'); - expect(result.client).toBeDefined(); - }); + // Execute + const result = await getBestAvailableAIModel( + session, + { requiresResearch: true }, + mockLog + ); - it('should return Claude when Perplexity is not available and Claude is not overloaded', async () => { - // Setup - const originalPerplexityKey = process.env.PERPLEXITY_API_KEY; - delete process.env.PERPLEXITY_API_KEY; // Make sure Perplexity is not available in process.env + // Verify + expect(result.type).toBe('perplexity'); + expect(result.client).toBeDefined(); + }); - const session = { - env: { - ANTHROPIC_API_KEY: 'test-anthropic-key' - // Purposely not including PERPLEXITY_API_KEY - } - }; - const mockLog = { warn: jest.fn(), info: jest.fn(), error: jest.fn() }; - - try { - // Execute - const result = await getBestAvailableAIModel(session, { requiresResearch: true }, mockLog); + it('should return Claude when Perplexity is not available and Claude is not overloaded', async () => { + // Setup + const originalPerplexityKey = process.env.PERPLEXITY_API_KEY; + delete process.env.PERPLEXITY_API_KEY; // Make sure Perplexity is not available in process.env - // Verify - // In our implementation, we prioritize research capability through Perplexity - // so if we're testing research but Perplexity isn't available, Claude is used - expect(result.type).toBe('claude'); - expect(result.client).toBeDefined(); - expect(mockLog.warn).toHaveBeenCalled(); // Warning about using Claude instead of Perplexity - } finally { - // Restore original env variables - if (originalPerplexityKey) { - process.env.PERPLEXITY_API_KEY = originalPerplexityKey; - } - } - }); + const session = { + env: { + ANTHROPIC_API_KEY: 'test-anthropic-key' + // Purposely not including PERPLEXITY_API_KEY + } + }; + const mockLog = { warn: jest.fn(), info: jest.fn(), error: jest.fn() }; - it('should fall back to Claude as last resort when overloaded', async () => { - // Setup - const session = { - env: { - ANTHROPIC_API_KEY: 'test-anthropic-key' - } - }; - const mockLog = { warn: jest.fn(), info: jest.fn(), error: jest.fn() }; + try { + // Execute + const result = await getBestAvailableAIModel( + session, + { requiresResearch: true }, + mockLog + ); - // Execute - const result = await getBestAvailableAIModel(session, { claudeOverloaded: true }, mockLog); + // Verify + // In our implementation, we prioritize research capability through Perplexity + // so if we're testing research but Perplexity isn't available, Claude is used + expect(result.type).toBe('claude'); + expect(result.client).toBeDefined(); + expect(mockLog.warn).toHaveBeenCalled(); // Warning about using Claude instead of Perplexity + } finally { + // Restore original env variables + if (originalPerplexityKey) { + process.env.PERPLEXITY_API_KEY = originalPerplexityKey; + } + } + }); - // Verify - expect(result.type).toBe('claude'); - expect(result.client).toBeDefined(); - expect(mockLog.warn).toHaveBeenCalled(); // Warning about Claude overloaded - }); + it('should fall back to Claude as last resort when overloaded', async () => { + // Setup + const session = { + env: { + ANTHROPIC_API_KEY: 'test-anthropic-key' + } + }; + const mockLog = { warn: jest.fn(), info: jest.fn(), error: jest.fn() }; - it('should throw error when no models are available', async () => { - // Setup - delete process.env.ANTHROPIC_API_KEY; - delete process.env.PERPLEXITY_API_KEY; - const session = { env: {} }; - const mockLog = { warn: jest.fn(), info: jest.fn(), error: jest.fn() }; + // Execute + const result = await getBestAvailableAIModel( + session, + { claudeOverloaded: true }, + mockLog + ); - // Execute & Verify - await expect(getBestAvailableAIModel(session, {}, mockLog)).rejects.toThrow(); - }); - }); + // Verify + expect(result.type).toBe('claude'); + expect(result.client).toBeDefined(); + expect(mockLog.warn).toHaveBeenCalled(); // Warning about Claude overloaded + }); - describe('handleClaudeError', () => { - it('should handle overloaded error', () => { - // Setup - const error = { - type: 'error', - error: { - type: 'overloaded_error', - message: 'Claude is overloaded' - } - }; + it('should throw error when no models are available', async () => { + // Setup + delete process.env.ANTHROPIC_API_KEY; + delete process.env.PERPLEXITY_API_KEY; + const session = { env: {} }; + const mockLog = { warn: jest.fn(), info: jest.fn(), error: jest.fn() }; - // Execute - const message = handleClaudeError(error); + // Execute & Verify + await expect( + getBestAvailableAIModel(session, {}, mockLog) + ).rejects.toThrow(); + }); + }); - // Verify - expect(message).toContain('overloaded'); - }); + describe('handleClaudeError', () => { + it('should handle overloaded error', () => { + // Setup + const error = { + type: 'error', + error: { + type: 'overloaded_error', + message: 'Claude is overloaded' + } + }; - it('should handle rate limit error', () => { - // Setup - const error = { - type: 'error', - error: { - type: 'rate_limit_error', - message: 'Rate limit exceeded' - } - }; + // Execute + const message = handleClaudeError(error); - // Execute - const message = handleClaudeError(error); + // Verify + expect(message).toContain('overloaded'); + }); - // Verify - expect(message).toContain('rate limit'); - }); + it('should handle rate limit error', () => { + // Setup + const error = { + type: 'error', + error: { + type: 'rate_limit_error', + message: 'Rate limit exceeded' + } + }; - it('should handle timeout error', () => { - // Setup - const error = { - message: 'Request timed out after 60 seconds' - }; + // Execute + const message = handleClaudeError(error); - // Execute - const message = handleClaudeError(error); + // Verify + expect(message).toContain('rate limit'); + }); - // Verify - expect(message).toContain('timed out'); - }); + it('should handle timeout error', () => { + // Setup + const error = { + message: 'Request timed out after 60 seconds' + }; - it('should handle generic errors', () => { - // Setup - const error = { - message: 'Something went wrong' - }; + // Execute + const message = handleClaudeError(error); - // Execute - const message = handleClaudeError(error); + // Verify + expect(message).toContain('timed out'); + }); - // Verify - expect(message).toContain('Error communicating with Claude'); - }); - }); -}); \ No newline at end of file + it('should handle generic errors', () => { + // Setup + const error = { + message: 'Something went wrong' + }; + + // Execute + const message = handleClaudeError(error); + + // Verify + expect(message).toContain('Error communicating with Claude'); + }); + }); +}); 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 574ad632..8be90e1d 100644 --- a/tests/unit/ui.test.js +++ b/tests/unit/ui.test.js @@ -3,242 +3,244 @@ */ 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, { - 'pending': 20, - 'in-progress': 15, - 'blocked': 5 - }); - expect(result).toContain('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).toContain('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).toContain('100%'); - }); + const result = formatDependenciesWithStatus(dependencies, allTasks, true); - test('should handle invalid percentages by clamping', () => { - const result1 = createProgressBar(0, 10); - expect(result1).toContain('0%'); - - const result2 = createProgressBar(100, 10); - expect(result2).toContain('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'); - test('should support status breakdown in the progress bar', () => { - const result = createProgressBar(30, 10, { - 'pending': 30, - 'in-progress': 20, - 'blocked': 10, - 'deferred': 5, - 'cancelled': 5 - }); - - expect(result).toContain('40%'); - }); - }); + // Test that it's a comma-separated list + expect(result.split(', ').length).toBe(3); + }); - describe('getComplexityWithColor function', () => { - test('should return high complexity in red', () => { - const result = getComplexityWithColor(8); - expect(result).toMatch(/8/); - expect(result).toContain('🔴'); - }); + test('should return "None" for empty dependencies', () => { + const result = formatDependenciesWithStatus([], []); + expect(result).toBe('None'); + }); - test('should return medium complexity in yellow', () => { - const result = getComplexityWithColor(5); - expect(result).toMatch(/5/); - expect(result).toContain('🟡'); - }); + test('should handle missing tasks in the task list', () => { + const dependencies = [1, 999]; + const allTasks = [{ id: 1, status: 'done' }]; - test('should return low complexity in green', () => { - const result = getComplexityWithColor(3); - expect(result).toMatch(/3/); - expect(result).toContain('🟢'); - }); + const result = formatDependenciesWithStatus(dependencies, allTasks); + expect(result).toBe('1, 999 (Not found)'); + }); + }); - test('should handle non-numeric inputs', () => { - const result = getComplexityWithColor('high'); - expect(result).toMatch(/high/); - expect(result).toContain('🔴'); - }); - }); -}); \ No newline at end of file + describe('createProgressBar function', () => { + test('should create a progress bar with the correct percentage', () => { + const result = createProgressBar(50, 10, { + pending: 20, + 'in-progress': 15, + blocked: 5 + }); + expect(result).toContain('50%'); + }); + + test('should handle 0% progress', () => { + const result = createProgressBar(0, 10); + expect(result).toContain('0%'); + }); + + test('should handle 100% progress', () => { + const result = createProgressBar(100, 10); + expect(result).toContain('100%'); + }); + + test('should handle invalid percentages by clamping', () => { + const result1 = createProgressBar(0, 10); + expect(result1).toContain('0%'); + + const result2 = createProgressBar(100, 10); + expect(result2).toContain('100%'); + }); + + test('should support status breakdown in the progress bar', () => { + const result = createProgressBar(30, 10, { + pending: 30, + 'in-progress': 20, + blocked: 10, + deferred: 5, + cancelled: 5 + }); + + expect(result).toContain('40%'); + }); + }); + + 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 de8b266b..7ad2465e 100644 --- a/tests/unit/utils.test.js +++ b/tests/unit/utils.test.js @@ -8,548 +8,607 @@ 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 text prefixes - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[INFO]')); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[WARN]')); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[ERROR]')); - }); + // 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), - 'utf8' - ); - }); + 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 text prefixes + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('[INFO]') + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('[WARN]') + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('[ERROR]') + ); + }); - 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), + 'utf8' + ); + }); + + 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' + }); + }); +});