From de28026b3266e47d38cc9656db3b8f275b96812b Mon Sep 17 00:00:00 2001 From: Chris Covington Date: Tue, 8 Jul 2025 00:59:21 -0700 Subject: [PATCH] Phase 1 refactoring path resolution. (#877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: implement centralized path management system with initTaskMaster This commit introduces a comprehensive refactoring of the TaskMaster CLI's path handling system, consolidating all path resolution logic into a centralized initTaskMaster function and TaskMaster class. This architectural change eliminates circular dependencies and provides consistent path management across all CLI commands. Key changes: • **Created new TaskMaster class and initTaskMaster factory function** in src/task-master.js - Centralized path resolution with boolean override logic (string = explicit path, true = required search, false/undefined = optional) - Built-in error handling with automatic process.exit() for missing required paths - Immutable path objects with getter methods for safe access • **Replaced findProjectRoot() calls throughout CLI** in scripts/modules/commands.js - Updated all 25+ CLI commands to use initTaskMaster() instead of scattered path handling - Eliminated hundreds of lines of redundant path resolution and error handling code - Consistent project root validation and path discovery across all commands • **Added comprehensive test suite** in tests/unit/task-master.test.js - 22 test cases covering project root detection, path resolution, override validation, and edge cases - Tests use temporary directories with proper cleanup and mock process.exit/console.error - Validates both successful scenarios and error conditions with proper exit codes * bring Usage for Parse PRD back, and revamp initTaskMaster to throw errors not error/exit. * fix(claude-code): recover from CLI JSON truncation bug (#913) (#920) Gracefully handle SyntaxError thrown by @anthropic-ai/claude-code when the CLI truncates large JSON outputs (4–16 kB cut-offs).\n\nKey points:\n• Detect JSON parse error + existing buffered text in both doGenerate() and doStream() code paths.\n• Convert the failure into a recoverable 'truncated' finish state and push a provider-warning.\n• Allows Task Master to continue parsing long PRDs / expand-task operations instead of crashing.\n\nA patch changeset (.changeset/claude-code-json-truncation.md) is included for the next release.\n\nRef: eyaltoledano/claude-task-master#913 * docs: fix gemini-cli authentication documentation (#923) Remove erroneous 'gemini auth login' command references and replace with correct 'gemini' command authentication flow. Update documentation to reflect proper OAuth setup process via the gemini CLI interactive interface. * fix: .gitignore missing trailing newline during project initialization (#855) * Support for Additional Anthropic Models on Bedrock (#870) * Add additional Anthropic Models for Bedrock * Update Models Docs from `scripts/modules/supported-models.json` * feat(models): add additional Bedrock supported models * docs: Auto-update and format models.md * fix: Ensure projectRoot is a string (potential WSL fix) (#892) * ensure projectRoot is a string * add changeset * Fix/spelling mistakes (#876) * docs: Auto-update and format models.md * fix: correct typos in documentation for parse-prd and taskmaster commands - Updated the `parse-prd` documentation to fix the spelling of "multiple." - Clarified the description of the `id` parameter in the `taskmaster` documentation to ensure proper syntax and readability. --------- Co-authored-by: github-actions[bot] * Fix `rules` command to use reliable project root detection like other commands (#908) * update/fix projectRoot call for consistency * internal naming consistency * add changeset * fix: Subtask generation fails on gemini-2.5-pro (#852) * fix: clarify details format in task expansion prompt * chore: add changeset * fix: use tag-specific complexity reports (#857) * fix(expand-task): Use tag-specific complexity reports - Add getTagAwareFilePath utility function to resolve tag-specific file paths - Update expandTask to use tag-aware complexity report paths - Fix issue where expand-task always used default complexity report - Add comprehensive tests for getTagAwareFilePath utility - Ensure proper handling of file extensions and directory structures Fixes #850: Expanding tasks not using tag-specific complexity reports The expandTask function now correctly uses complexity reports specific to the current tag context (e.g., task-complexity-report_feature-branch.json) instead of always using the default task-complexity-report.json file. This enables proper task expansion behavior when working with multiple tag contexts, ensuring complexity analysis is tag-specific and accurate. * chore: Add changeset for tag-specific complexity reports fix * test(expand-task): Add tests for tag-specific complexity report integration - Introduced a new test suite for verifying the integration of tag-specific complexity reports in the expandTask function. - Added a test case to ensure the correct complexity report is used when available for a specific tag. - Mocked file system interactions to simulate the presence of tag-specific complexity reports. This enhances the test coverage for task expansion behavior, ensuring it accurately reflects the complexity analysis based on the current tag context. * refactor(task-manager): unify and simplify tag-aware file path logic and tests - Reformatted imports and cleaned up comments in test files for readability - Centralized mocks: moved getTagAwareFilePath & slugifyTagForFilePath mocks to setup.js for consistency and maintainability - Simplified utils/getTagAwareFilePath: replaced manual parsing with path.parse() & path.format(); improved extension handling - Enhanced test mocks for path.parse, path.format & reset path.join in beforeEach to avoid interference - All tests now pass consistently; no change in functionality * fix: prevent tag corruption in bulk updates (#856) * fix(task-manager): prevent tag corruption in bulk updates and add tag preservation test - Fix writeJSON call in scripts/modules/task-manager/update-tasks.js (line 469) to include projectRoot and tag parameters. - Ensure tagged task lists maintain data integrity during bulk updates, preventing task disappearance in tagged contexts. - Update MCP tools to properly pass tag context through the call chain. - Introduce a comprehensive test case to verify that all tags are preserved when updating tasks, covering both master and feature-branch scenarios. Addresses an issue where bulk updates could corrupt tasks.json in tagged task list structures, reinforcing task management robustness. * style(tests): format task data in update-tasks test * fix: Critical writeJSON Context Fixes - Prevent Tag Corruption (#910) * feat(tasks): Fix critical tag corruption bug in task management - Fixed missing context parameters in writeJSON calls across add-task, remove-task, and add-subtask functions - Added projectRoot and tag parameters to prevent data corruption in multi-tag environments - Re-enabled generateTaskFiles calls to ensure markdown files are updated after operations - Enhanced add_subtask MCP tool with tag parameter support - Refactored addSubtaskDirect function to properly pass context to core logic - Streamlined codebase by removing deprecated functionality This resolves the critical bug where task operations in one tag context would corrupt or delete tasks from other tags in tasks.json. * feat(task-manager): Enhance addSubtask with current tag support - Added `getCurrentTag` utility to retrieve the current tag context for task operations. - Updated `addSubtask` to use the current tag when reading and writing tasks, ensuring proper context handling. - Refactored tests to accommodate changes in the `addSubtask` function, ensuring accurate mock implementations and expectations. - Cleaned up test cases for better readability and maintainability. This improves task management by preventing tag-related data corruption and enhances the overall functionality of the task manager. * feat(remove-task): Add tag support for task removal and enhance error handling - Introduced `tag` parameter in `removeTaskDirect` to specify context for task operations, improving multi-tag support. - Updated logging to include tag context in messages for better traceability. - Refactored task removal logic to streamline the process and improve error reporting. - Added comprehensive unit tests to validate tag handling and ensure robust error management. This enhancement prevents task data corruption across different tags and improves the overall reliability of the task management system. * feat(add-task): Add projectRoot and tag parameters to addTask tests - Updated `addTask` unit tests to include `projectRoot` and `tag` parameters for better context handling. - Enhanced test cases to ensure accurate expectations and improve overall test coverage. This change aligns with recent enhancements in task management, ensuring consistency across task operations. * feat(set-task-status): Add tag parameter support and enhance task status handling - Introduced `tag` parameter in `setTaskStatusDirect` and related functions to improve context management in multi-tag environments. - Updated `writeJSON` calls to ensure task data integrity across different tags. - Enhanced unit tests to validate tag preservation during task status updates, ensuring robust functionality. This change aligns with recent improvements in task management, preventing data corruption and enhancing overall reliability. * feat(tag-management): Enhance writeJSON calls to preserve tag context - Updated `writeJSON` calls in `createTag`, `deleteTag`, `renameTag`, `copyTag`, and `enhanceTagsWithMetadata` to include `projectRoot` for better context management and to prevent tag corruption. - Added comprehensive unit tests for tag management functions to ensure data integrity and proper tag handling during operations. This change improves the reliability of tag management by ensuring that operations do not corrupt existing tags and maintains the overall structure of the task data. * feat(expand-task): Update writeJSON to include projectRoot and tag context - Modified `writeJSON` call in `expandTaskDirect` to pass `projectRoot` and `tag` parameters, ensuring proper context management when saving tasks.json. - This change aligns with recent enhancements in task management, preventing potential data corruption and improving overall reliability. * feat(fix-dependencies): Add projectRoot and tag parameters for enhanced context management - Updated `fixDependenciesDirect` and `registerFixDependenciesTool` to include `projectRoot` and `tag` parameters, improving context handling during dependency fixes. - Introduced a new unit test for `fixDependenciesCommand` to ensure proper preservation of projectRoot and tag data in JSON outputs. This change enhances the reliability of dependency management by ensuring that context is maintained across operations, preventing potential data issues. * fix(context): propagate projectRoot and tag through dependency, expansion, status-update and tag-management commands to prevent cross-tag data corruption * test(fix-dependencies): Enhance unit tests for fixDependenciesCommand - Refactored tests to use unstable mocks for utils, ui, and task-manager modules, improving isolation and reliability. - Added checks for process.exit to ensure proper handling of invalid data scenarios. - Updated test cases to verify writeJSON calls with projectRoot and tag parameters, ensuring accurate context preservation during dependency fixes. This change strengthens the test suite for dependency management, ensuring robust functionality and preventing potential data issues. * chore(plan): remove outdated fix plan for `writeJSON` context parameters * feat: Add gemini-cli provider integration for Task Master (#897) * feat: Add gemini-cli provider integration for Task Master This commit adds comprehensive support for the Gemini CLI provider, enabling users to leverage Google's Gemini models through OAuth authentication via the gemini CLI tool. This integration provides a seamless experience for users who prefer using their existing Google account authentication rather than managing API keys. ## Implementation Details ### Provider Class (`src/ai-providers/gemini-cli.js`) - Created GeminiCliProvider extending BaseAIProvider - Implements dual authentication support: - Primary: OAuth authentication via `gemini auth login` (authType: 'oauth-personal') - Secondary: API key authentication for compatibility (authType: 'api-key') - Uses the npm package `ai-sdk-provider-gemini-cli` (v0.0.3) for SDK integration - Properly handles authentication validation without console output ### Model Configuration (`scripts/modules/supported-models.json`) - Added two Gemini models with accurate specifications: - gemini-2.5-pro: 72% SWE score, 65,536 max output tokens - gemini-2.5-flash: 71% SWE score, 65,536 max output tokens - Both models support main, fallback, and research roles - Configured with zero cost (free tier) ### System Integration - Registered provider in PROVIDERS map (`scripts/modules/ai-services-unified.js`) - Added to OPTIONAL_AUTH_PROVIDERS set for flexible authentication - Added GEMINI_CLI constant to provider constants (`src/constants/providers.js`) - Exported GeminiCliProvider from index (`src/ai-providers/index.js`) ### Command Line Support (`scripts/modules/commands.js`) - Added --gemini-cli flag to models command for provider hint - Integrated into model selection logic (setModel function) - Updated error messages to include gemini-cli in provider list - Removed unrelated azure/vertex changes to maintain PR focus ### Documentation (`docs/providers/gemini-cli.md`) - Comprehensive provider documentation emphasizing OAuth-first approach - Clear explanation of why users would choose gemini-cli over standard google provider - Detailed installation, authentication, and configuration instructions - Troubleshooting section with common issues and solutions ### Testing (`tests/unit/ai-providers/gemini-cli.test.js`) - Complete test suite with 12 tests covering all functionality - Tests for both OAuth and API key authentication paths - Error handling and edge case coverage - Updated mocks in ai-services-unified.test.js for integration testing ## Key Design Decisions 1. **OAuth-First Design**: The provider assumes users want to leverage their existing `gemini auth login` credentials, making this the default authentication method. 2. **Authentication Type Mapping**: Discovered through testing that the SDK expects: - 'oauth-personal' for OAuth/CLI authentication (not 'gemini-cli' or 'oauth') - 'api-key' for API key authentication (not 'gemini-api-key') 3. **Silent Operation**: Removed console.log statements from validateAuth to match the pattern used by other providers like claude-code. 4. **Limited Model Support**: Only gemini-2.5-pro and gemini-2.5-flash are available through the CLI, as confirmed by the package author. ## Usage ```bash # Install gemini CLI globally npm install -g @google/gemini-cli # Authenticate with Google account gemini auth login # Configure Task Master to use gemini-cli task-master models --set-main gemini-2.5-pro --gemini-cli # Use Task Master normally task-master new "Create a REST API endpoint" ``` ## Dependencies - Added `ai-sdk-provider-gemini-cli@^0.0.3` to package.json - This package wraps the Google Gemini CLI Core functionality for Vercel AI SDK ## Testing All tests pass (613 total), including the new gemini-cli provider tests. Code has been formatted with biome to maintain consistency. This implementation provides a clean, well-tested integration that follows Task Master's existing patterns while offering users a convenient way to use Gemini models with their existing Google authentication. * feat: implement lazy loading for gemini-cli provider - Move ai-sdk-provider-gemini-cli to optionalDependencies - Implement dynamic import with loadGeminiCliModule() function - Make getClient() async to support lazy loading - Update base-provider to handle async getClient() calls - Update tests to handle async getClient() method This allows the application to start without the gemini-cli package installed, only loading it when actually needed. * feat(gemini-cli): replace regex-based JSON extraction with jsonc-parser - Add jsonc-parser dependency for robust JSON parsing - Replace simple regex approach with progressive parsing strategy: 1. Direct parsing after cleanup 2. Smart boundary detection with single-pass analysis 3. Limited fallback for edge cases - Optimize performance with early termination and strategic sampling - Add comprehensive tests for variable declarations, trailing commas, escaped quotes, nested objects, and performance edge cases - Improve reliability for complex JSON structures that Gemini commonly produces - Fix code formatting with biome This addresses JSON parsing failures in generateObject operations while maintaining backward compatibility and significantly improving performance for large responses. * fix: update package-lock.json and fix formatting for CI/CD - Add jsonc-parser to package-lock.json for proper npm ci compatibility - Fix biome formatting issues in gemini-cli provider and tests - Ensure all CI/CD checks pass * feat(gemini-cli): implement comprehensive JSON output reliability system - Add automatic JSON request detection via content analysis patterns - Implement task-specific prompt simplification for improved AI compliance - Add strict JSON enforcement through enhanced system prompts - Implement response interception with intelligent JSON extraction fallback - Add comprehensive test coverage for all new JSON handling methods - Move debug logging to appropriate level for clean user experience This multi-layered approach addresses gemini-cli's conversational response tendencies, ensuring reliable structured JSON output for task expansion operations. Achieves 100% success rate in end-to-end testing while maintaining full backward compatibility with existing functionality. Technical implementation includes: • JSON detection via user message content analysis • Expand-task prompt simplification with cleaner instructions • System prompt enhancement with strict JSON enforcement • Response processing with jsonc-parser-based extraction • Comprehensive unit test coverage for edge cases • Debug-level logging to prevent user interface clutter Resolves: gemini-cli JSON formatting inconsistencies Tested: All 46 test suites pass, formatting verified * chore: add changeset for gemini-cli provider implementation Adds minor version bump for comprehensive gemini-cli provider with: - Lazy loading and optional dependency management - Advanced JSON parsing with jsonc-parser - Multi-layer reliability system for structured output - Complete test coverage and CI/CD compliance * refactor: consolidate optional auth provider logic - Add gemini-cli to existing providersWithoutApiKeys array in config-manager - Export providersWithoutApiKeys for reuse across modules - Remove duplicate OPTIONAL_AUTH_PROVIDERS Set from ai-services-unified - Update ai-services-unified to import and use centralized array - Fix Jest mock to include new providersWithoutApiKeys export This eliminates code duplication and provides a single source of truth for which providers support optional authentication, addressing PR reviewer feedback about existing similar functionality in src/constants. * docs: Auto-update and format models.md * Feat: Added automatic determination of task number based on complexity (#884) - Added 'defaultNumTasks: 10' to default config, now used in 'parse-prd' - Adjusted 'parse-prd' and 'expand-task' to: - Accept a 'numTasks' value of 0 - Updated tool and command descriptions - Updated prompts to 'an appropriate number of' when value is 0 - Updated 'README-task-master.md' and 'command-reference.md' docs - Added more tests for: 'parse-prd', 'expand-task' and 'config-manager' Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> * feat: Support custom response language (#510) * feat: Support custom response language * fix: Add default values for response language in config-manager.js * chore: Update configuration file and add default response language settings * feat: Support MCP/CLI custom response language * chore: Update test comments to English for consistency * docs: Auto-update and format models.md * chore: fix format --------- Co-authored-by: github-actions[bot] Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> * Feat: Implemented advanced settings for Claude Code AI provider (#872) * Feat: Implemented advanced settings for Claude Code AI provider - Added new 'claudeCode' property to default config - Added getters and validation functions to 'config-manager.js' - Added new 'isEmpty' utility to 'utils.js' - Added new constants file 'commands.js' for AI_COMMAND_NAMES - Updated Claude Code AI provider to use new config functions - Updated 'claude-code-usage.md' documentation - Added 'config-manager.test.js' tests to cover new settings * chore: run format --------- Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> * fix: issues with release (#915) Fix remove-task bug with mcp Fix response-language using old config file .taskmaster * fix(claude-code): recover from CLI JSON truncation bug (#913) (#920) Gracefully handle SyntaxError thrown by @anthropic-ai/claude-code when the CLI truncates large JSON outputs (4–16 kB cut-offs).\n\nKey points:\n• Detect JSON parse error + existing buffered text in both doGenerate() and doStream() code paths.\n• Convert the failure into a recoverable 'truncated' finish state and push a provider-warning.\n• Allows Task Master to continue parsing long PRDs / expand-task operations instead of crashing.\n\nA patch changeset (.changeset/claude-code-json-truncation.md) is included for the next release.\n\nRef: eyaltoledano/claude-task-master#913 * docs: fix gemini-cli authentication documentation (#923) Remove erroneous 'gemini auth login' command references and replace with correct 'gemini' command authentication flow. Update documentation to reflect proper OAuth setup process via the gemini CLI interactive interface. * chore: run format * fix: add initTaskMaster to new commands Fixes CI and broken commands * chore: format --------- Co-authored-by: Chris Covington Co-authored-by: Ben Vargas Co-authored-by: Joe Danziger Co-authored-by: Nicholas Spalding Co-authored-by: github-actions[bot] Co-authored-by: Ofer Shaal Co-authored-by: Shandy Hermawan Co-authored-by: Parthy <52548018+mm-parthy@users.noreply.github.com> Co-authored-by: Geoff Hammond Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Co-authored-by: shenysun <40556411+shenysun@users.noreply.github.com> --- scripts/modules/commands.js | 749 +++++++++++++++++---------------- src/task-master.js | 288 +++++++++++++ tests/unit/task-master.test.js | 425 +++++++++++++++++++ 3 files changed, 1089 insertions(+), 373 deletions(-) create mode 100644 src/task-master.js create mode 100644 tests/unit/task-master.test.js diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index 8b653463..65970c8a 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -18,7 +18,6 @@ import { log, readJSON, writeJSON, - findProjectRoot, getCurrentTag, detectCamelCaseFlags, toKebabCase @@ -79,11 +78,12 @@ import { CUSTOM_PROVIDERS } from '../../src/constants/providers.js'; import { COMPLEXITY_REPORT_FILE, - PRD_FILE, TASKMASTER_TASKS_FILE, - TASKMASTER_CONFIG_FILE + TASKMASTER_DOCS_DIR } from '../../src/constants/paths.js'; +import { initTaskMaster } from '../../src/task-master.js'; + import { displayBanner, displayHelp, @@ -822,25 +822,34 @@ function registerCommands(programInstance) { ) .option('--tag ', 'Specify tag context for task operations') .action(async (file, options) => { - // Use input option if file argument not provided - const inputFile = file || options.input; - const defaultPrdPath = PRD_FILE; + // Initialize TaskMaster + let taskMaster; + try { + taskMaster = initTaskMaster({ + prdPath: file || options.input || true, + tasksPath: options.output || true + }); + } catch (error) { + 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: "${TASKMASTER_TASKS_FILE}")\n -n, --num-tasks Number of tasks to generate (default: 10)\n -f, --force Skip confirmation when overwriting existing tasks\n --append Append new tasks to existing tasks.json instead of overwriting\n -r, --research Use Perplexity AI for research-backed task generation\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 task-master parse-prd requirements_v2.txt --append\n task-master parse-prd requirements.txt --research\n\n${chalk.yellow('Note: This command will:')}\n 1. Look for a PRD file at ${TASKMASTER_DOCS_DIR}/PRD.md by default\n 2. Use the file specified by --input or positional argument if provided\n 3. Generate tasks from the PRD and either:\n - Overwrite any existing tasks.json file (default)\n - Append to existing tasks.json if --append is used`, + { padding: 1, borderColor: 'blue', borderStyle: 'round' } + ) + ); + console.error(chalk.red(`\nError: ${error.message}`)); + process.exit(1); + } + const numTasks = parseInt(options.numTasks, 10); - const outputPath = options.output; const force = options.force || false; const append = options.append || false; const research = options.research || false; let useForce = force; const useAppend = append; - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - // Resolve tag using standard pattern - const tag = options.tag || getCurrentTag(projectRoot) || 'master'; + const tag = + options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; // Show current tag context displayCurrentTagIndicator(tag); @@ -849,10 +858,11 @@ function registerCommands(programInstance) { async function confirmOverwriteIfNeeded() { // Check if there are existing tasks in the target tag let hasExistingTasksInTag = false; - if (fs.existsSync(outputPath)) { + const tasksPath = taskMaster.getTasksPath(); + if (fs.existsSync(tasksPath)) { try { // Read the entire file to check if the tag exists - const existingFileContent = fs.readFileSync(outputPath, 'utf8'); + const existingFileContent = fs.readFileSync(tasksPath, 'utf8'); const allData = JSON.parse(existingFileContent); // Check if the target tag exists and has tasks @@ -871,7 +881,7 @@ function registerCommands(programInstance) { // Only show confirmation if there are existing tasks in the target tag if (hasExistingTasksInTag && !useForce && !useAppend) { - const overwrite = await confirmTaskOverwrite(outputPath); + const overwrite = await confirmTaskOverwrite(tasksPath); if (!overwrite) { log('info', 'Operation cancelled.'); return false; @@ -886,50 +896,9 @@ function registerCommands(programInstance) { let spinner; try { - if (!inputFile) { - if (fs.existsSync(defaultPrdPath)) { - console.log( - chalk.blue(`Using default PRD file path: ${defaultPrdPath}`) - ); - if (!(await confirmOverwriteIfNeeded())) return; - - console.log(chalk.blue(`Generating ${numTasks} tasks...`)); - spinner = ora('Parsing PRD and generating tasks...\n').start(); - await parsePRD(defaultPrdPath, outputPath, numTasks, { - append: useAppend, // Changed key from useAppend to append - force: useForce, // Changed key from useForce to force - research: research, - projectRoot: projectRoot, - tag: tag - }); - spinner.succeed('Tasks generated successfully!'); - return; - } - - console.log( - chalk.yellow( - `No PRD file specified and default PRD file not found at ${PRD_FILE}.` - ) - ); - 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: "${TASKMASTER_TASKS_FILE}")\n -n, --num-tasks Number of tasks to generate (default: 10)\n -f, --force Skip confirmation when overwriting existing tasks\n --append Append new tasks to existing tasks.json instead of overwriting\n -r, --research Use Perplexity AI for research-backed task generation\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 task-master parse-prd requirements_v2.txt --append\n task-master parse-prd requirements.txt --research\n\n${chalk.yellow('Note: This command will:')}\n 1. Look for a PRD file at ${PRD_FILE} by default\n 2. Use the file specified by --input or positional argument if provided\n 3. Generate tasks from the PRD and either:\n - Overwrite any existing tasks.json file (default)\n - Append to existing tasks.json if --append is used`, - { padding: 1, borderColor: 'blue', borderStyle: 'round' } - ) - ); - return; - } - - if (!fs.existsSync(inputFile)) { - console.error( - chalk.red(`Error: Input PRD file not found: ${inputFile}`) - ); - process.exit(1); - } - if (!(await confirmOverwriteIfNeeded())) return; - console.log(chalk.blue(`Parsing PRD file: ${inputFile}`)); + console.log(chalk.blue(`Parsing PRD file: ${taskMaster.getPrdPath()}`)); console.log(chalk.blue(`Generating ${numTasks} tasks...`)); if (append) { console.log(chalk.blue('Appending to existing tasks...')); @@ -943,13 +912,18 @@ function registerCommands(programInstance) { } spinner = ora('Parsing PRD and generating tasks...\n').start(); - await parsePRD(inputFile, outputPath, numTasks, { - append: useAppend, - force: useForce, - research: research, - projectRoot: projectRoot, - tag: tag - }); + await parsePRD( + taskMaster.getPrdPath(), + taskMaster.getTasksPath(), + numTasks, + { + append: useAppend, + force: useForce, + research: research, + projectRoot: taskMaster.getProjectRoot(), + tag: tag + } + ); spinner.succeed('Tasks generated successfully!'); } catch (error) { if (spinner) { @@ -987,19 +961,18 @@ function registerCommands(programInstance) { ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const tasksPath = options.file || TASKMASTER_TASKS_FILE; + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); + const fromId = parseInt(options.from, 10); // Validation happens here const prompt = options.prompt; const useResearch = options.research || false; - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - // Resolve tag using standard pattern - const tag = options.tag || getCurrentTag(projectRoot) || 'master'; + const tag = + options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; // Show current tag context displayCurrentTagIndicator(tag); @@ -1051,11 +1024,11 @@ function registerCommands(programInstance) { // Call core updateTasks, passing context for CLI await updateTasks( - tasksPath, + taskMaster.getTasksPath(), fromId, prompt, useResearch, - { projectRoot, tag } // Pass context with projectRoot and tag + { projectRoot: taskMaster.getProjectRoot(), tag } // Pass context with projectRoot and tag ); }); @@ -1086,16 +1059,14 @@ function registerCommands(programInstance) { .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { try { - const tasksPath = options.file || TASKMASTER_TASKS_FILE; - - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); // Resolve tag using standard pattern - const tag = options.tag || getCurrentTag(projectRoot) || 'master'; + const tag = + options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; // Show current tag context displayCurrentTagIndicator(tag); @@ -1193,7 +1164,7 @@ function registerCommands(programInstance) { taskId, prompt, useResearch, - { projectRoot, tag }, + { projectRoot: taskMaster.getProjectRoot(), tag }, 'text', options.append || false ); @@ -1259,16 +1230,14 @@ function registerCommands(programInstance) { .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { try { - const tasksPath = options.file || TASKMASTER_TASKS_FILE; - - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); // Resolve tag using standard pattern - const tag = options.tag || getCurrentTag(projectRoot) || 'master'; + const tag = + options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; // Show current tag context displayCurrentTagIndicator(tag); @@ -1368,7 +1337,7 @@ function registerCommands(programInstance) { subtaskId, prompt, useResearch, - { projectRoot, tag } + { projectRoot: taskMaster.getProjectRoot(), tag } ); if (!result) { @@ -1426,20 +1395,23 @@ function registerCommands(programInstance) { ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const tasksPath = options.file || TASKMASTER_TASKS_FILE; + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); + const outputDir = options.output; const tag = options.tag; - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - - console.log(chalk.blue(`Generating task files from: ${tasksPath}`)); + console.log( + chalk.blue(`Generating task files from: ${taskMaster.getTasksPath()}`) + ); console.log(chalk.blue(`Output directory: ${outputDir}`)); - await generateTaskFiles(tasksPath, outputDir, { projectRoot, tag }); + await generateTaskFiles(taskMaster.getTasksPath(), outputDir, { + projectRoot: taskMaster.getProjectRoot(), + tag + }); }); // set-status command @@ -1463,7 +1435,11 @@ function registerCommands(programInstance) { ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const tasksPath = options.file || TASKMASTER_TASKS_FILE; + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); + const taskId = options.id; const status = options.status; const tag = options.tag; @@ -1483,22 +1459,19 @@ function registerCommands(programInstance) { process.exit(1); } - // Find project root for tag resolution - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - // Resolve tag using standard pattern and show current tag context - const resolvedTag = tag || getCurrentTag(projectRoot) || 'master'; + const resolvedTag = + tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; displayCurrentTagIndicator(resolvedTag); console.log( chalk.blue(`Setting status of task(s) ${taskId} to: ${status}`) ); - await setTaskStatus(tasksPath, taskId, status, { projectRoot, tag }); + await setTaskStatus(taskMaster.getTasksPath(), taskId, status, { + projectRoot: taskMaster.getProjectRoot(), + tag + }); }); // list command @@ -1519,22 +1492,23 @@ function registerCommands(programInstance) { .option('--with-subtasks', 'Show subtasks for each task') .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true, + complexityReportPath: options.report || false + }); - const tasksPath = options.file || TASKMASTER_TASKS_FILE; - const reportPath = options.report; const statusFilter = options.status; const withSubtasks = options.withSubtasks || false; - const tag = options.tag || getCurrentTag(projectRoot) || 'master'; + const tag = + options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; // Show current tag context displayCurrentTagIndicator(tag); - console.log(chalk.blue(`Listing tasks from: ${tasksPath}`)); + console.log( + chalk.blue(`Listing tasks from: ${taskMaster.getTasksPath()}`) + ); if (statusFilter) { console.log(chalk.blue(`Filtering by status: ${statusFilter}`)); } @@ -1543,13 +1517,13 @@ function registerCommands(programInstance) { } await listTasks( - tasksPath, + taskMaster.getTasksPath(), statusFilter, - reportPath, + taskMaster.getComplexityReportPath(), withSubtasks, 'text', tag, - { projectRoot } + { projectRoot: taskMaster.getProjectRoot() } ); }); @@ -1580,16 +1554,16 @@ function registerCommands(programInstance) { ) // Allow file override .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - const tasksPath = path.resolve(projectRoot, options.file); // Resolve tasks path + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); const tag = options.tag; // Show current tag context - displayCurrentTagIndicator(tag || getCurrentTag(projectRoot) || 'master'); + displayCurrentTagIndicator( + tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master' + ); if (options.all) { // --- Handle expand --all --- @@ -1602,7 +1576,7 @@ function registerCommands(programInstance) { options.research, // Pass research flag options.prompt, // Pass additional context options.force, // Pass force flag - { projectRoot, tag } // Pass context with projectRoot and tag + { projectRoot: taskMaster.getProjectRoot(), tag } // Pass context with projectRoot and tag // outputFormat defaults to 'text' in expandAllTasks for CLI ); } catch (error) { @@ -1629,7 +1603,7 @@ function registerCommands(programInstance) { options.num, options.research, options.prompt, - { projectRoot, tag }, // Pass context with projectRoot and tag + { projectRoot: taskMaster.getProjectRoot(), tag }, // Pass context with projectRoot and tag options.force // Pass the force flag down ); // expandTask logs its own success/failure for single task @@ -1684,31 +1658,36 @@ function registerCommands(programInstance) { .option('--to ', 'Ending task ID in a range to analyze') .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const tasksPath = options.file || TASKMASTER_TASKS_FILE; + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true, + complexityReportPath: options.output || true + }); + const tag = options.tag; const modelOverride = options.model; const thresholdScore = parseFloat(options.threshold); const useResearch = options.research || false; - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - // Use the provided tag, or the current active tag, or default to 'master' - const targetTag = tag || getCurrentTag(projectRoot) || 'master'; + const targetTag = + tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; // Show current tag context displayCurrentTagIndicator(targetTag); // Tag-aware output file naming: master -> task-complexity-report.json, other tags -> task-complexity-report_tagname.json + const baseOutputPath = taskMaster.getComplexityReportPath(); const outputPath = options.output === COMPLEXITY_REPORT_FILE && targetTag !== 'master' - ? options.output.replace('.json', `_${targetTag}.json`) - : options.output; + ? baseOutputPath.replace('.json', `_${targetTag}.json`) + : baseOutputPath; - console.log(chalk.blue(`Analyzing task complexity from: ${tasksPath}`)); + console.log( + chalk.blue( + `Analyzing task complexity from: ${taskMaster.getTasksPath()}` + ) + ); console.log(chalk.blue(`Output report will be saved to: ${outputPath}`)); if (options.id) { @@ -1734,7 +1713,8 @@ function registerCommands(programInstance) { ...options, output: outputPath, tag: targetTag, - projectRoot: projectRoot + projectRoot: taskMaster.getProjectRoot(), + file: taskMaster.getTasksPath() }; await analyzeTaskComplexity(updatedOptions); @@ -1879,12 +1859,8 @@ function registerCommands(programInstance) { } } - // Determine project root and tasks file path - const projectRoot = findProjectRoot() || '.'; - const tag = options.tag || getCurrentTag(projectRoot) || 'master'; - const tasksPath = - options.file || - path.join(projectRoot, '.taskmaster', 'tasks', 'tasks.json'); + const tag = + options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; // Show current tag context displayCurrentTagIndicator(tag); @@ -1892,11 +1868,15 @@ function registerCommands(programInstance) { // Validate tasks file exists if task IDs are specified if (taskIds.length > 0) { try { - const tasksData = readJSON(tasksPath, projectRoot, tag); + const tasksData = readJSON( + taskMaster.getTasksPath(), + taskMaster.getProjectRoot(), + tag + ); if (!tasksData || !tasksData.tasks) { console.error( chalk.red( - `Error: No valid tasks found in ${tasksPath} for tag '${tag}'` + `Error: No valid tasks found in ${taskMaster.getTasksPath()} for tag '${tag}'` ) ); process.exit(1); @@ -1914,7 +1894,7 @@ function registerCommands(programInstance) { for (const filePath of filePaths) { const fullPath = path.isAbsolute(filePath) ? filePath - : path.join(projectRoot, filePath); + : path.join(taskMaster.getProjectRoot(), filePath); if (!fs.existsSync(fullPath)) { console.error(chalk.red(`Error: File not found: ${filePath}`)); process.exit(1); @@ -1933,8 +1913,8 @@ function registerCommands(programInstance) { saveToId: options.saveTo ? options.saveTo.trim() : null, allowFollowUp: true, // Always allow follow-up in CLI detailLevel: options.detail ? options.detail.toLowerCase() : 'medium', - tasksPath: tasksPath, - projectRoot: projectRoot + tasksPath: taskMaster.getTasksPath(), + projectRoot: taskMaster.getProjectRoot() }; // Display what we're about to do @@ -2116,14 +2096,15 @@ ${result.result} const all = options.all; const tag = options.tag; - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); // Show current tag context - displayCurrentTagIndicator(tag || getCurrentTag(projectRoot) || 'master'); + displayCurrentTagIndicator( + tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master' + ); if (!taskIds && !all) { console.error( @@ -2136,15 +2117,25 @@ ${result.result} if (all) { // If --all is specified, get all task IDs - const data = readJSON(tasksPath, projectRoot, tag); + const data = readJSON( + taskMaster.getTasksPath(), + taskMaster.getProjectRoot(), + tag + ); 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, { projectRoot, tag }); + clearSubtasks(taskMaster.getTasksPath(), allIds, { + projectRoot: taskMaster.getProjectRoot(), + tag + }); } else { - clearSubtasks(tasksPath, taskIds, { projectRoot, tag }); + clearSubtasks(taskMaster.getTasksPath(), taskIds, { + projectRoot: taskMaster.getProjectRoot(), + tag + }); } }); @@ -2207,15 +2198,14 @@ ${result.result} } // Correctly determine projectRoot - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); // Show current tag context displayCurrentTagIndicator( - options.tag || getCurrentTag(projectRoot) || 'master' + options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master' ); let manualTaskData = null; @@ -2302,16 +2292,21 @@ ${result.result} const reportPath = options.report; const tag = options.tag; - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); // Show current tag context - displayCurrentTagIndicator(tag || getCurrentTag(projectRoot) || 'master'); + displayCurrentTagIndicator( + tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master' + ); - await displayNextTask(tasksPath, reportPath, { projectRoot, tag }); + await displayNextTask( + taskMaster.getTasksPath(), + taskMaster.getComplexityReportPath(), + { projectRoot: taskMaster.getProjectRoot(), tag } + ); }); // show command @@ -2338,27 +2333,26 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (taskId, options) => { - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true, + complexityReportPath: options.report || false + }); const idArg = taskId || options.id; const statusFilter = options.status; const tag = options.tag; // Show current tag context - displayCurrentTagIndicator(tag || getCurrentTag(projectRoot) || 'master'); + displayCurrentTagIndicator( + tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master' + ); if (!idArg) { console.error(chalk.red('Error: Please provide a task ID')); process.exit(1); } - const tasksPath = options.file || TASKMASTER_TASKS_FILE; - const reportPath = options.report; - // Check if multiple IDs are provided (comma-separated) const taskIds = idArg .split(',') @@ -2368,21 +2362,21 @@ ${result.result} if (taskIds.length > 1) { // Multiple tasks - use compact summary view with interactive drill-down await displayMultipleTasksSummary( - tasksPath, + taskMaster.getTasksPath(), taskIds, - reportPath, + taskMaster.getComplexityReportPath(), statusFilter, - { projectRoot, tag } + { projectRoot: taskMaster.getProjectRoot(), tag } ); } else { // Single task - use detailed view await displayTaskById( - tasksPath, + taskMaster.getTasksPath(), taskIds[0], - reportPath, + taskMaster.getComplexityReportPath(), statusFilter, tag, - { projectRoot } + { projectRoot: taskMaster.getProjectRoot() } ); } }); @@ -2400,18 +2394,17 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const tasksPath = options.file || TASKMASTER_TASKS_FILE; + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); + const taskId = options.id; const dependencyId = options.dependsOn; - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - // Resolve tag using standard pattern - const tag = options.tag || getCurrentTag(projectRoot) || 'master'; + const tag = + options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; // Show current tag context displayCurrentTagIndicator(tag); @@ -2432,10 +2425,15 @@ ${result.result} ? dependencyId : parseInt(dependencyId, 10); - await addDependency(tasksPath, formattedTaskId, formattedDependencyId, { - projectRoot, - tag - }); + await addDependency( + taskMaster.getTasksPath(), + formattedTaskId, + formattedDependencyId, + { + projectRoot: taskMaster.getProjectRoot(), + tag + } + ); }); // remove-dependency command @@ -2451,18 +2449,17 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const tasksPath = options.file || TASKMASTER_TASKS_FILE; + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); + const taskId = options.id; const dependencyId = options.dependsOn; - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - // Resolve tag using standard pattern - const tag = options.tag || getCurrentTag(projectRoot) || 'master'; + const tag = + options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; // Show current tag context displayCurrentTagIndicator(tag); @@ -2484,11 +2481,11 @@ ${result.result} : parseInt(dependencyId, 10); await removeDependency( - tasksPath, + taskMaster.getTasksPath(), formattedTaskId, formattedDependencyId, { - projectRoot, + projectRoot: taskMaster.getProjectRoot(), tag } ); @@ -2507,20 +2504,20 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); // Resolve tag using standard pattern - const tag = options.tag || getCurrentTag(projectRoot) || 'master'; + const tag = + options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; // Show current tag context displayCurrentTagIndicator(tag); - await validateDependenciesCommand(options.file || TASKMASTER_TASKS_FILE, { - context: { projectRoot, tag } + await validateDependenciesCommand(taskMaster.getTasksPath(), { + context: { projectRoot: taskMaster.getProjectRoot(), tag } }); }); @@ -2535,20 +2532,20 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); // Resolve tag using standard pattern - const tag = options.tag || getCurrentTag(projectRoot) || 'master'; + const tag = + options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; // Show current tag context displayCurrentTagIndicator(tag); - await fixDependenciesCommand(options.file || TASKMASTER_TASKS_FILE, { - context: { projectRoot, tag } + await fixDependenciesCommand(taskMaster.getTasksPath(), { + context: { projectRoot: taskMaster.getProjectRoot(), tag } }); }); @@ -2563,23 +2560,24 @@ ${result.result} ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + complexityReportPath: options.file || true + }); // Use the provided tag, or the current active tag, or default to 'master' - const targetTag = options.tag || getCurrentTag(projectRoot) || 'master'; + const targetTag = + options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; // Show current tag context displayCurrentTagIndicator(targetTag); // Tag-aware report file naming: master -> task-complexity-report.json, other tags -> task-complexity-report_tagname.json + const baseReportPath = taskMaster.getComplexityReportPath(); const reportPath = options.file === COMPLEXITY_REPORT_FILE && targetTag !== 'master' - ? options.file.replace('.json', `_${targetTag}.json`) - : options.file || COMPLEXITY_REPORT_FILE; + ? baseReportPath.replace('.json', `_${targetTag}.json`) + : baseReportPath; await displayComplexityReport(reportPath); }); @@ -2609,19 +2607,18 @@ ${result.result} .option('--skip-generate', 'Skip regenerating task files') .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); - const tasksPath = options.file || TASKMASTER_TASKS_FILE; const parentId = options.parent; const existingTaskId = options.taskId; const generateFiles = !options.skipGenerate; // Resolve tag using standard pattern - const tag = options.tag || getCurrentTag(projectRoot) || 'master'; + const tag = + options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; // Show current tag context displayCurrentTagIndicator(tag); @@ -2654,12 +2651,12 @@ ${result.result} ) ); await addSubtask( - tasksPath, + taskMaster.getTasksPath(), parentId, existingTaskId, null, generateFiles, - { projectRoot, tag } + { projectRoot: taskMaster.getProjectRoot(), tag } ); console.log( chalk.green( @@ -2681,12 +2678,12 @@ ${result.result} }; const subtask = await addSubtask( - tasksPath, + taskMaster.getTasksPath(), parentId, null, newSubtaskData, generateFiles, - { projectRoot, tag } + { projectRoot: taskMaster.getProjectRoot(), tag } ); console.log( chalk.green( @@ -2794,18 +2791,16 @@ ${result.result} .option('--skip-generate', 'Skip regenerating task files') .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const tasksPath = options.file || TASKMASTER_TASKS_FILE; + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); + const subtaskIds = options.id; const convertToTask = options.convert || false; const generateFiles = !options.skipGenerate; const tag = options.tag; - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - if (!subtaskIds) { console.error( chalk.red( @@ -2840,11 +2835,11 @@ ${result.result} } const result = await removeSubtask( - tasksPath, + taskMaster.getTasksPath(), subtaskId, convertToTask, generateFiles, - { projectRoot, tag } + { projectRoot: taskMaster.getProjectRoot(), tag } ); if (convertToTask && result) { @@ -3097,17 +3092,16 @@ ${result.result} .option('-y, --yes', 'Skip confirmation prompt', false) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const tasksPath = options.file || TASKMASTER_TASKS_FILE; + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); + const taskIdsString = options.id; - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - // Resolve tag using standard pattern - const tag = options.tag || getCurrentTag(projectRoot) || 'master'; + const tag = + options.tag || getCurrentTag(taskMaster.getProjectRoot()) || 'master'; // Show current tag context displayCurrentTagIndicator(tag); @@ -3134,7 +3128,11 @@ ${result.result} try { // Read data once for checks and confirmation - const data = readJSON(tasksPath, projectRoot, tag); + const data = readJSON( + taskMaster.getTasksPath(), + taskMaster.getProjectRoot(), + tag + ); if (!data || !data.tasks) { console.error( chalk.red(`Error: No valid tasks found in ${tasksPath}`) @@ -3274,10 +3272,14 @@ ${result.result} const existingIdsString = existingTasksToRemove .map(({ id }) => id) .join(','); - const result = await removeTask(tasksPath, existingIdsString, { - projectRoot, - tag - }); + const result = await removeTask( + taskMaster.getTasksPath(), + existingIdsString, + { + projectRoot: taskMaster.getProjectRoot(), + tag + } + ); stopLoadingIndicator(indicator); @@ -3450,11 +3452,10 @@ Examples: $ task-master models --setup # Run interactive setup` ) .action(async (options) => { - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); // Validate flags: cannot use multiple provider flags simultaneously const providerFlags = [ options.openrouter, @@ -3669,7 +3670,8 @@ Examples: .option('--response ', 'Set the response language') .option('--setup', 'Run interactive setup to configure response language') .action(async (options) => { - const projectRoot = findProjectRoot(); // Find project root for context + const taskMaster = initTaskMaster({}); + const projectRoot = taskMaster.getProjectRoot(); // Find project root for context const { response, setup } = options; console.log( chalk.blue('Response language set to:', JSON.stringify(options)) @@ -3738,7 +3740,11 @@ Examples: ) .option('--tag ', 'Specify tag context for task operations') .action(async (options) => { - const tasksPath = options.file || TASKMASTER_TASKS_FILE; + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); + const sourceId = options.from; const destinationId = options.to; const tag = options.tag; @@ -3755,13 +3761,6 @@ Examples: process.exit(1); } - // Find project root for tag resolution - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - // Check if we're moving multiple tasks (comma-separated IDs) const sourceIds = sourceId.split(',').map((id) => id.trim()); const destinationIds = destinationId.split(',').map((id) => id.trim()); @@ -3789,7 +3788,11 @@ Examples: try { // Read tasks data once to validate destination IDs - const tasksData = readJSON(tasksPath, projectRoot, tag); + const tasksData = readJSON( + taskMaster.getTasksPath(), + taskMaster.getProjectRoot(), + tag + ); if (!tasksData || !tasksData.tasks) { console.error( chalk.red(`Error: Invalid or missing tasks file at ${tasksPath}`) @@ -3815,11 +3818,11 @@ Examples: ); try { await moveTask( - tasksPath, + taskMaster.getTasksPath(), fromId, toId, i === sourceIds.length - 1, - { projectRoot, tag } + { projectRoot: taskMaster.getProjectRoot(), tag } ); console.log( chalk.green( @@ -3845,11 +3848,11 @@ Examples: try { const result = await moveTask( - tasksPath, + taskMaster.getTasksPath(), sourceId, destinationId, true, - { projectRoot, tag } + { projectRoot: taskMaster.getProjectRoot(), tag } ); console.log( chalk.green( @@ -3886,7 +3889,8 @@ Examples: $ task-master rules --${RULES_SETUP_ACTION} # Interactive setup to select rule profiles` ) .action(async (action, profiles, options) => { - const projectRoot = findProjectRoot(); + const taskMaster = initTaskMaster({}); + const projectRoot = taskMaster.getProjectRoot(); if (!projectRoot) { console.error(chalk.red('Error: Could not find project root.')); process.exit(1); @@ -4186,31 +4190,24 @@ Examples: 'Show only tasks matching this status (e.g., pending, done)' ) .action(async (options) => { - const tasksPath = options.file || TASKMASTER_TASKS_FILE; + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); + const withSubtasks = options.withSubtasks || false; const status = options.status || null; - // Find project root - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error( - chalk.red( - 'Error: Could not find project root. Make sure you are in a Task Master project directory.' - ) - ); - process.exit(1); - } - console.log( chalk.blue( `📝 Syncing tasks to README.md${withSubtasks ? ' (with subtasks)' : ''}${status ? ` (status: ${status})` : ''}...` ) ); - const success = await syncTasksToReadme(projectRoot, { + const success = await syncTasksToReadme(taskMaster.getProjectRoot(), { withSubtasks, status, - tasksPath + tasksPath: taskMaster.getTasksPath() }); if (!success) { @@ -4249,13 +4246,10 @@ Examples: .option('-d, --description ', 'Optional description for the tag') .action(async (tagName, options) => { try { - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - - const tasksPath = path.resolve(projectRoot, options.file); + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); // Validate tasks file exists if (!fs.existsSync(tasksPath)) { @@ -4284,7 +4278,7 @@ Examples: } const context = { - projectRoot, + projectRoot: taskMaster.getProjectRoot(), commandName: 'add-tag', outputType: 'cli' }; @@ -4339,7 +4333,13 @@ Examples: description: options.description }; - await createTag(tasksPath, tagName, createOptions, context, 'text'); + await createTag( + taskMaster.getTasksPath(), + tagName, + createOptions, + context, + 'text' + ); } // Handle auto-switch if requested @@ -4352,7 +4352,13 @@ Examples: ) ) : tagName; - await useTag(tasksPath, finalTagName, {}, context, 'text'); + await useTag( + taskMaster.getTasksPath(), + finalTagName, + {}, + context, + 'text' + ); } } catch (error) { console.error(chalk.red(`Error creating tag: ${error.message}`)); @@ -4379,13 +4385,10 @@ Examples: .option('-y, --yes', 'Skip confirmation prompts') .action(async (tagName, options) => { try { - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - - const tasksPath = path.resolve(projectRoot, options.file); + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); // Validate tasks file exists if (!fs.existsSync(tasksPath)) { @@ -4400,12 +4403,18 @@ Examples: }; const context = { - projectRoot, + projectRoot: taskMaster.getProjectRoot(), commandName: 'delete-tag', outputType: 'cli' }; - await deleteTag(tasksPath, tagName, deleteOptions, context, 'text'); + await deleteTag( + taskMaster.getTasksPath(), + tagName, + deleteOptions, + context, + 'text' + ); } catch (error) { console.error(chalk.red(`Error deleting tag: ${error.message}`)); showDeleteTagHelp(); @@ -4430,13 +4439,10 @@ Examples: .option('--show-metadata', 'Show detailed metadata for each tag') .action(async (options) => { try { - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - - const tasksPath = path.resolve(projectRoot, options.file); + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); // Validate tasks file exists if (!fs.existsSync(tasksPath)) { @@ -4452,12 +4458,12 @@ Examples: }; const context = { - projectRoot, + projectRoot: taskMaster.getProjectRoot(), commandName: 'tags', outputType: 'cli' }; - await tags(tasksPath, listOptions, context, 'text'); + await tags(taskMaster.getTasksPath(), listOptions, context, 'text'); } catch (error) { console.error(chalk.red(`Error listing tags: ${error.message}`)); showTagsHelp(); @@ -4482,13 +4488,10 @@ Examples: ) .action(async (tagName, options) => { try { - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - - const tasksPath = path.resolve(projectRoot, options.file); + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); // Validate tasks file exists if (!fs.existsSync(tasksPath)) { @@ -4499,12 +4502,12 @@ Examples: } const context = { - projectRoot, + projectRoot: taskMaster.getProjectRoot(), commandName: 'use-tag', outputType: 'cli' }; - await useTag(tasksPath, tagName, {}, context, 'text'); + await useTag(taskMaster.getTasksPath(), tagName, {}, context, 'text'); } catch (error) { console.error(chalk.red(`Error switching tag: ${error.message}`)); showUseTagHelp(); @@ -4530,13 +4533,10 @@ Examples: ) .action(async (oldName, newName, options) => { try { - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - - const tasksPath = path.resolve(projectRoot, options.file); + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); // Validate tasks file exists if (!fs.existsSync(tasksPath)) { @@ -4547,12 +4547,19 @@ Examples: } const context = { - projectRoot, + projectRoot: taskMaster.getProjectRoot(), commandName: 'rename-tag', outputType: 'cli' }; - await renameTag(tasksPath, oldName, newName, {}, context, 'text'); + await renameTag( + taskMaster.getTasksPath(), + oldName, + newName, + {}, + context, + 'text' + ); } catch (error) { console.error(chalk.red(`Error renaming tag: ${error.message}`)); process.exit(1); @@ -4577,13 +4584,10 @@ Examples: .option('-d, --description ', 'Optional description for the new tag') .action(async (sourceName, targetName, options) => { try { - const projectRoot = findProjectRoot(); - if (!projectRoot) { - console.error(chalk.red('Error: Could not find project root.')); - process.exit(1); - } - - const tasksPath = path.resolve(projectRoot, options.file); + // Initialize TaskMaster + const taskMaster = initTaskMaster({ + tasksPath: options.file || true + }); // Validate tasks file exists if (!fs.existsSync(tasksPath)) { @@ -4598,13 +4602,13 @@ Examples: }; const context = { - projectRoot, + projectRoot: taskMaster.getProjectRoot(), commandName: 'copy-tag', outputType: 'cli' }; await copyTag( - tasksPath, + taskMaster.getTasksPath(), sourceName, targetName, copyOptions, @@ -4827,16 +4831,13 @@ async function runCLI(argv = process.argv) { // Check if migration has occurred and show FYI notice once try { - const projectRoot = findProjectRoot() || '.'; - const tasksPath = path.join( - projectRoot, - '.taskmaster', - 'tasks', - 'tasks.json' - ); - const statePath = path.join(projectRoot, '.taskmaster', 'state.json'); + // Use initTaskMaster with no required fields - will only fail if no project root + const taskMaster = initTaskMaster({}); - if (fs.existsSync(tasksPath)) { + const tasksPath = taskMaster.getTasksPath(); + const statePath = taskMaster.getStatePath(); + + if (tasksPath && fs.existsSync(tasksPath)) { // Read raw file to check if it has master key (bypassing tag resolution) const rawData = fs.readFileSync(tasksPath, 'utf8'); const parsedData = JSON.parse(rawData); @@ -4844,7 +4845,7 @@ async function runCLI(argv = process.argv) { if (parsedData && parsedData.master) { // Migration has occurred, check if we've shown the notice let stateData = { migrationNoticeShown: false }; - if (fs.existsSync(statePath)) { + if (statePath && fs.existsSync(statePath)) { // Read state.json directly without tag resolution since it's not a tagged file const rawStateData = fs.readFileSync(statePath, 'utf8'); stateData = JSON.parse(rawStateData) || stateData; @@ -4856,7 +4857,9 @@ async function runCLI(argv = process.argv) { // Mark as shown stateData.migrationNoticeShown = true; // Write state.json directly without tag resolution since it's not a tagged file - fs.writeFileSync(statePath, JSON.stringify(stateData, null, 2)); + if (statePath) { + fs.writeFileSync(statePath, JSON.stringify(stateData, null, 2)); + } } } } diff --git a/src/task-master.js b/src/task-master.js new file mode 100644 index 00000000..71eefb0e --- /dev/null +++ b/src/task-master.js @@ -0,0 +1,288 @@ +/** + * task-master.js + * This module provides a centralized path management system for the Task Master application. + * It exports the TaskMaster class and the initTaskMaster factory function to create a single, + * authoritative source for all critical file and directory paths, resolving circular dependencies. + */ + +import path from 'path'; +import fs from 'fs'; +import { + TASKMASTER_DIR, + TASKMASTER_TASKS_FILE, + LEGACY_TASKS_FILE, + TASKMASTER_DOCS_DIR, + TASKMASTER_REPORTS_DIR, + TASKMASTER_CONFIG_FILE, + LEGACY_CONFIG_FILE +} from './constants/paths.js'; + +/** + * TaskMaster class manages all the paths for the application. + * An instance of this class is created by the initTaskMaster function. + */ +export class TaskMaster { + #paths; + + /** + * The constructor is intended to be used only by the initTaskMaster factory function. + * @param {object} paths - A pre-resolved object of all application paths. + */ + constructor(paths) { + this.#paths = Object.freeze({ ...paths }); + } + + /** + * @returns {string|null} The absolute path to the project root. + */ + getProjectRoot() { + return this.#paths.projectRoot; + } + + /** + * @returns {string|null} The absolute path to the .taskmaster directory. + */ + getTaskMasterDir() { + return this.#paths.taskMasterDir; + } + + /** + * @returns {string|null} The absolute path to the tasks.json file. + */ + getTasksPath() { + return this.#paths.tasksPath; + } + + /** + * @returns {string|null} The absolute path to the PRD file. + */ + getPrdPath() { + return this.#paths.prdPath; + } + + /** + * @returns {string|null} The absolute path to the complexity report. + */ + getComplexityReportPath() { + return this.#paths.complexityReportPath; + } + + /** + * @returns {string|null} The absolute path to the config.json file. + */ + getConfigPath() { + return this.#paths.configPath; + } + + /** + * @returns {string|null} The absolute path to the state.json file. + */ + getStatePath() { + return this.#paths.statePath; + } + + /** + * @returns {object} A frozen object containing all resolved paths. + */ + getAllPaths() { + return this.#paths; + } +} + +/** + * Initializes a TaskMaster instance with resolved paths. + * This function centralizes path resolution logic. + * + * @param {object} [overrides={}] - An object with possible path overrides. + * @param {string} [overrides.projectRoot] + * @param {string} [overrides.tasksPath] + * @param {string} [overrides.prdPath] + * @param {string} [overrides.complexityReportPath] + * @param {string} [overrides.configPath] + * @param {string} [overrides.statePath] + * @returns {TaskMaster} An initialized TaskMaster instance. + */ +export function initTaskMaster(overrides = {}) { + const findProjectRoot = (startDir = process.cwd()) => { + const projectMarkers = [TASKMASTER_DIR, LEGACY_CONFIG_FILE]; + let currentDir = path.resolve(startDir); + const rootDir = path.parse(currentDir).root; + while (currentDir !== rootDir) { + for (const marker of projectMarkers) { + const markerPath = path.join(currentDir, marker); + if (fs.existsSync(markerPath)) { + return currentDir; + } + } + currentDir = path.dirname(currentDir); + } + return null; + }; + + const resolvePath = ( + pathType, + override, + defaultPaths = [], + basePath = null + ) => { + if (typeof override === 'string') { + const resolvedPath = path.isAbsolute(override) + ? override + : path.resolve(basePath || process.cwd(), override); + + if (!fs.existsSync(resolvedPath)) { + throw new Error( + `${pathType} override path does not exist: ${resolvedPath}` + ); + } + return resolvedPath; + } + + if (override === true) { + // Required path - search defaults and fail if not found + for (const defaultPath of defaultPaths) { + const fullPath = path.isAbsolute(defaultPath) + ? defaultPath + : path.join(basePath || process.cwd(), defaultPath); + if (fs.existsSync(fullPath)) { + return fullPath; + } + } + throw new Error( + `Required ${pathType} not found. Searched: ${defaultPaths.join(', ')}` + ); + } + + // Optional path (override === false/undefined) - search defaults, return null if not found + for (const defaultPath of defaultPaths) { + const fullPath = path.isAbsolute(defaultPath) + ? defaultPath + : path.join(basePath || process.cwd(), defaultPath); + if (fs.existsSync(fullPath)) { + return fullPath; + } + } + + return null; + }; + + const paths = {}; + + // Project Root + if (overrides.projectRoot) { + const resolvedOverride = path.resolve(overrides.projectRoot); + if (!fs.existsSync(resolvedOverride)) { + throw new Error( + `Project root override path does not exist: ${resolvedOverride}` + ); + } + + const hasTaskmasterDir = fs.existsSync( + path.join(resolvedOverride, TASKMASTER_DIR) + ); + const hasLegacyConfig = fs.existsSync( + path.join(resolvedOverride, LEGACY_CONFIG_FILE) + ); + + if (!hasTaskmasterDir && !hasLegacyConfig) { + throw new Error( + `Project root override is not a valid taskmaster project: ${resolvedOverride}` + ); + } + + paths.projectRoot = resolvedOverride; + } else { + const foundRoot = findProjectRoot(); + if (!foundRoot) { + throw new Error( + 'Unable to find project root. No project markers found. Run "init" command first.' + ); + } + paths.projectRoot = foundRoot; + } + + // TaskMaster Directory + if ('taskMasterDir' in overrides) { + paths.taskMasterDir = resolvePath( + 'taskmaster directory', + overrides.taskMasterDir, + [TASKMASTER_DIR], + paths.projectRoot + ); + } else { + paths.taskMasterDir = resolvePath( + 'taskmaster directory', + false, + [TASKMASTER_DIR], + paths.projectRoot + ); + } + + // Remaining paths - only resolve if key exists in overrides + if ('configPath' in overrides) { + paths.configPath = resolvePath( + 'config file', + overrides.configPath, + [TASKMASTER_CONFIG_FILE, LEGACY_CONFIG_FILE], + paths.projectRoot + ); + } + + if ('statePath' in overrides) { + paths.statePath = resolvePath( + 'state file', + overrides.statePath, + ['state.json'], + paths.taskMasterDir + ); + } + + if ('tasksPath' in overrides) { + paths.tasksPath = resolvePath( + 'tasks file', + overrides.tasksPath, + [TASKMASTER_TASKS_FILE, LEGACY_TASKS_FILE], + paths.projectRoot + ); + } + + if ('prdPath' in overrides) { + paths.prdPath = resolvePath( + 'PRD file', + overrides.prdPath, + [ + path.join(TASKMASTER_DOCS_DIR, 'PRD.md'), + path.join(TASKMASTER_DOCS_DIR, 'prd.md'), + path.join(TASKMASTER_DOCS_DIR, 'PRD.txt'), + path.join(TASKMASTER_DOCS_DIR, 'prd.txt'), + path.join('scripts', 'PRD.md'), + path.join('scripts', 'prd.md'), + path.join('scripts', 'PRD.txt'), + path.join('scripts', 'prd.txt'), + 'PRD.md', + 'prd.md', + 'PRD.txt', + 'prd.txt' + ], + paths.projectRoot + ); + } + + if ('complexityReportPath' in overrides) { + paths.complexityReportPath = resolvePath( + 'complexity report', + overrides.complexityReportPath, + [ + path.join(TASKMASTER_REPORTS_DIR, 'task-complexity-report.json'), + path.join(TASKMASTER_REPORTS_DIR, 'complexity-report.json'), + path.join('scripts', 'task-complexity-report.json'), + path.join('scripts', 'complexity-report.json'), + 'task-complexity-report.json', + 'complexity-report.json' + ], + paths.projectRoot + ); + } + + return new TaskMaster(paths); +} diff --git a/tests/unit/task-master.test.js b/tests/unit/task-master.test.js new file mode 100644 index 00000000..26067d12 --- /dev/null +++ b/tests/unit/task-master.test.js @@ -0,0 +1,425 @@ +/** + * Tests for task-master.js initTaskMaster function + */ + +import { jest } from '@jest/globals'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import { initTaskMaster, TaskMaster } from '../../src/task-master.js'; +import { + TASKMASTER_DIR, + TASKMASTER_TASKS_FILE, + LEGACY_CONFIG_FILE, + TASKMASTER_CONFIG_FILE, + LEGACY_TASKS_FILE +} from '../../src/constants/paths.js'; + +// Mock the console to prevent noise during tests +jest.spyOn(console, 'error').mockImplementation(() => {}); + +describe('initTaskMaster', () => { + let tempDir; + let originalCwd; + + beforeEach(() => { + // Create a temporary directory for testing + tempDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'taskmaster-test-')) + ); + originalCwd = process.cwd(); + + // Clear all mocks + jest.clearAllMocks(); + }); + + afterEach(() => { + // Restore original working directory + process.chdir(originalCwd); + + // Clean up temporary directory + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('Project root detection', () => { + test('should find project root when .taskmaster directory exists', () => { + // Arrange - Create .taskmaster directory in temp dir + const taskMasterDir = path.join(tempDir, TASKMASTER_DIR); + fs.mkdirSync(taskMasterDir, { recursive: true }); + + // Change to temp directory + process.chdir(tempDir); + + // Act + const taskMaster = initTaskMaster({}); + + // Assert + expect(taskMaster.getProjectRoot()).toBe(tempDir); + expect(taskMaster).toBeInstanceOf(TaskMaster); + }); + + test('should find project root when legacy config file exists', () => { + // Arrange - Create legacy config file in temp dir + const legacyConfigPath = path.join(tempDir, LEGACY_CONFIG_FILE); + fs.writeFileSync(legacyConfigPath, '{}'); + + // Change to temp directory + process.chdir(tempDir); + + // Act + const taskMaster = initTaskMaster({}); + + // Assert + expect(taskMaster.getProjectRoot()).toBe(tempDir); + }); + + test('should find project root from subdirectory', () => { + // Arrange - Create .taskmaster directory in temp dir + const taskMasterDir = path.join(tempDir, TASKMASTER_DIR); + fs.mkdirSync(taskMasterDir, { recursive: true }); + + // Create a subdirectory and change to it + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + process.chdir(srcDir); + + // Act + const taskMaster = initTaskMaster({}); + + // Assert + expect(taskMaster.getProjectRoot()).toBe(tempDir); + }); + + test('should find project root from deeply nested subdirectory', () => { + // Arrange - Create .taskmaster directory in temp dir + const taskMasterDir = path.join(tempDir, TASKMASTER_DIR); + fs.mkdirSync(taskMasterDir, { recursive: true }); + + // Create deeply nested subdirectory and change to it + const deepDir = path.join(tempDir, 'src', 'components', 'ui'); + fs.mkdirSync(deepDir, { recursive: true }); + process.chdir(deepDir); + + // Act + const taskMaster = initTaskMaster({}); + + // Assert + expect(taskMaster.getProjectRoot()).toBe(tempDir); + }); + + test('should throw error when no project markers found', () => { + // Arrange - Empty temp directory, no project markers + process.chdir(tempDir); + + // Act & Assert + expect(() => { + initTaskMaster({}); + }).toThrow( + 'Unable to find project root. No project markers found. Run "init" command first.' + ); + }); + }); + + describe('Project root override validation', () => { + test('should accept valid project root override with .taskmaster directory', () => { + // Arrange - Create .taskmaster directory in temp dir + const taskMasterDir = path.join(tempDir, TASKMASTER_DIR); + fs.mkdirSync(taskMasterDir, { recursive: true }); + + // Act + const taskMaster = initTaskMaster({ projectRoot: tempDir }); + + // Assert + expect(taskMaster.getProjectRoot()).toBe(tempDir); + }); + + test('should accept valid project root override with legacy config', () => { + // Arrange - Create legacy config file in temp dir + const legacyConfigPath = path.join(tempDir, LEGACY_CONFIG_FILE); + fs.writeFileSync(legacyConfigPath, '{}'); + + // Act + const taskMaster = initTaskMaster({ projectRoot: tempDir }); + + // Assert + expect(taskMaster.getProjectRoot()).toBe(tempDir); + }); + + test('should throw error when project root override does not exist', () => { + // Arrange - Non-existent path + const nonExistentPath = path.join(tempDir, 'does-not-exist'); + + // Act & Assert + expect(() => { + initTaskMaster({ projectRoot: nonExistentPath }); + }).toThrow( + `Project root override path does not exist: ${nonExistentPath}` + ); + }); + + test('should throw error when project root override has no project markers', () => { + // Arrange - Empty temp directory (no project markers) + + // Act & Assert + expect(() => { + initTaskMaster({ projectRoot: tempDir }); + }).toThrow( + `Project root override is not a valid taskmaster project: ${tempDir}` + ); + }); + + test('should resolve relative project root override', () => { + // Arrange - Create .taskmaster directory in temp dir + const taskMasterDir = path.join(tempDir, TASKMASTER_DIR); + fs.mkdirSync(taskMasterDir, { recursive: true }); + + // Create subdirectory and change to it + const srcDir = path.join(tempDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + process.chdir(srcDir); + + // Act - Use relative path '../' to go back to project root + const taskMaster = initTaskMaster({ projectRoot: '../' }); + + // Assert + expect(taskMaster.getProjectRoot()).toBe(tempDir); + }); + }); + + describe('Path resolution with boolean logic', () => { + let taskMasterDir, tasksPath, configPath, statePath; + + beforeEach(() => { + // Setup a valid project structure + taskMasterDir = path.join(tempDir, TASKMASTER_DIR); + fs.mkdirSync(taskMasterDir, { recursive: true }); + + tasksPath = path.join(tempDir, TASKMASTER_TASKS_FILE); + fs.mkdirSync(path.dirname(tasksPath), { recursive: true }); + fs.writeFileSync(tasksPath, '[]'); + + configPath = path.join(tempDir, TASKMASTER_CONFIG_FILE); + fs.writeFileSync(configPath, '{}'); + + statePath = path.join(taskMasterDir, 'state.json'); + fs.writeFileSync(statePath, '{}'); + + process.chdir(tempDir); + }); + + test('should return paths when required (true) and files exist', () => { + // Act + const taskMaster = initTaskMaster({ + tasksPath: true, + configPath: true, + statePath: true + }); + + // Assert + expect(taskMaster.getTasksPath()).toBe(tasksPath); + expect(taskMaster.getConfigPath()).toBe(configPath); + expect(taskMaster.getStatePath()).toBe(statePath); + }); + + test('should throw error when required (true) files do not exist', () => { + // Arrange - Remove tasks file + fs.unlinkSync(tasksPath); + + // Act & Assert + expect(() => { + initTaskMaster({ tasksPath: true }); + }).toThrow( + 'Required tasks file not found. Searched: .taskmaster/tasks/tasks.json, tasks/tasks.json' + ); + }); + + test('should return null when optional (false/undefined) files do not exist', () => { + // Arrange - Remove tasks file + fs.unlinkSync(tasksPath); + + // Act + const taskMaster = initTaskMaster({ + tasksPath: false + }); + + // Assert + expect(taskMaster.getTasksPath()).toBeNull(); + }); + + test('should return null when optional files not specified in overrides', () => { + // Arrange - Remove all optional files + fs.unlinkSync(tasksPath); + fs.unlinkSync(configPath); + fs.unlinkSync(statePath); + + // Act - Don't specify any optional paths + const taskMaster = initTaskMaster({}); + + // Assert + expect(taskMaster.getTasksPath()).toBeUndefined(); + expect(taskMaster.getConfigPath()).toBeUndefined(); + expect(taskMaster.getStatePath()).toBeUndefined(); + }); + }); + + describe('String path overrides', () => { + let taskMasterDir; + + beforeEach(() => { + taskMasterDir = path.join(tempDir, TASKMASTER_DIR); + fs.mkdirSync(taskMasterDir, { recursive: true }); + process.chdir(tempDir); + }); + + test('should accept valid absolute path override', () => { + // Arrange - Create custom tasks file + const customTasksPath = path.join(tempDir, 'custom-tasks.json'); + fs.writeFileSync(customTasksPath, '[]'); + + // Act + const taskMaster = initTaskMaster({ + tasksPath: customTasksPath + }); + + // Assert + expect(taskMaster.getTasksPath()).toBe(customTasksPath); + }); + + test('should accept valid relative path override', () => { + // Arrange - Create custom tasks file + const customTasksPath = path.join(tempDir, 'custom-tasks.json'); + fs.writeFileSync(customTasksPath, '[]'); + + // Act + const taskMaster = initTaskMaster({ + tasksPath: './custom-tasks.json' + }); + + // Assert + expect(taskMaster.getTasksPath()).toBe(customTasksPath); + }); + + test('should throw error when string path override does not exist', () => { + // Arrange - Non-existent file path + const nonExistentPath = path.join(tempDir, 'does-not-exist.json'); + + // Act & Assert + expect(() => { + initTaskMaster({ tasksPath: nonExistentPath }); + }).toThrow(`tasks file override path does not exist: ${nonExistentPath}`); + }); + }); + + describe('Legacy file support', () => { + beforeEach(() => { + // Setup basic project structure + const taskMasterDir = path.join(tempDir, TASKMASTER_DIR); + fs.mkdirSync(taskMasterDir, { recursive: true }); + process.chdir(tempDir); + }); + + test('should find legacy tasks file when new format does not exist', () => { + // Arrange - Create legacy tasks file + const legacyTasksDir = path.join(tempDir, 'tasks'); + fs.mkdirSync(legacyTasksDir, { recursive: true }); + const legacyTasksPath = path.join(tempDir, LEGACY_TASKS_FILE); + fs.writeFileSync(legacyTasksPath, '[]'); + + // Act + const taskMaster = initTaskMaster({ tasksPath: true }); + + // Assert + expect(taskMaster.getTasksPath()).toBe(legacyTasksPath); + }); + + test('should prefer new format over legacy when both exist', () => { + // Arrange - Create both new and legacy files + const newTasksPath = path.join(tempDir, TASKMASTER_TASKS_FILE); + fs.mkdirSync(path.dirname(newTasksPath), { recursive: true }); + fs.writeFileSync(newTasksPath, '[]'); + + const legacyTasksDir = path.join(tempDir, 'tasks'); + fs.mkdirSync(legacyTasksDir, { recursive: true }); + const legacyTasksPath = path.join(tempDir, LEGACY_TASKS_FILE); + fs.writeFileSync(legacyTasksPath, '[]'); + + // Act + const taskMaster = initTaskMaster({ tasksPath: true }); + + // Assert + expect(taskMaster.getTasksPath()).toBe(newTasksPath); + }); + + test('should find legacy config file when new format does not exist', () => { + // Arrange - Create legacy config file + const legacyConfigPath = path.join(tempDir, LEGACY_CONFIG_FILE); + fs.writeFileSync(legacyConfigPath, '{}'); + + // Act + const taskMaster = initTaskMaster({ configPath: true }); + + // Assert + expect(taskMaster.getConfigPath()).toBe(legacyConfigPath); + }); + }); + + describe('TaskMaster class methods', () => { + test('should return all paths via getAllPaths method', () => { + // Arrange - Setup project with all files + const taskMasterDir = path.join(tempDir, TASKMASTER_DIR); + fs.mkdirSync(taskMasterDir, { recursive: true }); + + const tasksPath = path.join(tempDir, TASKMASTER_TASKS_FILE); + fs.mkdirSync(path.dirname(tasksPath), { recursive: true }); + fs.writeFileSync(tasksPath, '[]'); + + const configPath = path.join(tempDir, TASKMASTER_CONFIG_FILE); + fs.writeFileSync(configPath, '{}'); + + process.chdir(tempDir); + + // Act + const taskMaster = initTaskMaster({ + tasksPath: true, + configPath: true + }); + + // Assert + const allPaths = taskMaster.getAllPaths(); + expect(allPaths).toEqual( + expect.objectContaining({ + projectRoot: tempDir, + taskMasterDir: taskMasterDir, + tasksPath: tasksPath, + configPath: configPath + }) + ); + + // Verify paths object is frozen + expect(() => { + allPaths.projectRoot = '/different/path'; + }).toThrow(); + }); + + test('should return correct individual paths', () => { + // Arrange + const taskMasterDir = path.join(tempDir, TASKMASTER_DIR); + fs.mkdirSync(taskMasterDir, { recursive: true }); + process.chdir(tempDir); + + // Act + const taskMaster = initTaskMaster({}); + + // Assert + expect(taskMaster.getProjectRoot()).toBe(tempDir); + expect(taskMaster.getTaskMasterDir()).toBe(taskMasterDir); + expect(taskMaster.getTasksPath()).toBeUndefined(); + expect(taskMaster.getPrdPath()).toBeUndefined(); + expect(taskMaster.getComplexityReportPath()).toBeUndefined(); + expect(taskMaster.getConfigPath()).toBeUndefined(); + expect(taskMaster.getStatePath()).toBeUndefined(); + }); + }); +});