This is page 38 of 52. Use http://codebase.md/eyaltoledano/claude-task-master?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .changeset │ ├── config.json │ └── README.md ├── .claude │ ├── agents │ │ ├── task-checker.md │ │ ├── task-executor.md │ │ └── task-orchestrator.md │ ├── commands │ │ ├── dedupe.md │ │ └── tm │ │ ├── add-dependency │ │ │ └── add-dependency.md │ │ ├── add-subtask │ │ │ ├── add-subtask.md │ │ │ └── convert-task-to-subtask.md │ │ ├── add-task │ │ │ └── add-task.md │ │ ├── analyze-complexity │ │ │ └── analyze-complexity.md │ │ ├── complexity-report │ │ │ └── complexity-report.md │ │ ├── expand │ │ │ ├── expand-all-tasks.md │ │ │ └── expand-task.md │ │ ├── fix-dependencies │ │ │ └── fix-dependencies.md │ │ ├── generate │ │ │ └── generate-tasks.md │ │ ├── help.md │ │ ├── init │ │ │ ├── init-project-quick.md │ │ │ └── init-project.md │ │ ├── learn.md │ │ ├── list │ │ │ ├── list-tasks-by-status.md │ │ │ ├── list-tasks-with-subtasks.md │ │ │ └── list-tasks.md │ │ ├── models │ │ │ ├── setup-models.md │ │ │ └── view-models.md │ │ ├── next │ │ │ └── next-task.md │ │ ├── parse-prd │ │ │ ├── parse-prd-with-research.md │ │ │ └── parse-prd.md │ │ ├── remove-dependency │ │ │ └── remove-dependency.md │ │ ├── remove-subtask │ │ │ └── remove-subtask.md │ │ ├── remove-subtasks │ │ │ ├── remove-all-subtasks.md │ │ │ └── remove-subtasks.md │ │ ├── remove-task │ │ │ └── remove-task.md │ │ ├── set-status │ │ │ ├── to-cancelled.md │ │ │ ├── to-deferred.md │ │ │ ├── to-done.md │ │ │ ├── to-in-progress.md │ │ │ ├── to-pending.md │ │ │ └── to-review.md │ │ ├── setup │ │ │ ├── install-taskmaster.md │ │ │ └── quick-install-taskmaster.md │ │ ├── show │ │ │ └── show-task.md │ │ ├── status │ │ │ └── project-status.md │ │ ├── sync-readme │ │ │ └── sync-readme.md │ │ ├── tm-main.md │ │ ├── update │ │ │ ├── update-single-task.md │ │ │ ├── update-task.md │ │ │ └── update-tasks-from-id.md │ │ ├── utils │ │ │ └── analyze-project.md │ │ ├── validate-dependencies │ │ │ └── validate-dependencies.md │ │ └── workflows │ │ ├── auto-implement-tasks.md │ │ ├── command-pipeline.md │ │ └── smart-workflow.md │ └── TM_COMMANDS_GUIDE.md ├── .coderabbit.yaml ├── .cursor │ ├── mcp.json │ └── rules │ ├── ai_providers.mdc │ ├── ai_services.mdc │ ├── architecture.mdc │ ├── changeset.mdc │ ├── commands.mdc │ ├── context_gathering.mdc │ ├── cursor_rules.mdc │ ├── dependencies.mdc │ ├── dev_workflow.mdc │ ├── git_workflow.mdc │ ├── glossary.mdc │ ├── mcp.mdc │ ├── new_features.mdc │ ├── self_improve.mdc │ ├── tags.mdc │ ├── taskmaster.mdc │ ├── tasks.mdc │ ├── telemetry.mdc │ ├── test_workflow.mdc │ ├── tests.mdc │ ├── ui.mdc │ └── utilities.mdc ├── .cursorignore ├── .env.example ├── .github │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.md │ │ ├── enhancements---feature-requests.md │ │ └── feedback.md │ ├── PULL_REQUEST_TEMPLATE │ │ ├── bugfix.md │ │ ├── config.yml │ │ ├── feature.md │ │ └── integration.md │ ├── PULL_REQUEST_TEMPLATE.md │ ├── scripts │ │ ├── auto-close-duplicates.mjs │ │ ├── backfill-duplicate-comments.mjs │ │ ├── check-pre-release-mode.mjs │ │ ├── parse-metrics.mjs │ │ ├── release.mjs │ │ ├── tag-extension.mjs │ │ └── utils.mjs │ └── workflows │ ├── auto-close-duplicates.yml │ ├── backfill-duplicate-comments.yml │ ├── ci.yml │ ├── claude-dedupe-issues.yml │ ├── claude-docs-trigger.yml │ ├── claude-docs-updater.yml │ ├── claude-issue-triage.yml │ ├── claude.yml │ ├── extension-ci.yml │ ├── extension-release.yml │ ├── log-issue-events.yml │ ├── pre-release.yml │ ├── release-check.yml │ ├── release.yml │ ├── update-models-md.yml │ └── weekly-metrics-discord.yml ├── .gitignore ├── .kiro │ ├── hooks │ │ ├── tm-code-change-task-tracker.kiro.hook │ │ ├── tm-complexity-analyzer.kiro.hook │ │ ├── tm-daily-standup-assistant.kiro.hook │ │ ├── tm-git-commit-task-linker.kiro.hook │ │ ├── tm-pr-readiness-checker.kiro.hook │ │ ├── tm-task-dependency-auto-progression.kiro.hook │ │ └── tm-test-success-task-completer.kiro.hook │ ├── settings │ │ └── mcp.json │ └── steering │ ├── dev_workflow.md │ ├── kiro_rules.md │ ├── self_improve.md │ ├── taskmaster_hooks_workflow.md │ └── taskmaster.md ├── .manypkg.json ├── .mcp.json ├── .npmignore ├── .nvmrc ├── .taskmaster │ ├── CLAUDE.md │ ├── config.json │ ├── docs │ │ ├── MIGRATION-ROADMAP.md │ │ ├── prd-tm-start.txt │ │ ├── prd.txt │ │ ├── README.md │ │ ├── research │ │ │ ├── 2025-06-14_how-can-i-improve-the-scope-up-and-scope-down-comm.md │ │ │ ├── 2025-06-14_should-i-be-using-any-specific-libraries-for-this.md │ │ │ ├── 2025-06-14_test-save-functionality.md │ │ │ ├── 2025-06-14_test-the-fix-for-duplicate-saves-final-test.md │ │ │ └── 2025-08-01_do-we-need-to-add-new-commands-or-can-we-just-weap.md │ │ ├── task-template-importing-prd.txt │ │ ├── test-prd.txt │ │ └── tm-core-phase-1.txt │ ├── reports │ │ ├── task-complexity-report_cc-kiro-hooks.json │ │ ├── task-complexity-report_test-prd-tag.json │ │ ├── task-complexity-report_tm-core-phase-1.json │ │ ├── task-complexity-report.json │ │ └── tm-core-complexity.json │ ├── state.json │ ├── tasks │ │ ├── task_001_tm-start.txt │ │ ├── task_002_tm-start.txt │ │ ├── task_003_tm-start.txt │ │ ├── task_004_tm-start.txt │ │ ├── task_007_tm-start.txt │ │ └── tasks.json │ └── templates │ └── example_prd.txt ├── .vscode │ ├── extensions.json │ └── settings.json ├── apps │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── auth.command.ts │ │ │ │ ├── context.command.ts │ │ │ │ ├── list.command.ts │ │ │ │ ├── set-status.command.ts │ │ │ │ ├── show.command.ts │ │ │ │ └── start.command.ts │ │ │ ├── index.ts │ │ │ ├── ui │ │ │ │ ├── components │ │ │ │ │ ├── dashboard.component.ts │ │ │ │ │ ├── header.component.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── next-task.component.ts │ │ │ │ │ ├── suggested-steps.component.ts │ │ │ │ │ └── task-detail.component.ts │ │ │ │ └── index.ts │ │ │ └── utils │ │ │ ├── auto-update.ts │ │ │ └── ui.ts │ │ └── tsconfig.json │ ├── docs │ │ ├── archive │ │ │ ├── ai-client-utils-example.mdx │ │ │ ├── ai-development-workflow.mdx │ │ │ ├── command-reference.mdx │ │ │ ├── configuration.mdx │ │ │ ├── cursor-setup.mdx │ │ │ ├── examples.mdx │ │ │ └── Installation.mdx │ │ ├── best-practices │ │ │ ├── advanced-tasks.mdx │ │ │ ├── configuration-advanced.mdx │ │ │ └── index.mdx │ │ ├── capabilities │ │ │ ├── cli-root-commands.mdx │ │ │ ├── index.mdx │ │ │ ├── mcp.mdx │ │ │ └── task-structure.mdx │ │ ├── CHANGELOG.md │ │ ├── docs.json │ │ ├── favicon.svg │ │ ├── getting-started │ │ │ ├── contribute.mdx │ │ │ ├── faq.mdx │ │ │ └── quick-start │ │ │ ├── configuration-quick.mdx │ │ │ ├── execute-quick.mdx │ │ │ ├── installation.mdx │ │ │ ├── moving-forward.mdx │ │ │ ├── prd-quick.mdx │ │ │ ├── quick-start.mdx │ │ │ ├── requirements.mdx │ │ │ ├── rules-quick.mdx │ │ │ └── tasks-quick.mdx │ │ ├── introduction.mdx │ │ ├── licensing.md │ │ ├── logo │ │ │ ├── dark.svg │ │ │ ├── light.svg │ │ │ └── task-master-logo.png │ │ ├── package.json │ │ ├── README.md │ │ ├── style.css │ │ ├── vercel.json │ │ └── whats-new.mdx │ └── extension │ ├── .vscodeignore │ ├── assets │ │ ├── banner.png │ │ ├── icon-dark.svg │ │ ├── icon-light.svg │ │ ├── icon.png │ │ ├── screenshots │ │ │ ├── kanban-board.png │ │ │ └── task-details.png │ │ └── sidebar-icon.svg │ ├── CHANGELOG.md │ ├── components.json │ ├── docs │ │ ├── extension-CI-setup.md │ │ └── extension-development-guide.md │ ├── esbuild.js │ ├── LICENSE │ ├── package.json │ ├── package.mjs │ ├── package.publish.json │ ├── README.md │ ├── src │ │ ├── components │ │ │ ├── ConfigView.tsx │ │ │ ├── constants.ts │ │ │ ├── TaskDetails │ │ │ │ ├── AIActionsSection.tsx │ │ │ │ ├── DetailsSection.tsx │ │ │ │ ├── PriorityBadge.tsx │ │ │ │ ├── SubtasksSection.tsx │ │ │ │ ├── TaskMetadataSidebar.tsx │ │ │ │ └── useTaskDetails.ts │ │ │ ├── TaskDetailsView.tsx │ │ │ ├── TaskMasterLogo.tsx │ │ │ └── ui │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── CollapsibleSection.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── label.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── separator.tsx │ │ │ ├── shadcn-io │ │ │ │ └── kanban │ │ │ │ └── index.tsx │ │ │ └── textarea.tsx │ │ ├── extension.ts │ │ ├── index.ts │ │ ├── lib │ │ │ └── utils.ts │ │ ├── services │ │ │ ├── config-service.ts │ │ │ ├── error-handler.ts │ │ │ ├── notification-preferences.ts │ │ │ ├── polling-service.ts │ │ │ ├── polling-strategies.ts │ │ │ ├── sidebar-webview-manager.ts │ │ │ ├── task-repository.ts │ │ │ ├── terminal-manager.ts │ │ │ └── webview-manager.ts │ │ ├── test │ │ │ └── extension.test.ts │ │ ├── utils │ │ │ ├── configManager.ts │ │ │ ├── connectionManager.ts │ │ │ ├── errorHandler.ts │ │ │ ├── event-emitter.ts │ │ │ ├── logger.ts │ │ │ ├── mcpClient.ts │ │ │ ├── notificationPreferences.ts │ │ │ └── task-master-api │ │ │ ├── cache │ │ │ │ └── cache-manager.ts │ │ │ ├── index.ts │ │ │ ├── mcp-client.ts │ │ │ ├── transformers │ │ │ │ └── task-transformer.ts │ │ │ └── types │ │ │ └── index.ts │ │ └── webview │ │ ├── App.tsx │ │ ├── components │ │ │ ├── AppContent.tsx │ │ │ ├── EmptyState.tsx │ │ │ ├── ErrorBoundary.tsx │ │ │ ├── PollingStatus.tsx │ │ │ ├── PriorityBadge.tsx │ │ │ ├── SidebarView.tsx │ │ │ ├── TagDropdown.tsx │ │ │ ├── TaskCard.tsx │ │ │ ├── TaskEditModal.tsx │ │ │ ├── TaskMasterKanban.tsx │ │ │ ├── ToastContainer.tsx │ │ │ └── ToastNotification.tsx │ │ ├── constants │ │ │ └── index.ts │ │ ├── contexts │ │ │ └── VSCodeContext.tsx │ │ ├── hooks │ │ │ ├── useTaskQueries.ts │ │ │ ├── useVSCodeMessages.ts │ │ │ └── useWebviewHeight.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── providers │ │ │ └── QueryProvider.tsx │ │ ├── reducers │ │ │ └── appReducer.ts │ │ ├── sidebar.tsx │ │ ├── types │ │ │ └── index.ts │ │ └── utils │ │ ├── logger.ts │ │ └── toast.ts │ └── tsconfig.json ├── assets │ ├── .windsurfrules │ ├── AGENTS.md │ ├── claude │ │ ├── agents │ │ │ ├── task-checker.md │ │ │ ├── task-executor.md │ │ │ └── task-orchestrator.md │ │ ├── commands │ │ │ └── tm │ │ │ ├── add-dependency │ │ │ │ └── add-dependency.md │ │ │ ├── add-subtask │ │ │ │ ├── add-subtask.md │ │ │ │ └── convert-task-to-subtask.md │ │ │ ├── add-task │ │ │ │ └── add-task.md │ │ │ ├── analyze-complexity │ │ │ │ └── analyze-complexity.md │ │ │ ├── clear-subtasks │ │ │ │ ├── clear-all-subtasks.md │ │ │ │ └── clear-subtasks.md │ │ │ ├── complexity-report │ │ │ │ └── complexity-report.md │ │ │ ├── expand │ │ │ │ ├── expand-all-tasks.md │ │ │ │ └── expand-task.md │ │ │ ├── fix-dependencies │ │ │ │ └── fix-dependencies.md │ │ │ ├── generate │ │ │ │ └── generate-tasks.md │ │ │ ├── help.md │ │ │ ├── init │ │ │ │ ├── init-project-quick.md │ │ │ │ └── init-project.md │ │ │ ├── learn.md │ │ │ ├── list │ │ │ │ ├── list-tasks-by-status.md │ │ │ │ ├── list-tasks-with-subtasks.md │ │ │ │ └── list-tasks.md │ │ │ ├── models │ │ │ │ ├── setup-models.md │ │ │ │ └── view-models.md │ │ │ ├── next │ │ │ │ └── next-task.md │ │ │ ├── parse-prd │ │ │ │ ├── parse-prd-with-research.md │ │ │ │ └── parse-prd.md │ │ │ ├── remove-dependency │ │ │ │ └── remove-dependency.md │ │ │ ├── remove-subtask │ │ │ │ └── remove-subtask.md │ │ │ ├── remove-subtasks │ │ │ │ ├── remove-all-subtasks.md │ │ │ │ └── remove-subtasks.md │ │ │ ├── remove-task │ │ │ │ └── remove-task.md │ │ │ ├── set-status │ │ │ │ ├── to-cancelled.md │ │ │ │ ├── to-deferred.md │ │ │ │ ├── to-done.md │ │ │ │ ├── to-in-progress.md │ │ │ │ ├── to-pending.md │ │ │ │ └── to-review.md │ │ │ ├── setup │ │ │ │ ├── install-taskmaster.md │ │ │ │ └── quick-install-taskmaster.md │ │ │ ├── show │ │ │ │ └── show-task.md │ │ │ ├── status │ │ │ │ └── project-status.md │ │ │ ├── sync-readme │ │ │ │ └── sync-readme.md │ │ │ ├── tm-main.md │ │ │ ├── update │ │ │ │ ├── update-single-task.md │ │ │ │ ├── update-task.md │ │ │ │ └── update-tasks-from-id.md │ │ │ ├── utils │ │ │ │ └── analyze-project.md │ │ │ ├── validate-dependencies │ │ │ │ └── validate-dependencies.md │ │ │ └── workflows │ │ │ ├── auto-implement-tasks.md │ │ │ ├── command-pipeline.md │ │ │ └── smart-workflow.md │ │ └── TM_COMMANDS_GUIDE.md │ ├── config.json │ ├── env.example │ ├── example_prd.txt │ ├── gitignore │ ├── kiro-hooks │ │ ├── tm-code-change-task-tracker.kiro.hook │ │ ├── tm-complexity-analyzer.kiro.hook │ │ ├── tm-daily-standup-assistant.kiro.hook │ │ ├── tm-git-commit-task-linker.kiro.hook │ │ ├── tm-pr-readiness-checker.kiro.hook │ │ ├── tm-task-dependency-auto-progression.kiro.hook │ │ └── tm-test-success-task-completer.kiro.hook │ ├── roocode │ │ ├── .roo │ │ │ ├── rules-architect │ │ │ │ └── architect-rules │ │ │ ├── rules-ask │ │ │ │ └── ask-rules │ │ │ ├── rules-code │ │ │ │ └── code-rules │ │ │ ├── rules-debug │ │ │ │ └── debug-rules │ │ │ ├── rules-orchestrator │ │ │ │ └── orchestrator-rules │ │ │ └── rules-test │ │ │ └── test-rules │ │ └── .roomodes │ ├── rules │ │ ├── cursor_rules.mdc │ │ ├── dev_workflow.mdc │ │ ├── self_improve.mdc │ │ ├── taskmaster_hooks_workflow.mdc │ │ └── taskmaster.mdc │ └── scripts_README.md ├── bin │ └── task-master.js ├── biome.json ├── CHANGELOG.md ├── CLAUDE.md ├── context │ ├── chats │ │ ├── add-task-dependencies-1.md │ │ └── max-min-tokens.txt.md │ ├── fastmcp-core.txt │ ├── fastmcp-docs.txt │ ├── MCP_INTEGRATION.md │ ├── mcp-js-sdk-docs.txt │ ├── mcp-protocol-repo.txt │ ├── mcp-protocol-schema-03262025.json │ └── mcp-protocol-spec.txt ├── CONTRIBUTING.md ├── docs │ ├── CLI-COMMANDER-PATTERN.md │ ├── command-reference.md │ ├── configuration.md │ ├── contributor-docs │ │ └── testing-roo-integration.md │ ├── cross-tag-task-movement.md │ ├── examples │ │ └── claude-code-usage.md │ ├── examples.md │ ├── licensing.md │ ├── mcp-provider-guide.md │ ├── mcp-provider.md │ ├── migration-guide.md │ ├── models.md │ ├── providers │ │ └── gemini-cli.md │ ├── README.md │ ├── scripts │ │ └── models-json-to-markdown.js │ ├── task-structure.md │ └── tutorial.md ├── images │ └── logo.png ├── index.js ├── jest.config.js ├── jest.resolver.cjs ├── LICENSE ├── llms-install.md ├── mcp-server │ ├── server.js │ └── src │ ├── core │ │ ├── __tests__ │ │ │ └── context-manager.test.js │ │ ├── context-manager.js │ │ ├── direct-functions │ │ │ ├── add-dependency.js │ │ │ ├── add-subtask.js │ │ │ ├── add-tag.js │ │ │ ├── add-task.js │ │ │ ├── analyze-task-complexity.js │ │ │ ├── cache-stats.js │ │ │ ├── clear-subtasks.js │ │ │ ├── complexity-report.js │ │ │ ├── copy-tag.js │ │ │ ├── create-tag-from-branch.js │ │ │ ├── delete-tag.js │ │ │ ├── expand-all-tasks.js │ │ │ ├── expand-task.js │ │ │ ├── fix-dependencies.js │ │ │ ├── generate-task-files.js │ │ │ ├── initialize-project.js │ │ │ ├── list-tags.js │ │ │ ├── list-tasks.js │ │ │ ├── models.js │ │ │ ├── move-task-cross-tag.js │ │ │ ├── move-task.js │ │ │ ├── next-task.js │ │ │ ├── parse-prd.js │ │ │ ├── remove-dependency.js │ │ │ ├── remove-subtask.js │ │ │ ├── remove-task.js │ │ │ ├── rename-tag.js │ │ │ ├── research.js │ │ │ ├── response-language.js │ │ │ ├── rules.js │ │ │ ├── scope-down.js │ │ │ ├── scope-up.js │ │ │ ├── set-task-status.js │ │ │ ├── show-task.js │ │ │ ├── update-subtask-by-id.js │ │ │ ├── update-task-by-id.js │ │ │ ├── update-tasks.js │ │ │ ├── use-tag.js │ │ │ └── validate-dependencies.js │ │ ├── task-master-core.js │ │ └── utils │ │ ├── env-utils.js │ │ └── path-utils.js │ ├── custom-sdk │ │ ├── errors.js │ │ ├── index.js │ │ ├── json-extractor.js │ │ ├── language-model.js │ │ ├── message-converter.js │ │ └── schema-converter.js │ ├── index.js │ ├── logger.js │ ├── providers │ │ └── mcp-provider.js │ └── tools │ ├── add-dependency.js │ ├── add-subtask.js │ ├── add-tag.js │ ├── add-task.js │ ├── analyze.js │ ├── clear-subtasks.js │ ├── complexity-report.js │ ├── copy-tag.js │ ├── delete-tag.js │ ├── expand-all.js │ ├── expand-task.js │ ├── fix-dependencies.js │ ├── generate.js │ ├── get-operation-status.js │ ├── get-task.js │ ├── get-tasks.js │ ├── index.js │ ├── initialize-project.js │ ├── list-tags.js │ ├── models.js │ ├── move-task.js │ ├── next-task.js │ ├── parse-prd.js │ ├── remove-dependency.js │ ├── remove-subtask.js │ ├── remove-task.js │ ├── rename-tag.js │ ├── research.js │ ├── response-language.js │ ├── rules.js │ ├── scope-down.js │ ├── scope-up.js │ ├── set-task-status.js │ ├── update-subtask.js │ ├── update-task.js │ ├── update.js │ ├── use-tag.js │ ├── utils.js │ └── validate-dependencies.js ├── mcp-test.js ├── output.json ├── package-lock.json ├── package.json ├── packages │ ├── build-config │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ └── tsdown.base.ts │ │ └── tsconfig.json │ └── tm-core │ ├── .gitignore │ ├── CHANGELOG.md │ ├── docs │ │ └── listTasks-architecture.md │ ├── package.json │ ├── POC-STATUS.md │ ├── README.md │ ├── src │ │ ├── auth │ │ │ ├── auth-manager.test.ts │ │ │ ├── auth-manager.ts │ │ │ ├── config.ts │ │ │ ├── credential-store.test.ts │ │ │ ├── credential-store.ts │ │ │ ├── index.ts │ │ │ ├── oauth-service.ts │ │ │ ├── supabase-session-storage.ts │ │ │ └── types.ts │ │ ├── clients │ │ │ ├── index.ts │ │ │ └── supabase-client.ts │ │ ├── config │ │ │ ├── config-manager.spec.ts │ │ │ ├── config-manager.ts │ │ │ ├── index.ts │ │ │ └── services │ │ │ ├── config-loader.service.spec.ts │ │ │ ├── config-loader.service.ts │ │ │ ├── config-merger.service.spec.ts │ │ │ ├── config-merger.service.ts │ │ │ ├── config-persistence.service.spec.ts │ │ │ ├── config-persistence.service.ts │ │ │ ├── environment-config-provider.service.spec.ts │ │ │ ├── environment-config-provider.service.ts │ │ │ ├── index.ts │ │ │ ├── runtime-state-manager.service.spec.ts │ │ │ └── runtime-state-manager.service.ts │ │ ├── constants │ │ │ └── index.ts │ │ ├── entities │ │ │ └── task.entity.ts │ │ ├── errors │ │ │ ├── index.ts │ │ │ └── task-master-error.ts │ │ ├── executors │ │ │ ├── base-executor.ts │ │ │ ├── claude-executor.ts │ │ │ ├── executor-factory.ts │ │ │ ├── executor-service.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── index.ts │ │ ├── interfaces │ │ │ ├── ai-provider.interface.ts │ │ │ ├── configuration.interface.ts │ │ │ ├── index.ts │ │ │ └── storage.interface.ts │ │ ├── logger │ │ │ ├── factory.ts │ │ │ ├── index.ts │ │ │ └── logger.ts │ │ ├── mappers │ │ │ └── TaskMapper.ts │ │ ├── parser │ │ │ └── index.ts │ │ ├── providers │ │ │ ├── ai │ │ │ │ ├── base-provider.ts │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── repositories │ │ │ ├── supabase-task-repository.ts │ │ │ └── task-repository.interface.ts │ │ ├── services │ │ │ ├── index.ts │ │ │ ├── organization.service.ts │ │ │ ├── task-execution-service.ts │ │ │ └── task-service.ts │ │ ├── storage │ │ │ ├── api-storage.ts │ │ │ ├── file-storage │ │ │ │ ├── file-operations.ts │ │ │ │ ├── file-storage.ts │ │ │ │ ├── format-handler.ts │ │ │ │ ├── index.ts │ │ │ │ └── path-resolver.ts │ │ │ ├── index.ts │ │ │ └── storage-factory.ts │ │ ├── subpath-exports.test.ts │ │ ├── task-master-core.ts │ │ ├── types │ │ │ ├── database.types.ts │ │ │ ├── index.ts │ │ │ └── legacy.ts │ │ └── utils │ │ ├── id-generator.ts │ │ └── index.ts │ ├── tests │ │ ├── integration │ │ │ └── list-tasks.test.ts │ │ ├── mocks │ │ │ └── mock-provider.ts │ │ ├── setup.ts │ │ └── unit │ │ ├── base-provider.test.ts │ │ ├── executor.test.ts │ │ └── smoke.test.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── README-task-master.md ├── README.md ├── scripts │ ├── dev.js │ ├── init.js │ ├── modules │ │ ├── ai-services-unified.js │ │ ├── commands.js │ │ ├── config-manager.js │ │ ├── dependency-manager.js │ │ ├── index.js │ │ ├── prompt-manager.js │ │ ├── supported-models.json │ │ ├── sync-readme.js │ │ ├── task-manager │ │ │ ├── add-subtask.js │ │ │ ├── add-task.js │ │ │ ├── analyze-task-complexity.js │ │ │ ├── clear-subtasks.js │ │ │ ├── expand-all-tasks.js │ │ │ ├── expand-task.js │ │ │ ├── find-next-task.js │ │ │ ├── generate-task-files.js │ │ │ ├── is-task-dependent.js │ │ │ ├── list-tasks.js │ │ │ ├── migrate.js │ │ │ ├── models.js │ │ │ ├── move-task.js │ │ │ ├── parse-prd │ │ │ │ ├── index.js │ │ │ │ ├── parse-prd-config.js │ │ │ │ ├── parse-prd-helpers.js │ │ │ │ ├── parse-prd-non-streaming.js │ │ │ │ ├── parse-prd-streaming.js │ │ │ │ └── parse-prd.js │ │ │ ├── remove-subtask.js │ │ │ ├── remove-task.js │ │ │ ├── research.js │ │ │ ├── response-language.js │ │ │ ├── scope-adjustment.js │ │ │ ├── set-task-status.js │ │ │ ├── tag-management.js │ │ │ ├── task-exists.js │ │ │ ├── update-single-task-status.js │ │ │ ├── update-subtask-by-id.js │ │ │ ├── update-task-by-id.js │ │ │ └── update-tasks.js │ │ ├── task-manager.js │ │ ├── ui.js │ │ ├── update-config-tokens.js │ │ ├── utils │ │ │ ├── contextGatherer.js │ │ │ ├── fuzzyTaskSearch.js │ │ │ └── git-utils.js │ │ └── utils.js │ ├── task-complexity-report.json │ ├── test-claude-errors.js │ └── test-claude.js ├── src │ ├── ai-providers │ │ ├── anthropic.js │ │ ├── azure.js │ │ ├── base-provider.js │ │ ├── bedrock.js │ │ ├── claude-code.js │ │ ├── custom-sdk │ │ │ ├── claude-code │ │ │ │ ├── errors.js │ │ │ │ ├── index.js │ │ │ │ ├── json-extractor.js │ │ │ │ ├── language-model.js │ │ │ │ ├── message-converter.js │ │ │ │ └── types.js │ │ │ └── grok-cli │ │ │ ├── errors.js │ │ │ ├── index.js │ │ │ ├── json-extractor.js │ │ │ ├── language-model.js │ │ │ ├── message-converter.js │ │ │ └── types.js │ │ ├── gemini-cli.js │ │ ├── google-vertex.js │ │ ├── google.js │ │ ├── grok-cli.js │ │ ├── groq.js │ │ ├── index.js │ │ ├── ollama.js │ │ ├── openai.js │ │ ├── openrouter.js │ │ ├── perplexity.js │ │ └── xai.js │ ├── constants │ │ ├── commands.js │ │ ├── paths.js │ │ ├── profiles.js │ │ ├── providers.js │ │ ├── rules-actions.js │ │ ├── task-priority.js │ │ └── task-status.js │ ├── profiles │ │ ├── amp.js │ │ ├── base-profile.js │ │ ├── claude.js │ │ ├── cline.js │ │ ├── codex.js │ │ ├── cursor.js │ │ ├── gemini.js │ │ ├── index.js │ │ ├── kilo.js │ │ ├── kiro.js │ │ ├── opencode.js │ │ ├── roo.js │ │ ├── trae.js │ │ ├── vscode.js │ │ ├── windsurf.js │ │ └── zed.js │ ├── progress │ │ ├── base-progress-tracker.js │ │ ├── cli-progress-factory.js │ │ ├── parse-prd-tracker.js │ │ ├── progress-tracker-builder.js │ │ └── tracker-ui.js │ ├── prompts │ │ ├── add-task.json │ │ ├── analyze-complexity.json │ │ ├── expand-task.json │ │ ├── parse-prd.json │ │ ├── README.md │ │ ├── research.json │ │ ├── schemas │ │ │ ├── parameter.schema.json │ │ │ ├── prompt-template.schema.json │ │ │ ├── README.md │ │ │ └── variant.schema.json │ │ ├── update-subtask.json │ │ ├── update-task.json │ │ └── update-tasks.json │ ├── provider-registry │ │ └── index.js │ ├── task-master.js │ ├── ui │ │ ├── confirm.js │ │ ├── indicators.js │ │ └── parse-prd.js │ └── utils │ ├── asset-resolver.js │ ├── create-mcp-config.js │ ├── format.js │ ├── getVersion.js │ ├── logger-utils.js │ ├── manage-gitignore.js │ ├── path-utils.js │ ├── profiles.js │ ├── rule-transformer.js │ ├── stream-parser.js │ └── timeout-manager.js ├── test-clean-tags.js ├── test-config-manager.js ├── test-prd.txt ├── test-tag-functions.js ├── test-version-check-full.js ├── test-version-check.js ├── tests │ ├── e2e │ │ ├── e2e_helpers.sh │ │ ├── parse_llm_output.cjs │ │ ├── run_e2e.sh │ │ ├── run_fallback_verification.sh │ │ └── test_llm_analysis.sh │ ├── fixture │ │ └── test-tasks.json │ ├── fixtures │ │ ├── .taskmasterconfig │ │ ├── sample-claude-response.js │ │ ├── sample-prd.txt │ │ └── sample-tasks.js │ ├── integration │ │ ├── claude-code-optional.test.js │ │ ├── cli │ │ │ ├── commands.test.js │ │ │ ├── complex-cross-tag-scenarios.test.js │ │ │ └── move-cross-tag.test.js │ │ ├── manage-gitignore.test.js │ │ ├── mcp-server │ │ │ └── direct-functions.test.js │ │ ├── move-task-cross-tag.integration.test.js │ │ ├── move-task-simple.integration.test.js │ │ └── profiles │ │ ├── amp-init-functionality.test.js │ │ ├── claude-init-functionality.test.js │ │ ├── cline-init-functionality.test.js │ │ ├── codex-init-functionality.test.js │ │ ├── cursor-init-functionality.test.js │ │ ├── gemini-init-functionality.test.js │ │ ├── opencode-init-functionality.test.js │ │ ├── roo-files-inclusion.test.js │ │ ├── roo-init-functionality.test.js │ │ ├── rules-files-inclusion.test.js │ │ ├── trae-init-functionality.test.js │ │ ├── vscode-init-functionality.test.js │ │ └── windsurf-init-functionality.test.js │ ├── manual │ │ ├── progress │ │ │ ├── parse-prd-analysis.js │ │ │ ├── test-parse-prd.js │ │ │ └── TESTING_GUIDE.md │ │ └── prompts │ │ ├── prompt-test.js │ │ └── README.md │ ├── README.md │ ├── setup.js │ └── unit │ ├── ai-providers │ │ ├── claude-code.test.js │ │ ├── custom-sdk │ │ │ └── claude-code │ │ │ └── language-model.test.js │ │ ├── gemini-cli.test.js │ │ ├── mcp-components.test.js │ │ └── openai.test.js │ ├── ai-services-unified.test.js │ ├── commands.test.js │ ├── config-manager.test.js │ ├── config-manager.test.mjs │ ├── dependency-manager.test.js │ ├── init.test.js │ ├── initialize-project.test.js │ ├── kebab-case-validation.test.js │ ├── manage-gitignore.test.js │ ├── mcp │ │ └── tools │ │ ├── __mocks__ │ │ │ └── move-task.js │ │ ├── add-task.test.js │ │ ├── analyze-complexity.test.js │ │ ├── expand-all.test.js │ │ ├── get-tasks.test.js │ │ ├── initialize-project.test.js │ │ ├── move-task-cross-tag-options.test.js │ │ ├── move-task-cross-tag.test.js │ │ └── remove-task.test.js │ ├── mcp-providers │ │ ├── mcp-components.test.js │ │ └── mcp-provider.test.js │ ├── parse-prd.test.js │ ├── profiles │ │ ├── amp-integration.test.js │ │ ├── claude-integration.test.js │ │ ├── cline-integration.test.js │ │ ├── codex-integration.test.js │ │ ├── cursor-integration.test.js │ │ ├── gemini-integration.test.js │ │ ├── kilo-integration.test.js │ │ ├── kiro-integration.test.js │ │ ├── mcp-config-validation.test.js │ │ ├── opencode-integration.test.js │ │ ├── profile-safety-check.test.js │ │ ├── roo-integration.test.js │ │ ├── rule-transformer-cline.test.js │ │ ├── rule-transformer-cursor.test.js │ │ ├── rule-transformer-gemini.test.js │ │ ├── rule-transformer-kilo.test.js │ │ ├── rule-transformer-kiro.test.js │ │ ├── rule-transformer-opencode.test.js │ │ ├── rule-transformer-roo.test.js │ │ ├── rule-transformer-trae.test.js │ │ ├── rule-transformer-vscode.test.js │ │ ├── rule-transformer-windsurf.test.js │ │ ├── rule-transformer-zed.test.js │ │ ├── rule-transformer.test.js │ │ ├── selective-profile-removal.test.js │ │ ├── subdirectory-support.test.js │ │ ├── trae-integration.test.js │ │ ├── vscode-integration.test.js │ │ ├── windsurf-integration.test.js │ │ └── zed-integration.test.js │ ├── progress │ │ └── base-progress-tracker.test.js │ ├── prompt-manager.test.js │ ├── prompts │ │ └── expand-task-prompt.test.js │ ├── providers │ │ └── provider-registry.test.js │ ├── scripts │ │ └── modules │ │ ├── commands │ │ │ ├── move-cross-tag.test.js │ │ │ └── README.md │ │ ├── dependency-manager │ │ │ ├── circular-dependencies.test.js │ │ │ ├── cross-tag-dependencies.test.js │ │ │ └── fix-dependencies-command.test.js │ │ ├── task-manager │ │ │ ├── add-subtask.test.js │ │ │ ├── add-task.test.js │ │ │ ├── analyze-task-complexity.test.js │ │ │ ├── clear-subtasks.test.js │ │ │ ├── complexity-report-tag-isolation.test.js │ │ │ ├── expand-all-tasks.test.js │ │ │ ├── expand-task.test.js │ │ │ ├── find-next-task.test.js │ │ │ ├── generate-task-files.test.js │ │ │ ├── list-tasks.test.js │ │ │ ├── move-task-cross-tag.test.js │ │ │ ├── move-task.test.js │ │ │ ├── parse-prd.test.js │ │ │ ├── remove-subtask.test.js │ │ │ ├── remove-task.test.js │ │ │ ├── research.test.js │ │ │ ├── scope-adjustment.test.js │ │ │ ├── set-task-status.test.js │ │ │ ├── setup.js │ │ │ ├── update-single-task-status.test.js │ │ │ ├── update-subtask-by-id.test.js │ │ │ ├── update-task-by-id.test.js │ │ │ └── update-tasks.test.js │ │ ├── ui │ │ │ └── cross-tag-error-display.test.js │ │ └── utils-tag-aware-paths.test.js │ ├── task-finder.test.js │ ├── task-manager │ │ ├── clear-subtasks.test.js │ │ ├── move-task.test.js │ │ ├── tag-boundary.test.js │ │ └── tag-management.test.js │ ├── task-master.test.js │ ├── ui │ │ └── indicators.test.js │ ├── ui.test.js │ ├── utils-strip-ansi.test.js │ └── utils.test.js ├── tsconfig.json ├── tsdown.config.ts └── turbo.json ``` # Files -------------------------------------------------------------------------------- /tests/unit/config-manager.test.mjs: -------------------------------------------------------------------------------- ``` 1 | // @ts-check 2 | /** 3 | * Module to test the config-manager.js functionality 4 | * This file uses ES module syntax (.mjs) to properly handle imports 5 | */ 6 | 7 | import fs from 'fs'; 8 | import path from 'path'; 9 | import { jest } from '@jest/globals'; 10 | import { fileURLToPath } from 'url'; 11 | import { sampleTasks } from '../fixtures/sample-tasks.js'; 12 | 13 | // Disable chalk's color detection which can cause fs.readFileSync calls 14 | process.env.FORCE_COLOR = '0'; 15 | 16 | // --- Read REAL supported-models.json data BEFORE mocks --- 17 | const __filename = fileURLToPath(import.meta.url); // Get current file path 18 | const __dirname = path.dirname(__filename); // Get current directory 19 | const realSupportedModelsPath = path.resolve( 20 | __dirname, 21 | '../../scripts/modules/supported-models.json' 22 | ); 23 | let REAL_SUPPORTED_MODELS_CONTENT; 24 | let REAL_SUPPORTED_MODELS_DATA; 25 | try { 26 | REAL_SUPPORTED_MODELS_CONTENT = fs.readFileSync( 27 | realSupportedModelsPath, 28 | 'utf-8' 29 | ); 30 | REAL_SUPPORTED_MODELS_DATA = JSON.parse(REAL_SUPPORTED_MODELS_CONTENT); 31 | } catch (err) { 32 | console.error( 33 | 'FATAL TEST SETUP ERROR: Could not read or parse real supported-models.json', 34 | err 35 | ); 36 | REAL_SUPPORTED_MODELS_CONTENT = '{}'; // Default to empty object on error 37 | REAL_SUPPORTED_MODELS_DATA = {}; 38 | process.exit(1); // Exit if essential test data can't be loaded 39 | } 40 | 41 | // --- Define Mock Function Instances --- 42 | const mockFindProjectRoot = jest.fn(); 43 | const mockLog = jest.fn(); 44 | const mockResolveEnvVariable = jest.fn(); 45 | 46 | // --- Mock fs functions directly instead of the whole module --- 47 | const mockExistsSync = jest.fn(); 48 | const mockReadFileSync = jest.fn(); 49 | const mockWriteFileSync = jest.fn(); 50 | 51 | // Instead of mocking the entire fs module, mock just the functions we need 52 | fs.existsSync = mockExistsSync; 53 | fs.readFileSync = mockReadFileSync; 54 | fs.writeFileSync = mockWriteFileSync; 55 | 56 | // --- Test Data (Keep as is, ensure DEFAULT_CONFIG is accurate) --- 57 | const MOCK_PROJECT_ROOT = '/mock/project'; 58 | const MOCK_CONFIG_PATH = path.join(MOCK_PROJECT_ROOT, '.taskmasterconfig'); 59 | 60 | // Updated DEFAULT_CONFIG reflecting the implementation 61 | const DEFAULT_CONFIG = { 62 | models: { 63 | main: { 64 | provider: 'anthropic', 65 | modelId: 'claude-3-7-sonnet-20250219', 66 | maxTokens: 64000, 67 | temperature: 0.2 68 | }, 69 | research: { 70 | provider: 'perplexity', 71 | modelId: 'sonar-pro', 72 | maxTokens: 8700, 73 | temperature: 0.1 74 | }, 75 | fallback: { 76 | provider: 'anthropic', 77 | modelId: 'claude-3-5-sonnet', 78 | maxTokens: 8192, 79 | temperature: 0.2 80 | } 81 | }, 82 | global: { 83 | logLevel: 'info', 84 | debug: false, 85 | defaultSubtasks: 5, 86 | defaultPriority: 'medium', 87 | projectName: 'Task Master', 88 | ollamaBaseURL: 'http://localhost:11434/api' 89 | } 90 | }; 91 | 92 | // Other test data (VALID_CUSTOM_CONFIG, PARTIAL_CONFIG, INVALID_PROVIDER_CONFIG) 93 | const VALID_CUSTOM_CONFIG = { 94 | models: { 95 | main: { 96 | provider: 'openai', 97 | modelId: 'gpt-4o', 98 | maxTokens: 4096, 99 | temperature: 0.5 100 | }, 101 | research: { 102 | provider: 'google', 103 | modelId: 'gemini-1.5-pro-latest', 104 | maxTokens: 8192, 105 | temperature: 0.3 106 | }, 107 | fallback: { 108 | provider: 'anthropic', 109 | modelId: 'claude-3-opus-20240229', 110 | maxTokens: 100000, 111 | temperature: 0.4 112 | } 113 | }, 114 | global: { 115 | logLevel: 'debug', 116 | defaultPriority: 'high', 117 | projectName: 'My Custom Project' 118 | } 119 | }; 120 | 121 | const PARTIAL_CONFIG = { 122 | models: { 123 | main: { provider: 'openai', modelId: 'gpt-4-turbo' } 124 | }, 125 | global: { 126 | projectName: 'Partial Project' 127 | } 128 | }; 129 | 130 | const INVALID_PROVIDER_CONFIG = { 131 | models: { 132 | main: { provider: 'invalid-provider', modelId: 'some-model' }, 133 | research: { 134 | provider: 'perplexity', 135 | modelId: 'llama-3-sonar-large-32k-online' 136 | } 137 | }, 138 | global: { 139 | logLevel: 'warn' 140 | } 141 | }; 142 | 143 | // Define spies globally to be restored in afterAll 144 | let consoleErrorSpy; 145 | let consoleWarnSpy; 146 | 147 | beforeAll(() => { 148 | // Set up console spies 149 | consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 150 | consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); 151 | }); 152 | 153 | afterAll(() => { 154 | // Restore all spies 155 | jest.restoreAllMocks(); 156 | }); 157 | 158 | describe('Config Manager Module', () => { 159 | // Declare variables for imported module 160 | let configManager; 161 | 162 | // Reset mocks before each test for isolation 163 | beforeEach(async () => { 164 | // Clear all mock calls and reset implementations between tests 165 | jest.clearAllMocks(); 166 | // Reset the external mock instances for utils 167 | mockFindProjectRoot.mockReset(); 168 | mockLog.mockReset(); 169 | mockResolveEnvVariable.mockReset(); 170 | mockExistsSync.mockReset(); 171 | mockReadFileSync.mockReset(); 172 | mockWriteFileSync.mockReset(); 173 | 174 | // --- Mock Dependencies BEFORE importing the module under test --- 175 | // Mock the 'utils.js' module using doMock (applied at runtime) 176 | jest.doMock('../../scripts/modules/utils.js', () => ({ 177 | __esModule: true, // Indicate it's an ES module mock 178 | findProjectRoot: mockFindProjectRoot, // Use the mock function instance 179 | log: mockLog, // Use the mock function instance 180 | resolveEnvVariable: mockResolveEnvVariable // Use the mock function instance 181 | })); 182 | 183 | // Dynamically import the module under test AFTER mocking dependencies 184 | configManager = await import('../../scripts/modules/config-manager.js'); 185 | 186 | // --- Default Mock Implementations --- 187 | mockFindProjectRoot.mockReturnValue(MOCK_PROJECT_ROOT); // Default for utils.findProjectRoot 188 | mockExistsSync.mockReturnValue(true); // Assume files exist by default 189 | 190 | // Default readFileSync: Return REAL models content, mocked config, or throw error 191 | mockReadFileSync.mockImplementation((filePath) => { 192 | const baseName = path.basename(filePath); 193 | if (baseName === 'supported-models.json') { 194 | // Return the REAL file content stringified 195 | return REAL_SUPPORTED_MODELS_CONTENT; 196 | } else if (filePath === MOCK_CONFIG_PATH) { 197 | // Still mock the .taskmasterconfig reads 198 | return JSON.stringify(DEFAULT_CONFIG); // Default behavior 199 | } 200 | // Throw for unexpected reads - helps catch errors 201 | throw new Error(`Unexpected fs.readFileSync call in test: ${filePath}`); 202 | }); 203 | 204 | // Default writeFileSync: Do nothing, just allow calls 205 | mockWriteFileSync.mockImplementation(() => {}); 206 | }); 207 | 208 | // --- Validation Functions --- 209 | describe('Validation Functions', () => { 210 | // Tests for validateProvider and validateProviderModelCombination 211 | test('validateProvider should return true for valid providers', () => { 212 | expect(configManager.validateProvider('openai')).toBe(true); 213 | expect(configManager.validateProvider('anthropic')).toBe(true); 214 | expect(configManager.validateProvider('google')).toBe(true); 215 | expect(configManager.validateProvider('perplexity')).toBe(true); 216 | expect(configManager.validateProvider('ollama')).toBe(true); 217 | expect(configManager.validateProvider('openrouter')).toBe(true); 218 | }); 219 | 220 | test('validateProvider should return false for invalid providers', () => { 221 | expect(configManager.validateProvider('invalid-provider')).toBe(false); 222 | expect(configManager.validateProvider('grok')).toBe(false); // Not in mock map 223 | expect(configManager.validateProvider('')).toBe(false); 224 | expect(configManager.validateProvider(null)).toBe(false); 225 | }); 226 | 227 | test('validateProviderModelCombination should validate known good combinations', () => { 228 | // Re-load config to ensure MODEL_MAP is populated from mock (now real data) 229 | configManager.getConfig(MOCK_PROJECT_ROOT, true); 230 | expect( 231 | configManager.validateProviderModelCombination('openai', 'gpt-4o') 232 | ).toBe(true); 233 | expect( 234 | configManager.validateProviderModelCombination( 235 | 'anthropic', 236 | 'claude-3-5-sonnet-20241022' 237 | ) 238 | ).toBe(true); 239 | }); 240 | 241 | test('validateProviderModelCombination should return false for known bad combinations', () => { 242 | // Re-load config to ensure MODEL_MAP is populated from mock (now real data) 243 | configManager.getConfig(MOCK_PROJECT_ROOT, true); 244 | expect( 245 | configManager.validateProviderModelCombination( 246 | 'openai', 247 | 'claude-3-opus-20240229' 248 | ) 249 | ).toBe(false); 250 | }); 251 | 252 | test('validateProviderModelCombination should return true for ollama/openrouter (empty lists in map)', () => { 253 | // Re-load config to ensure MODEL_MAP is populated from mock (now real data) 254 | configManager.getConfig(MOCK_PROJECT_ROOT, true); 255 | expect( 256 | configManager.validateProviderModelCombination('ollama', 'any-model') 257 | ).toBe(false); 258 | expect( 259 | configManager.validateProviderModelCombination( 260 | 'openrouter', 261 | 'any/model' 262 | ) 263 | ).toBe(false); 264 | }); 265 | 266 | test('validateProviderModelCombination should return true for providers not in map', () => { 267 | // Re-load config to ensure MODEL_MAP is populated from mock (now real data) 268 | configManager.getConfig(MOCK_PROJECT_ROOT, true); 269 | // The implementation returns true if the provider isn't in the map 270 | expect( 271 | configManager.validateProviderModelCombination( 272 | 'unknown-provider', 273 | 'some-model' 274 | ) 275 | ).toBe(true); 276 | }); 277 | }); 278 | 279 | // --- getConfig Tests --- 280 | describe('getConfig Tests', () => { 281 | test('should return default config if .taskmasterconfig does not exist', () => { 282 | // Arrange 283 | mockExistsSync.mockReturnValue(false); 284 | // findProjectRoot mock is set in beforeEach 285 | 286 | // Act: Call getConfig with explicit root 287 | const config = configManager.getConfig(MOCK_PROJECT_ROOT, true); // Force reload 288 | 289 | // Assert 290 | expect(config).toEqual(DEFAULT_CONFIG); 291 | expect(mockFindProjectRoot).not.toHaveBeenCalled(); // Explicit root provided 292 | expect(mockExistsSync).toHaveBeenCalledWith(MOCK_CONFIG_PATH); 293 | expect(mockReadFileSync).not.toHaveBeenCalled(); // No read if file doesn't exist 294 | expect(consoleWarnSpy).toHaveBeenCalledWith( 295 | expect.stringContaining('not found at provided project root') 296 | ); 297 | }); 298 | 299 | test.skip('should use findProjectRoot and return defaults if file not found', () => { 300 | // TODO: Fix mock interaction, findProjectRoot isn't being registered as called 301 | // Arrange 302 | mockExistsSync.mockReturnValue(false); 303 | // findProjectRoot mock is set in beforeEach 304 | 305 | // Act: Call getConfig without explicit root 306 | const config = configManager.getConfig(null, true); // Force reload 307 | 308 | // Assert 309 | expect(mockFindProjectRoot).toHaveBeenCalled(); // Should be called now 310 | expect(mockExistsSync).toHaveBeenCalledWith(MOCK_CONFIG_PATH); 311 | expect(config).toEqual(DEFAULT_CONFIG); 312 | expect(mockReadFileSync).not.toHaveBeenCalled(); 313 | expect(consoleWarnSpy).toHaveBeenCalledWith( 314 | expect.stringContaining('not found at derived root') 315 | ); // Adjusted expected warning 316 | }); 317 | 318 | test('should read and merge valid config file with defaults', () => { 319 | // Arrange: Override readFileSync for this test 320 | mockReadFileSync.mockImplementation((filePath) => { 321 | if (filePath === MOCK_CONFIG_PATH) 322 | return JSON.stringify(VALID_CUSTOM_CONFIG); 323 | if (path.basename(filePath) === 'supported-models.json') { 324 | // Provide necessary models for validation within getConfig 325 | return JSON.stringify({ 326 | openai: [{ id: 'gpt-4o' }], 327 | google: [{ id: 'gemini-1.5-pro-latest' }], 328 | perplexity: [{ id: 'sonar-pro' }], 329 | anthropic: [ 330 | { id: 'claude-3-opus-20240229' }, 331 | { id: 'claude-3-5-sonnet' }, 332 | { id: 'claude-3-7-sonnet-20250219' }, 333 | { id: 'claude-3-5-sonnet' } 334 | ], 335 | ollama: [], 336 | openrouter: [] 337 | }); 338 | } 339 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); 340 | }); 341 | mockExistsSync.mockReturnValue(true); 342 | // findProjectRoot mock set in beforeEach 343 | 344 | // Act 345 | const config = configManager.getConfig(MOCK_PROJECT_ROOT, true); // Force reload 346 | 347 | // Assert: Construct expected merged config 348 | const expectedMergedConfig = { 349 | models: { 350 | main: { 351 | ...DEFAULT_CONFIG.models.main, 352 | ...VALID_CUSTOM_CONFIG.models.main 353 | }, 354 | research: { 355 | ...DEFAULT_CONFIG.models.research, 356 | ...VALID_CUSTOM_CONFIG.models.research 357 | }, 358 | fallback: { 359 | ...DEFAULT_CONFIG.models.fallback, 360 | ...VALID_CUSTOM_CONFIG.models.fallback 361 | } 362 | }, 363 | global: { ...DEFAULT_CONFIG.global, ...VALID_CUSTOM_CONFIG.global } 364 | }; 365 | expect(config).toEqual(expectedMergedConfig); 366 | expect(mockExistsSync).toHaveBeenCalledWith(MOCK_CONFIG_PATH); 367 | expect(mockReadFileSync).toHaveBeenCalledWith(MOCK_CONFIG_PATH, 'utf-8'); 368 | }); 369 | 370 | test('should merge defaults for partial config file', () => { 371 | // Arrange 372 | mockReadFileSync.mockImplementation((filePath) => { 373 | if (filePath === MOCK_CONFIG_PATH) 374 | return JSON.stringify(PARTIAL_CONFIG); 375 | if (path.basename(filePath) === 'supported-models.json') { 376 | return JSON.stringify({ 377 | openai: [{ id: 'gpt-4-turbo' }], 378 | perplexity: [{ id: 'sonar-pro' }], 379 | anthropic: [ 380 | { id: 'claude-3-7-sonnet-20250219' }, 381 | { id: 'claude-3-5-sonnet' } 382 | ], 383 | ollama: [], 384 | openrouter: [] 385 | }); 386 | } 387 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); 388 | }); 389 | mockExistsSync.mockReturnValue(true); 390 | // findProjectRoot mock set in beforeEach 391 | 392 | // Act 393 | const config = configManager.getConfig(MOCK_PROJECT_ROOT, true); 394 | 395 | // Assert: Construct expected merged config 396 | const expectedMergedConfig = { 397 | models: { 398 | main: { 399 | ...DEFAULT_CONFIG.models.main, 400 | ...PARTIAL_CONFIG.models.main 401 | }, 402 | research: { ...DEFAULT_CONFIG.models.research }, 403 | fallback: { ...DEFAULT_CONFIG.models.fallback } 404 | }, 405 | global: { ...DEFAULT_CONFIG.global, ...PARTIAL_CONFIG.global } 406 | }; 407 | expect(config).toEqual(expectedMergedConfig); 408 | expect(mockReadFileSync).toHaveBeenCalledWith(MOCK_CONFIG_PATH, 'utf-8'); 409 | }); 410 | 411 | test('should handle JSON parsing error and return defaults', () => { 412 | // Arrange 413 | mockReadFileSync.mockImplementation((filePath) => { 414 | if (filePath === MOCK_CONFIG_PATH) return 'invalid json'; 415 | // Mock models read needed for initial load before parse error 416 | if (path.basename(filePath) === 'supported-models.json') { 417 | return JSON.stringify({ 418 | anthropic: [{ id: 'claude-3-7-sonnet-20250219' }], 419 | perplexity: [{ id: 'sonar-pro' }], 420 | fallback: [{ id: 'claude-3-5-sonnet' }], 421 | ollama: [], 422 | openrouter: [] 423 | }); 424 | } 425 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); 426 | }); 427 | mockExistsSync.mockReturnValue(true); 428 | // findProjectRoot mock set in beforeEach 429 | 430 | // Act 431 | const config = configManager.getConfig(MOCK_PROJECT_ROOT, true); 432 | 433 | // Assert 434 | expect(config).toEqual(DEFAULT_CONFIG); 435 | expect(consoleErrorSpy).toHaveBeenCalledWith( 436 | expect.stringContaining('Error reading or parsing') 437 | ); 438 | }); 439 | 440 | test('should handle file read error and return defaults', () => { 441 | // Arrange 442 | const readError = new Error('Permission denied'); 443 | mockReadFileSync.mockImplementation((filePath) => { 444 | if (filePath === MOCK_CONFIG_PATH) throw readError; 445 | // Mock models read needed for initial load before read error 446 | if (path.basename(filePath) === 'supported-models.json') { 447 | return JSON.stringify({ 448 | anthropic: [{ id: 'claude-3-7-sonnet-20250219' }], 449 | perplexity: [{ id: 'sonar-pro' }], 450 | fallback: [{ id: 'claude-3-5-sonnet' }], 451 | ollama: [], 452 | openrouter: [] 453 | }); 454 | } 455 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); 456 | }); 457 | mockExistsSync.mockReturnValue(true); 458 | // findProjectRoot mock set in beforeEach 459 | 460 | // Act 461 | const config = configManager.getConfig(MOCK_PROJECT_ROOT, true); 462 | 463 | // Assert 464 | expect(config).toEqual(DEFAULT_CONFIG); 465 | expect(consoleErrorSpy).toHaveBeenCalledWith( 466 | expect.stringContaining( 467 | `Permission denied. Using default configuration.` 468 | ) 469 | ); 470 | }); 471 | 472 | test('should validate provider and fallback to default if invalid', () => { 473 | // Arrange 474 | mockReadFileSync.mockImplementation((filePath) => { 475 | if (filePath === MOCK_CONFIG_PATH) 476 | return JSON.stringify(INVALID_PROVIDER_CONFIG); 477 | if (path.basename(filePath) === 'supported-models.json') { 478 | return JSON.stringify({ 479 | perplexity: [{ id: 'llama-3-sonar-large-32k-online' }], 480 | anthropic: [ 481 | { id: 'claude-3-7-sonnet-20250219' }, 482 | { id: 'claude-3-5-sonnet' } 483 | ], 484 | ollama: [], 485 | openrouter: [] 486 | }); 487 | } 488 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); 489 | }); 490 | mockExistsSync.mockReturnValue(true); 491 | // findProjectRoot mock set in beforeEach 492 | 493 | // Act 494 | const config = configManager.getConfig(MOCK_PROJECT_ROOT, true); 495 | 496 | // Assert 497 | expect(consoleWarnSpy).toHaveBeenCalledWith( 498 | expect.stringContaining( 499 | 'Warning: Invalid main provider "invalid-provider"' 500 | ) 501 | ); 502 | const expectedMergedConfig = { 503 | models: { 504 | main: { ...DEFAULT_CONFIG.models.main }, 505 | research: { 506 | ...DEFAULT_CONFIG.models.research, 507 | ...INVALID_PROVIDER_CONFIG.models.research 508 | }, 509 | fallback: { ...DEFAULT_CONFIG.models.fallback } 510 | }, 511 | global: { ...DEFAULT_CONFIG.global, ...INVALID_PROVIDER_CONFIG.global } 512 | }; 513 | expect(config).toEqual(expectedMergedConfig); 514 | }); 515 | }); 516 | 517 | // --- writeConfig Tests --- 518 | describe('writeConfig', () => { 519 | test('should write valid config to file', () => { 520 | // Arrange (Default mocks are sufficient) 521 | // findProjectRoot mock set in beforeEach 522 | mockWriteFileSync.mockImplementation(() => {}); // Ensure it doesn't throw 523 | 524 | // Act 525 | const success = configManager.writeConfig( 526 | VALID_CUSTOM_CONFIG, 527 | MOCK_PROJECT_ROOT 528 | ); 529 | 530 | // Assert 531 | expect(success).toBe(true); 532 | expect(mockWriteFileSync).toHaveBeenCalledWith( 533 | MOCK_CONFIG_PATH, 534 | JSON.stringify(VALID_CUSTOM_CONFIG, null, 2) // writeConfig stringifies 535 | ); 536 | expect(consoleErrorSpy).not.toHaveBeenCalled(); 537 | }); 538 | 539 | test('should return false and log error if write fails', () => { 540 | // Arrange 541 | const mockWriteError = new Error('Disk full'); 542 | mockWriteFileSync.mockImplementation(() => { 543 | throw mockWriteError; 544 | }); 545 | // findProjectRoot mock set in beforeEach 546 | 547 | // Act 548 | const success = configManager.writeConfig( 549 | VALID_CUSTOM_CONFIG, 550 | MOCK_PROJECT_ROOT 551 | ); 552 | 553 | // Assert 554 | expect(success).toBe(false); 555 | expect(mockWriteFileSync).toHaveBeenCalled(); 556 | expect(consoleErrorSpy).toHaveBeenCalledWith( 557 | expect.stringContaining(`Disk full`) 558 | ); 559 | }); 560 | 561 | test.skip('should return false if project root cannot be determined', () => { 562 | // TODO: Fix mock interaction or function logic, returns true unexpectedly in test 563 | // Arrange: Override mock for this specific test 564 | mockFindProjectRoot.mockReturnValue(null); 565 | 566 | // Act: Call without explicit root 567 | const success = configManager.writeConfig(VALID_CUSTOM_CONFIG); 568 | 569 | // Assert 570 | expect(success).toBe(false); // Function should return false if root is null 571 | expect(mockFindProjectRoot).toHaveBeenCalled(); 572 | expect(mockWriteFileSync).not.toHaveBeenCalled(); 573 | expect(consoleErrorSpy).toHaveBeenCalledWith( 574 | expect.stringContaining('Could not determine project root') 575 | ); 576 | }); 577 | }); 578 | 579 | // --- Getter Functions --- 580 | describe('Getter Functions', () => { 581 | test('getMainProvider should return provider from config', () => { 582 | // Arrange: Set up readFileSync to return VALID_CUSTOM_CONFIG 583 | mockReadFileSync.mockImplementation((filePath) => { 584 | if (filePath === MOCK_CONFIG_PATH) 585 | return JSON.stringify(VALID_CUSTOM_CONFIG); 586 | if (path.basename(filePath) === 'supported-models.json') { 587 | return JSON.stringify({ 588 | openai: [{ id: 'gpt-4o' }], 589 | google: [{ id: 'gemini-1.5-pro-latest' }], 590 | anthropic: [ 591 | { id: 'claude-3-opus-20240229' }, 592 | { id: 'claude-3-7-sonnet-20250219' }, 593 | { id: 'claude-3-5-sonnet' } 594 | ], 595 | perplexity: [{ id: 'sonar-pro' }], 596 | ollama: [], 597 | openrouter: [] 598 | }); // Added perplexity 599 | } 600 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); 601 | }); 602 | mockExistsSync.mockReturnValue(true); 603 | // findProjectRoot mock set in beforeEach 604 | 605 | // Act 606 | const provider = configManager.getMainProvider(MOCK_PROJECT_ROOT); 607 | 608 | // Assert 609 | expect(provider).toBe(VALID_CUSTOM_CONFIG.models.main.provider); 610 | }); 611 | 612 | test('getLogLevel should return logLevel from config', () => { 613 | // Arrange: Set up readFileSync to return VALID_CUSTOM_CONFIG 614 | mockReadFileSync.mockImplementation((filePath) => { 615 | if (filePath === MOCK_CONFIG_PATH) 616 | return JSON.stringify(VALID_CUSTOM_CONFIG); 617 | if (path.basename(filePath) === 'supported-models.json') { 618 | // Provide enough mock model data for validation within getConfig 619 | return JSON.stringify({ 620 | openai: [{ id: 'gpt-4o' }], 621 | google: [{ id: 'gemini-1.5-pro-latest' }], 622 | anthropic: [ 623 | { id: 'claude-3-opus-20240229' }, 624 | { id: 'claude-3-7-sonnet-20250219' }, 625 | { id: 'claude-3-5-sonnet' } 626 | ], 627 | perplexity: [{ id: 'sonar-pro' }], 628 | ollama: [], 629 | openrouter: [] 630 | }); 631 | } 632 | throw new Error(`Unexpected fs.readFileSync call: ${filePath}`); 633 | }); 634 | mockExistsSync.mockReturnValue(true); 635 | // findProjectRoot mock set in beforeEach 636 | 637 | // Act 638 | const logLevel = configManager.getLogLevel(MOCK_PROJECT_ROOT); 639 | 640 | // Assert 641 | expect(logLevel).toBe(VALID_CUSTOM_CONFIG.global.logLevel); 642 | }); 643 | 644 | // Add more tests for other getters (getResearchProvider, getProjectName, etc.) 645 | }); 646 | 647 | // --- isConfigFilePresent Tests --- 648 | describe('isConfigFilePresent', () => { 649 | test('should return true if config file exists', () => { 650 | mockExistsSync.mockReturnValue(true); 651 | // findProjectRoot mock set in beforeEach 652 | expect(configManager.isConfigFilePresent(MOCK_PROJECT_ROOT)).toBe(true); 653 | expect(mockExistsSync).toHaveBeenCalledWith(MOCK_CONFIG_PATH); 654 | }); 655 | 656 | test('should return false if config file does not exist', () => { 657 | mockExistsSync.mockReturnValue(false); 658 | // findProjectRoot mock set in beforeEach 659 | expect(configManager.isConfigFilePresent(MOCK_PROJECT_ROOT)).toBe(false); 660 | expect(mockExistsSync).toHaveBeenCalledWith(MOCK_CONFIG_PATH); 661 | }); 662 | 663 | test.skip('should use findProjectRoot if explicitRoot is not provided', () => { 664 | // TODO: Fix mock interaction, findProjectRoot isn't being registered as called 665 | mockExistsSync.mockReturnValue(true); 666 | // findProjectRoot mock set in beforeEach 667 | expect(configManager.isConfigFilePresent()).toBe(true); 668 | expect(mockFindProjectRoot).toHaveBeenCalled(); // Should be called now 669 | }); 670 | }); 671 | 672 | // --- getAllProviders Tests --- 673 | describe('getAllProviders', () => { 674 | test('should return list of providers from supported-models.json', () => { 675 | // Arrange: Ensure config is loaded with real data 676 | configManager.getConfig(null, true); // Force load using the mock that returns real data 677 | 678 | // Act 679 | const providers = configManager.getAllProviders(); 680 | // Assert 681 | // Assert against the actual keys in the REAL loaded data 682 | const expectedProviders = Object.keys(REAL_SUPPORTED_MODELS_DATA); 683 | expect(providers).toEqual(expect.arrayContaining(expectedProviders)); 684 | expect(providers.length).toBe(expectedProviders.length); 685 | }); 686 | }); 687 | 688 | // Add tests for getParametersForRole if needed 689 | 690 | // Note: Tests for setMainModel, setResearchModel were removed as the functions were removed in the implementation. 691 | // If similar setter functions exist, add tests for them following the writeConfig pattern. 692 | 693 | // --- isApiKeySet Tests --- 694 | describe('isApiKeySet', () => { 695 | const mockSession = { env: {} }; // Mock session for MCP context 696 | 697 | // Test cases: [providerName, envVarName, keyValue, expectedResult, testName] 698 | const testCases = [ 699 | // Valid Keys 700 | [ 701 | 'anthropic', 702 | 'ANTHROPIC_API_KEY', 703 | 'sk-valid-key', 704 | true, 705 | 'valid Anthropic key' 706 | ], 707 | [ 708 | 'openai', 709 | 'OPENAI_API_KEY', 710 | 'sk-another-valid-key', 711 | true, 712 | 'valid OpenAI key' 713 | ], 714 | [ 715 | 'perplexity', 716 | 'PERPLEXITY_API_KEY', 717 | 'pplx-valid', 718 | true, 719 | 'valid Perplexity key' 720 | ], 721 | [ 722 | 'google', 723 | 'GOOGLE_API_KEY', 724 | 'google-valid-key', 725 | true, 726 | 'valid Google key' 727 | ], 728 | [ 729 | 'mistral', 730 | 'MISTRAL_API_KEY', 731 | 'mistral-valid-key', 732 | true, 733 | 'valid Mistral key' 734 | ], 735 | [ 736 | 'openrouter', 737 | 'OPENROUTER_API_KEY', 738 | 'or-valid-key', 739 | true, 740 | 'valid OpenRouter key' 741 | ], 742 | ['xai', 'XAI_API_KEY', 'xai-valid-key', true, 'valid XAI key'], 743 | [ 744 | 'azure', 745 | 'AZURE_OPENAI_API_KEY', 746 | 'azure-valid-key', 747 | true, 748 | 'valid Azure key' 749 | ], 750 | 751 | // Ollama (special case - no key needed) 752 | [ 753 | 'ollama', 754 | 'OLLAMA_API_KEY', 755 | undefined, 756 | true, 757 | 'Ollama provider (no key needed)' 758 | ], // OLLAMA_API_KEY might not be in keyMap 759 | 760 | // Invalid / Missing Keys 761 | [ 762 | 'anthropic', 763 | 'ANTHROPIC_API_KEY', 764 | undefined, 765 | false, 766 | 'missing Anthropic key' 767 | ], 768 | ['anthropic', 'ANTHROPIC_API_KEY', null, false, 'null Anthropic key'], 769 | ['openai', 'OPENAI_API_KEY', '', false, 'empty OpenAI key'], 770 | [ 771 | 'perplexity', 772 | 'PERPLEXITY_API_KEY', 773 | ' ', 774 | false, 775 | 'whitespace Perplexity key' 776 | ], 777 | 778 | // Placeholder Keys 779 | [ 780 | 'google', 781 | 'GOOGLE_API_KEY', 782 | 'YOUR_GOOGLE_API_KEY_HERE', 783 | false, 784 | 'placeholder Google key (YOUR_..._HERE)' 785 | ], 786 | [ 787 | 'mistral', 788 | 'MISTRAL_API_KEY', 789 | 'MISTRAL_KEY_HERE', 790 | false, 791 | 'placeholder Mistral key (..._KEY_HERE)' 792 | ], 793 | [ 794 | 'openrouter', 795 | 'OPENROUTER_API_KEY', 796 | 'ENTER_OPENROUTER_KEY_HERE', 797 | false, 798 | 'placeholder OpenRouter key (general ...KEY_HERE)' 799 | ], 800 | 801 | // Unknown provider 802 | ['unknownprovider', 'UNKNOWN_KEY', 'any-key', false, 'unknown provider'] 803 | ]; 804 | 805 | testCases.forEach( 806 | ([providerName, envVarName, keyValue, expectedResult, testName]) => { 807 | test(`should return ${expectedResult} for ${testName} (CLI context)`, () => { 808 | // CLI context (resolveEnvVariable uses process.env or .env via projectRoot) 809 | mockResolveEnvVariable.mockImplementation((key) => { 810 | return key === envVarName ? keyValue : undefined; 811 | }); 812 | expect( 813 | configManager.isApiKeySet(providerName, null, MOCK_PROJECT_ROOT) 814 | ).toBe(expectedResult); 815 | if (providerName !== 'ollama' && providerName !== 'unknownprovider') { 816 | // Ollama and unknown don't try to resolve 817 | expect(mockResolveEnvVariable).toHaveBeenCalledWith( 818 | envVarName, 819 | null, 820 | MOCK_PROJECT_ROOT 821 | ); 822 | } 823 | }); 824 | 825 | test(`should return ${expectedResult} for ${testName} (MCP context)`, () => { 826 | // MCP context (resolveEnvVariable uses session.env) 827 | const mcpSession = { env: { [envVarName]: keyValue } }; 828 | mockResolveEnvVariable.mockImplementation((key, sessionArg) => { 829 | return sessionArg && sessionArg.env 830 | ? sessionArg.env[key] 831 | : undefined; 832 | }); 833 | expect( 834 | configManager.isApiKeySet(providerName, mcpSession, null) 835 | ).toBe(expectedResult); 836 | if (providerName !== 'ollama' && providerName !== 'unknownprovider') { 837 | expect(mockResolveEnvVariable).toHaveBeenCalledWith( 838 | envVarName, 839 | mcpSession, 840 | null 841 | ); 842 | } 843 | }); 844 | } 845 | ); 846 | 847 | test('isApiKeySet should log a warning for an unknown provider', () => { 848 | mockLog.mockClear(); // Clear previous log calls 849 | configManager.isApiKeySet('nonexistentprovider'); 850 | expect(mockLog).toHaveBeenCalledWith( 851 | 'warn', 852 | expect.stringContaining('Unknown provider name: nonexistentprovider') 853 | ); 854 | }); 855 | 856 | test('isApiKeySet should handle provider names case-insensitively for keyMap lookup', () => { 857 | mockResolveEnvVariable.mockReturnValue('a-valid-key'); 858 | expect( 859 | configManager.isApiKeySet('Anthropic', null, MOCK_PROJECT_ROOT) 860 | ).toBe(true); 861 | expect(mockResolveEnvVariable).toHaveBeenCalledWith( 862 | 'ANTHROPIC_API_KEY', 863 | null, 864 | MOCK_PROJECT_ROOT 865 | ); 866 | 867 | mockResolveEnvVariable.mockReturnValue('another-valid-key'); 868 | expect(configManager.isApiKeySet('OPENAI', null, MOCK_PROJECT_ROOT)).toBe( 869 | true 870 | ); 871 | expect(mockResolveEnvVariable).toHaveBeenCalledWith( 872 | 'OPENAI_API_KEY', 873 | null, 874 | MOCK_PROJECT_ROOT 875 | ); 876 | }); 877 | }); 878 | }); 879 | ``` -------------------------------------------------------------------------------- /mcp-server/src/tools/utils.js: -------------------------------------------------------------------------------- ```javascript 1 | /** 2 | * tools/utils.js 3 | * Utility functions for Task Master CLI integration 4 | */ 5 | 6 | import { spawnSync } from 'child_process'; 7 | import path from 'path'; 8 | import fs from 'fs'; 9 | import { contextManager } from '../core/context-manager.js'; // Import the singleton 10 | import { fileURLToPath } from 'url'; 11 | import packageJson from '../../../package.json' with { type: 'json' }; 12 | import { getCurrentTag } from '../../../scripts/modules/utils.js'; 13 | 14 | // Import path utilities to ensure consistent path resolution 15 | import { 16 | lastFoundProjectRoot, 17 | PROJECT_MARKERS 18 | } from '../core/utils/path-utils.js'; 19 | 20 | const __filename = fileURLToPath(import.meta.url); 21 | 22 | // Cache for version info to avoid repeated file reads 23 | let cachedVersionInfo = null; 24 | 25 | /** 26 | * Get version information from package.json 27 | * @returns {Object} Version information 28 | */ 29 | function getVersionInfo() { 30 | // Return cached version if available 31 | if (cachedVersionInfo) { 32 | return cachedVersionInfo; 33 | } 34 | 35 | // Use the imported packageJson directly 36 | cachedVersionInfo = { 37 | version: packageJson.version || 'unknown', 38 | name: packageJson.name || 'task-master-ai' 39 | }; 40 | return cachedVersionInfo; 41 | } 42 | 43 | /** 44 | * Get current tag information for MCP responses 45 | * @param {string} projectRoot - The project root directory 46 | * @param {Object} log - Logger object 47 | * @returns {Object} Tag information object 48 | */ 49 | function getTagInfo(projectRoot, log) { 50 | try { 51 | if (!projectRoot) { 52 | log.warn('No project root provided for tag information'); 53 | return { currentTag: 'master', availableTags: ['master'] }; 54 | } 55 | 56 | const currentTag = getCurrentTag(projectRoot); 57 | 58 | // Read available tags from tasks.json 59 | let availableTags = ['master']; // Default fallback 60 | try { 61 | const tasksJsonPath = path.join( 62 | projectRoot, 63 | '.taskmaster', 64 | 'tasks', 65 | 'tasks.json' 66 | ); 67 | if (fs.existsSync(tasksJsonPath)) { 68 | const tasksData = JSON.parse(fs.readFileSync(tasksJsonPath, 'utf-8')); 69 | 70 | // If it's the new tagged format, extract tag keys 71 | if ( 72 | tasksData && 73 | typeof tasksData === 'object' && 74 | !Array.isArray(tasksData.tasks) 75 | ) { 76 | const tagKeys = Object.keys(tasksData).filter( 77 | (key) => 78 | tasksData[key] && 79 | typeof tasksData[key] === 'object' && 80 | Array.isArray(tasksData[key].tasks) 81 | ); 82 | if (tagKeys.length > 0) { 83 | availableTags = tagKeys; 84 | } 85 | } 86 | } 87 | } catch (tagError) { 88 | log.debug(`Could not read available tags: ${tagError.message}`); 89 | } 90 | 91 | return { 92 | currentTag: currentTag || 'master', 93 | availableTags: availableTags 94 | }; 95 | } catch (error) { 96 | log.warn(`Error getting tag information: ${error.message}`); 97 | return { currentTag: 'master', availableTags: ['master'] }; 98 | } 99 | } 100 | 101 | /** 102 | * Get normalized project root path 103 | * @param {string|undefined} projectRootRaw - Raw project root from arguments 104 | * @param {Object} log - Logger object 105 | * @returns {string} - Normalized absolute path to project root 106 | */ 107 | function getProjectRoot(projectRootRaw, log) { 108 | // PRECEDENCE ORDER: 109 | // 1. Environment variable override (TASK_MASTER_PROJECT_ROOT) 110 | // 2. Explicitly provided projectRoot in args 111 | // 3. Previously found/cached project root 112 | // 4. Current directory if it has project markers 113 | // 5. Current directory with warning 114 | 115 | // 1. Check for environment variable override 116 | if (process.env.TASK_MASTER_PROJECT_ROOT) { 117 | const envRoot = process.env.TASK_MASTER_PROJECT_ROOT; 118 | const absolutePath = path.isAbsolute(envRoot) 119 | ? envRoot 120 | : path.resolve(process.cwd(), envRoot); 121 | log.info( 122 | `Using project root from TASK_MASTER_PROJECT_ROOT environment variable: ${absolutePath}` 123 | ); 124 | return absolutePath; 125 | } 126 | 127 | // 2. If project root is explicitly provided, use it 128 | if (projectRootRaw) { 129 | const absolutePath = path.isAbsolute(projectRootRaw) 130 | ? projectRootRaw 131 | : path.resolve(process.cwd(), projectRootRaw); 132 | 133 | log.info(`Using explicitly provided project root: ${absolutePath}`); 134 | return absolutePath; 135 | } 136 | 137 | // 3. If we have a last found project root from a tasks.json search, use that for consistency 138 | if (lastFoundProjectRoot) { 139 | log.info( 140 | `Using last known project root where tasks.json was found: ${lastFoundProjectRoot}` 141 | ); 142 | return lastFoundProjectRoot; 143 | } 144 | 145 | // 4. Check if the current directory has any indicators of being a task-master project 146 | const currentDir = process.cwd(); 147 | if ( 148 | PROJECT_MARKERS.some((marker) => { 149 | const markerPath = path.join(currentDir, marker); 150 | return fs.existsSync(markerPath); 151 | }) 152 | ) { 153 | log.info( 154 | `Using current directory as project root (found project markers): ${currentDir}` 155 | ); 156 | return currentDir; 157 | } 158 | 159 | // 5. Default to current working directory but warn the user 160 | log.warn( 161 | `No task-master project detected in current directory. Using ${currentDir} as project root.` 162 | ); 163 | log.warn( 164 | 'Consider using --project-root to specify the correct project location or set TASK_MASTER_PROJECT_ROOT environment variable.' 165 | ); 166 | return currentDir; 167 | } 168 | 169 | /** 170 | * Extracts and normalizes the project root path from the MCP session object. 171 | * @param {Object} session - The MCP session object. 172 | * @param {Object} log - The MCP logger object. 173 | * @returns {string|null} - The normalized absolute project root path or null if not found/invalid. 174 | */ 175 | function getProjectRootFromSession(session, log) { 176 | try { 177 | // Add detailed logging of session structure 178 | log.info( 179 | `Session object: ${JSON.stringify({ 180 | hasSession: !!session, 181 | hasRoots: !!session?.roots, 182 | rootsType: typeof session?.roots, 183 | isRootsArray: Array.isArray(session?.roots), 184 | rootsLength: session?.roots?.length, 185 | firstRoot: session?.roots?.[0], 186 | hasRootsRoots: !!session?.roots?.roots, 187 | rootsRootsType: typeof session?.roots?.roots, 188 | isRootsRootsArray: Array.isArray(session?.roots?.roots), 189 | rootsRootsLength: session?.roots?.roots?.length, 190 | firstRootsRoot: session?.roots?.roots?.[0] 191 | })}` 192 | ); 193 | 194 | let rawRootPath = null; 195 | let decodedPath = null; 196 | let finalPath = null; 197 | 198 | // Check primary location 199 | if (session?.roots?.[0]?.uri) { 200 | rawRootPath = session.roots[0].uri; 201 | log.info(`Found raw root URI in session.roots[0].uri: ${rawRootPath}`); 202 | } 203 | // Check alternate location 204 | else if (session?.roots?.roots?.[0]?.uri) { 205 | rawRootPath = session.roots.roots[0].uri; 206 | log.info( 207 | `Found raw root URI in session.roots.roots[0].uri: ${rawRootPath}` 208 | ); 209 | } 210 | 211 | if (rawRootPath) { 212 | // Decode URI and strip file:// protocol 213 | decodedPath = rawRootPath.startsWith('file://') 214 | ? decodeURIComponent(rawRootPath.slice(7)) 215 | : rawRootPath; // Assume non-file URI is already decoded? Or decode anyway? Let's decode. 216 | if (!rawRootPath.startsWith('file://')) { 217 | decodedPath = decodeURIComponent(rawRootPath); // Decode even if no file:// 218 | } 219 | 220 | // Handle potential Windows drive prefix after stripping protocol (e.g., /C:/...) 221 | if ( 222 | decodedPath.startsWith('/') && 223 | /[A-Za-z]:/.test(decodedPath.substring(1, 3)) 224 | ) { 225 | decodedPath = decodedPath.substring(1); // Remove leading slash if it's like /C:/... 226 | } 227 | 228 | log.info(`Decoded path: ${decodedPath}`); 229 | 230 | // Normalize slashes and resolve 231 | const normalizedSlashes = decodedPath.replace(/\\/g, '/'); 232 | finalPath = path.resolve(normalizedSlashes); // Resolve to absolute path for current OS 233 | 234 | log.info(`Normalized and resolved session path: ${finalPath}`); 235 | return finalPath; 236 | } 237 | 238 | // Fallback Logic (remains the same) 239 | log.warn('No project root URI found in session. Attempting fallbacks...'); 240 | const cwd = process.cwd(); 241 | 242 | // Fallback 1: Use server path deduction (Cursor IDE) 243 | const serverPath = process.argv[1]; 244 | if (serverPath && serverPath.includes('mcp-server')) { 245 | const mcpServerIndex = serverPath.indexOf('mcp-server'); 246 | if (mcpServerIndex !== -1) { 247 | const projectRoot = path.dirname( 248 | serverPath.substring(0, mcpServerIndex) 249 | ); // Go up one level 250 | 251 | if ( 252 | fs.existsSync(path.join(projectRoot, '.cursor')) || 253 | fs.existsSync(path.join(projectRoot, 'mcp-server')) || 254 | fs.existsSync(path.join(projectRoot, 'package.json')) 255 | ) { 256 | log.info( 257 | `Using project root derived from server path: ${projectRoot}` 258 | ); 259 | return projectRoot; // Already absolute 260 | } 261 | } 262 | } 263 | 264 | // Fallback 2: Use CWD 265 | log.info(`Using current working directory as ultimate fallback: ${cwd}`); 266 | return cwd; // Already absolute 267 | } catch (e) { 268 | log.error(`Error in getProjectRootFromSession: ${e.message}`); 269 | // Attempt final fallback to CWD on error 270 | const cwd = process.cwd(); 271 | log.warn( 272 | `Returning CWD (${cwd}) due to error during session root processing.` 273 | ); 274 | return cwd; 275 | } 276 | } 277 | 278 | /** 279 | * Handle API result with standardized error handling and response formatting 280 | * @param {Object} result - Result object from API call with success, data, and error properties 281 | * @param {Object} log - Logger object 282 | * @param {string} errorPrefix - Prefix for error messages 283 | * @param {Function} processFunction - Optional function to process successful result data 284 | * @param {string} [projectRoot] - Optional project root for tag information 285 | * @returns {Object} - Standardized MCP response object 286 | */ 287 | async function handleApiResult( 288 | result, 289 | log, 290 | errorPrefix = 'API error', 291 | processFunction = processMCPResponseData, 292 | projectRoot = null 293 | ) { 294 | // Get version info for every response 295 | const versionInfo = getVersionInfo(); 296 | 297 | // Get tag info if project root is provided 298 | const tagInfo = projectRoot ? getTagInfo(projectRoot, log) : null; 299 | 300 | if (!result.success) { 301 | const errorMsg = result.error?.message || `Unknown ${errorPrefix}`; 302 | log.error(`${errorPrefix}: ${errorMsg}`); 303 | return createErrorResponse(errorMsg, versionInfo, tagInfo); 304 | } 305 | 306 | // Process the result data if needed 307 | const processedData = processFunction 308 | ? processFunction(result.data) 309 | : result.data; 310 | 311 | log.info('Successfully completed operation'); 312 | 313 | // Create the response payload including version info and tag info 314 | const responsePayload = { 315 | data: processedData, 316 | version: versionInfo 317 | }; 318 | 319 | // Add tag information if available 320 | if (tagInfo) { 321 | responsePayload.tag = tagInfo; 322 | } 323 | 324 | return createContentResponse(responsePayload); 325 | } 326 | 327 | /** 328 | * Executes a task-master CLI command synchronously. 329 | * @param {string} command - The command to execute (e.g., 'add-task') 330 | * @param {Object} log - Logger instance 331 | * @param {Array} args - Arguments for the command 332 | * @param {string|undefined} projectRootRaw - Optional raw project root path (will be normalized internally) 333 | * @param {Object|null} customEnv - Optional object containing environment variables to pass to the child process 334 | * @returns {Object} - The result of the command execution 335 | */ 336 | function executeTaskMasterCommand( 337 | command, 338 | log, 339 | args = [], 340 | projectRootRaw = null, 341 | customEnv = null // Changed from session to customEnv 342 | ) { 343 | try { 344 | // Normalize project root internally using the getProjectRoot utility 345 | const cwd = getProjectRoot(projectRootRaw, log); 346 | 347 | log.info( 348 | `Executing task-master ${command} with args: ${JSON.stringify( 349 | args 350 | )} in directory: ${cwd}` 351 | ); 352 | 353 | // Prepare full arguments array 354 | const fullArgs = [command, ...args]; 355 | 356 | // Common options for spawn 357 | const spawnOptions = { 358 | encoding: 'utf8', 359 | cwd: cwd, 360 | // Merge process.env with customEnv, giving precedence to customEnv 361 | env: { ...process.env, ...(customEnv || {}) } 362 | }; 363 | 364 | // Log the environment being passed (optional, for debugging) 365 | // log.info(`Spawn options env: ${JSON.stringify(spawnOptions.env)}`); 366 | 367 | // Execute the command using the global task-master CLI or local script 368 | // Try the global CLI first 369 | let result = spawnSync('task-master', fullArgs, spawnOptions); 370 | 371 | // If global CLI is not available, try fallback to the local script 372 | if (result.error && result.error.code === 'ENOENT') { 373 | log.info('Global task-master not found, falling back to local script'); 374 | // Pass the same spawnOptions (including env) to the fallback 375 | result = spawnSync('node', ['scripts/dev.js', ...fullArgs], spawnOptions); 376 | } 377 | 378 | if (result.error) { 379 | throw new Error(`Command execution error: ${result.error.message}`); 380 | } 381 | 382 | if (result.status !== 0) { 383 | // Improve error handling by combining stderr and stdout if stderr is empty 384 | const errorOutput = result.stderr 385 | ? result.stderr.trim() 386 | : result.stdout 387 | ? result.stdout.trim() 388 | : 'Unknown error'; 389 | throw new Error( 390 | `Command failed with exit code ${result.status}: ${errorOutput}` 391 | ); 392 | } 393 | 394 | return { 395 | success: true, 396 | stdout: result.stdout, 397 | stderr: result.stderr 398 | }; 399 | } catch (error) { 400 | log.error(`Error executing task-master command: ${error.message}`); 401 | return { 402 | success: false, 403 | error: error.message 404 | }; 405 | } 406 | } 407 | 408 | /** 409 | * Checks cache for a result using the provided key. If not found, executes the action function, 410 | * caches the result upon success, and returns the result. 411 | * 412 | * @param {Object} options - Configuration options. 413 | * @param {string} options.cacheKey - The unique key for caching this operation's result. 414 | * @param {Function} options.actionFn - The async function to execute if the cache misses. 415 | * Should return an object like { success: boolean, data?: any, error?: { code: string, message: string } }. 416 | * @param {Object} options.log - The logger instance. 417 | * @returns {Promise<Object>} - An object containing the result. 418 | * Format: { success: boolean, data?: any, error?: { code: string, message: string } } 419 | */ 420 | async function getCachedOrExecute({ cacheKey, actionFn, log }) { 421 | // Check cache first 422 | const cachedResult = contextManager.getCachedData(cacheKey); 423 | 424 | if (cachedResult !== undefined) { 425 | log.info(`Cache hit for key: ${cacheKey}`); 426 | return cachedResult; 427 | } 428 | 429 | log.info(`Cache miss for key: ${cacheKey}. Executing action function.`); 430 | 431 | // Execute the action function if cache missed 432 | const result = await actionFn(); 433 | 434 | // If the action was successful, cache the result 435 | if (result.success && result.data !== undefined) { 436 | log.info(`Action successful. Caching result for key: ${cacheKey}`); 437 | contextManager.setCachedData(cacheKey, result); 438 | } else if (!result.success) { 439 | log.warn( 440 | `Action failed for cache key ${cacheKey}. Result not cached. Error: ${result.error?.message}` 441 | ); 442 | } else { 443 | log.warn( 444 | `Action for cache key ${cacheKey} succeeded but returned no data. Result not cached.` 445 | ); 446 | } 447 | 448 | return result; 449 | } 450 | 451 | /** 452 | * Recursively removes specified fields from task objects, whether single or in an array. 453 | * Handles common data structures returned by task commands. 454 | * @param {Object|Array} taskOrData - A single task object or a data object containing a 'tasks' array. 455 | * @param {string[]} fieldsToRemove - An array of field names to remove. 456 | * @returns {Object|Array} - The processed data with specified fields removed. 457 | */ 458 | function processMCPResponseData( 459 | taskOrData, 460 | fieldsToRemove = ['details', 'testStrategy'] 461 | ) { 462 | if (!taskOrData) { 463 | return taskOrData; 464 | } 465 | 466 | // Helper function to process a single task object 467 | const processSingleTask = (task) => { 468 | if (typeof task !== 'object' || task === null) { 469 | return task; 470 | } 471 | 472 | const processedTask = { ...task }; 473 | 474 | // Remove specified fields from the task 475 | fieldsToRemove.forEach((field) => { 476 | delete processedTask[field]; 477 | }); 478 | 479 | // Recursively process subtasks if they exist and are an array 480 | if (processedTask.subtasks && Array.isArray(processedTask.subtasks)) { 481 | // Use processArrayOfTasks to handle the subtasks array 482 | processedTask.subtasks = processArrayOfTasks(processedTask.subtasks); 483 | } 484 | 485 | return processedTask; 486 | }; 487 | 488 | // Helper function to process an array of tasks 489 | const processArrayOfTasks = (tasks) => { 490 | return tasks.map(processSingleTask); 491 | }; 492 | 493 | // Check if the input is a data structure containing a 'tasks' array (like from listTasks) 494 | if ( 495 | typeof taskOrData === 'object' && 496 | taskOrData !== null && 497 | Array.isArray(taskOrData.tasks) 498 | ) { 499 | return { 500 | ...taskOrData, // Keep other potential fields like 'stats', 'filter' 501 | tasks: processArrayOfTasks(taskOrData.tasks) 502 | }; 503 | } 504 | // Check if the input is likely a single task object (add more checks if needed) 505 | else if ( 506 | typeof taskOrData === 'object' && 507 | taskOrData !== null && 508 | 'id' in taskOrData && 509 | 'title' in taskOrData 510 | ) { 511 | return processSingleTask(taskOrData); 512 | } 513 | // Check if the input is an array of tasks directly (less common but possible) 514 | else if (Array.isArray(taskOrData)) { 515 | return processArrayOfTasks(taskOrData); 516 | } 517 | 518 | // If it doesn't match known task structures, return it as is 519 | return taskOrData; 520 | } 521 | 522 | /** 523 | * Creates standard content response for tools 524 | * @param {string|Object} content - Content to include in response 525 | * @returns {Object} - Content response object in FastMCP format 526 | */ 527 | function createContentResponse(content) { 528 | // FastMCP requires text type, so we format objects as JSON strings 529 | return { 530 | content: [ 531 | { 532 | type: 'text', 533 | text: 534 | typeof content === 'object' 535 | ? // Format JSON nicely with indentation 536 | JSON.stringify(content, null, 2) 537 | : // Keep other content types as-is 538 | String(content) 539 | } 540 | ] 541 | }; 542 | } 543 | 544 | /** 545 | * Creates error response for tools 546 | * @param {string} errorMessage - Error message to include in response 547 | * @param {Object} [versionInfo] - Optional version information object 548 | * @param {Object} [tagInfo] - Optional tag information object 549 | * @returns {Object} - Error content response object in FastMCP format 550 | */ 551 | function createErrorResponse(errorMessage, versionInfo, tagInfo) { 552 | // Provide fallback version info if not provided 553 | if (!versionInfo) { 554 | versionInfo = getVersionInfo(); 555 | } 556 | 557 | let responseText = `Error: ${errorMessage} 558 | Version: ${versionInfo.version} 559 | Name: ${versionInfo.name}`; 560 | 561 | // Add tag information if available 562 | if (tagInfo) { 563 | responseText += ` 564 | Current Tag: ${tagInfo.currentTag}`; 565 | } 566 | 567 | return { 568 | content: [ 569 | { 570 | type: 'text', 571 | text: responseText 572 | } 573 | ], 574 | isError: true 575 | }; 576 | } 577 | 578 | /** 579 | * Creates a logger wrapper object compatible with core function expectations. 580 | * Adapts the MCP logger to the { info, warn, error, debug, success } structure. 581 | * @param {Object} log - The MCP logger instance. 582 | * @returns {Object} - The logger wrapper object. 583 | */ 584 | function createLogWrapper(log) { 585 | return { 586 | info: (message, ...args) => log.info(message, ...args), 587 | warn: (message, ...args) => log.warn(message, ...args), 588 | error: (message, ...args) => log.error(message, ...args), 589 | // Handle optional debug method 590 | debug: (message, ...args) => 591 | log.debug ? log.debug(message, ...args) : null, 592 | // Map success to info as a common fallback 593 | success: (message, ...args) => log.info(message, ...args) 594 | }; 595 | } 596 | 597 | /** 598 | * Resolves and normalizes a project root path from various formats. 599 | * Handles URI encoding, Windows paths, and file protocols. 600 | * @param {string | undefined | null} rawPath - The raw project root path. 601 | * @param {object} [log] - Optional logger object. 602 | * @returns {string | null} Normalized absolute path or null if input is invalid/empty. 603 | */ 604 | function normalizeProjectRoot(rawPath, log) { 605 | if (!rawPath) return null; 606 | try { 607 | let pathString = Array.isArray(rawPath) ? rawPath[0] : String(rawPath); 608 | if (!pathString) return null; 609 | 610 | // 1. Decode URI Encoding 611 | // Use try-catch for decoding as malformed URIs can throw 612 | try { 613 | pathString = decodeURIComponent(pathString); 614 | } catch (decodeError) { 615 | if (log) 616 | log.warn( 617 | `Could not decode URI component for path "${rawPath}": ${decodeError.message}. Proceeding with raw string.` 618 | ); 619 | // Proceed with the original string if decoding fails 620 | pathString = Array.isArray(rawPath) ? rawPath[0] : String(rawPath); 621 | } 622 | 623 | // 2. Strip file:// prefix (handle 2 or 3 slashes) 624 | if (pathString.startsWith('file:///')) { 625 | pathString = pathString.slice(7); // Slice 7 for file:///, may leave leading / on Windows 626 | } else if (pathString.startsWith('file://')) { 627 | pathString = pathString.slice(7); // Slice 7 for file:// 628 | } 629 | 630 | // 3. Handle potential Windows leading slash after stripping prefix (e.g., /C:/...) 631 | // This checks if it starts with / followed by a drive letter C: D: etc. 632 | if ( 633 | pathString.startsWith('/') && 634 | /[A-Za-z]:/.test(pathString.substring(1, 3)) 635 | ) { 636 | pathString = pathString.substring(1); // Remove the leading slash 637 | } 638 | 639 | // 4. Normalize backslashes to forward slashes 640 | pathString = pathString.replace(/\\/g, '/'); 641 | 642 | // 5. Resolve to absolute path using server's OS convention 643 | const resolvedPath = path.resolve(pathString); 644 | return resolvedPath; 645 | } catch (error) { 646 | if (log) { 647 | log.error( 648 | `Error normalizing project root path "${rawPath}": ${error.message}` 649 | ); 650 | } 651 | return null; // Return null on error 652 | } 653 | } 654 | 655 | /** 656 | * Extracts the raw project root path from the session (without normalization). 657 | * Used as a fallback within the HOF. 658 | * @param {Object} session - The MCP session object. 659 | * @param {Object} log - The MCP logger object. 660 | * @returns {string|null} The raw path string or null. 661 | */ 662 | function getRawProjectRootFromSession(session, log) { 663 | try { 664 | // Check primary location 665 | if (session?.roots?.[0]?.uri) { 666 | return session.roots[0].uri; 667 | } 668 | // Check alternate location 669 | else if (session?.roots?.roots?.[0]?.uri) { 670 | return session.roots.roots[0].uri; 671 | } 672 | return null; // Not found in expected session locations 673 | } catch (e) { 674 | log.error(`Error accessing session roots: ${e.message}`); 675 | return null; 676 | } 677 | } 678 | 679 | /** 680 | * Higher-order function to wrap MCP tool execute methods. 681 | * Ensures args.projectRoot is present and normalized before execution. 682 | * Uses TASK_MASTER_PROJECT_ROOT environment variable with proper precedence. 683 | * @param {Function} executeFn - The original async execute(args, context) function. 684 | * @returns {Function} The wrapped async execute function. 685 | */ 686 | function withNormalizedProjectRoot(executeFn) { 687 | return async (args, context) => { 688 | const { log, session } = context; 689 | let normalizedRoot = null; 690 | let rootSource = 'unknown'; 691 | 692 | try { 693 | // PRECEDENCE ORDER: 694 | // 1. TASK_MASTER_PROJECT_ROOT environment variable (from process.env or session) 695 | // 2. args.projectRoot (explicitly provided) 696 | // 3. Session-based project root resolution 697 | // 4. Current directory fallback 698 | 699 | // 1. Check for TASK_MASTER_PROJECT_ROOT environment variable first 700 | if (process.env.TASK_MASTER_PROJECT_ROOT) { 701 | const envRoot = process.env.TASK_MASTER_PROJECT_ROOT; 702 | normalizedRoot = path.isAbsolute(envRoot) 703 | ? envRoot 704 | : path.resolve(process.cwd(), envRoot); 705 | rootSource = 'TASK_MASTER_PROJECT_ROOT environment variable'; 706 | log.info(`Using project root from ${rootSource}: ${normalizedRoot}`); 707 | } 708 | // Also check session environment variables for TASK_MASTER_PROJECT_ROOT 709 | else if (session?.env?.TASK_MASTER_PROJECT_ROOT) { 710 | const envRoot = session.env.TASK_MASTER_PROJECT_ROOT; 711 | normalizedRoot = path.isAbsolute(envRoot) 712 | ? envRoot 713 | : path.resolve(process.cwd(), envRoot); 714 | rootSource = 'TASK_MASTER_PROJECT_ROOT session environment variable'; 715 | log.info(`Using project root from ${rootSource}: ${normalizedRoot}`); 716 | } 717 | // 2. If no environment variable, try args.projectRoot 718 | else if (args.projectRoot) { 719 | normalizedRoot = normalizeProjectRoot(args.projectRoot, log); 720 | rootSource = 'args.projectRoot'; 721 | log.info(`Using project root from ${rootSource}: ${normalizedRoot}`); 722 | } 723 | // 3. If no args.projectRoot, try session-based resolution 724 | else { 725 | const sessionRoot = getProjectRootFromSession(session, log); 726 | if (sessionRoot) { 727 | normalizedRoot = sessionRoot; // getProjectRootFromSession already normalizes 728 | rootSource = 'session'; 729 | log.info(`Using project root from ${rootSource}: ${normalizedRoot}`); 730 | } 731 | } 732 | 733 | if (!normalizedRoot) { 734 | log.error( 735 | 'Could not determine project root from environment, args, or session.' 736 | ); 737 | return createErrorResponse( 738 | 'Could not determine project root. Please provide projectRoot argument or ensure TASK_MASTER_PROJECT_ROOT environment variable is set.' 739 | ); 740 | } 741 | 742 | // Inject the normalized root back into args 743 | const updatedArgs = { ...args, projectRoot: normalizedRoot }; 744 | 745 | // Execute the original function with normalized root in args 746 | return await executeFn(updatedArgs, context); 747 | } catch (error) { 748 | log.error( 749 | `Error within withNormalizedProjectRoot HOF (Normalized Root: ${normalizedRoot}): ${error.message}` 750 | ); 751 | // Add stack trace if available and debug enabled 752 | if (error.stack && log.debug) { 753 | log.debug(error.stack); 754 | } 755 | // Return a generic error or re-throw depending on desired behavior 756 | return createErrorResponse(`Operation failed: ${error.message}`); 757 | } 758 | }; 759 | } 760 | 761 | /** 762 | * Checks progress reporting capability and returns the validated function or undefined. 763 | * 764 | * STANDARD PATTERN for AI-powered, long-running operations (parse-prd, expand-task, expand-all, analyze): 765 | * 766 | * This helper should be used as the first step in any MCP tool that performs long-running 767 | * AI operations. It validates the availability of progress reporting and provides consistent 768 | * logging about the capability status. 769 | * 770 | * Operations that should use this pattern: 771 | * - parse-prd: Parsing PRD documents with AI 772 | * - expand-task: Expanding tasks into subtasks 773 | * - expand-all: Expanding all tasks in batch 774 | * - analyze-complexity: Analyzing task complexity 775 | * - update-task: Updating tasks with AI assistance 776 | * - add-task: Creating new tasks with AI 777 | * - Any operation that makes AI service calls 778 | * 779 | * @example Basic usage in a tool's execute function: 780 | * ```javascript 781 | * import { checkProgressCapability } from './utils.js'; 782 | * 783 | * async execute(args, context) { 784 | * const { log, reportProgress, session } = context; 785 | * 786 | * // Always validate progress capability first 787 | * const progressCapability = checkProgressCapability(reportProgress, log); 788 | * 789 | * // Pass to direct function - it handles undefined gracefully 790 | * const result = await expandTask(taskId, numSubtasks, { 791 | * session, 792 | * reportProgress: progressCapability, 793 | * mcpLog: log 794 | * }); 795 | * } 796 | * ``` 797 | * 798 | * @example With progress reporting available: 799 | * ```javascript 800 | * // When reportProgress is available, users see real-time updates: 801 | * // "Starting PRD analysis (Input: 5432 tokens)..." 802 | * // "Task 1/10 - Implement user authentication" 803 | * // "Task 2/10 - Create database schema" 804 | * // "Task Generation Completed | Tokens: 5432/1234" 805 | * ``` 806 | * 807 | * @example Without progress reporting (graceful degradation): 808 | * ```javascript 809 | * // When reportProgress is not available: 810 | * // - Operation runs normally without progress updates 811 | * // - Debug log: "reportProgress not available - operation will run without progress updates" 812 | * // - User gets final result after completion 813 | * ``` 814 | * 815 | * @param {Function|undefined} reportProgress - The reportProgress function from MCP context. 816 | * Expected signature: async (progress: {progress: number, total: number, message: string}) => void 817 | * @param {Object} log - Logger instance with debug, info, warn, error methods 818 | * @returns {Function|undefined} The validated reportProgress function or undefined if not available 819 | */ 820 | function checkProgressCapability(reportProgress, log) { 821 | // Validate that reportProgress is available for long-running operations 822 | if (typeof reportProgress !== 'function') { 823 | log.debug( 824 | 'reportProgress not available - operation will run without progress updates' 825 | ); 826 | return undefined; 827 | } 828 | 829 | return reportProgress; 830 | } 831 | 832 | // Ensure all functions are exported 833 | export { 834 | getProjectRoot, 835 | getProjectRootFromSession, 836 | getTagInfo, 837 | handleApiResult, 838 | executeTaskMasterCommand, 839 | getCachedOrExecute, 840 | processMCPResponseData, 841 | createContentResponse, 842 | createErrorResponse, 843 | createLogWrapper, 844 | normalizeProjectRoot, 845 | getRawProjectRootFromSession, 846 | withNormalizedProjectRoot, 847 | checkProgressCapability 848 | }; 849 | ``` -------------------------------------------------------------------------------- /tests/unit/ai-services-unified.test.js: -------------------------------------------------------------------------------- ```javascript 1 | import { jest } from '@jest/globals'; 2 | 3 | // Mock config-manager 4 | const mockGetMainProvider = jest.fn(); 5 | const mockGetMainModelId = jest.fn(); 6 | const mockGetResearchProvider = jest.fn(); 7 | const mockGetResearchModelId = jest.fn(); 8 | const mockGetFallbackProvider = jest.fn(); 9 | const mockGetFallbackModelId = jest.fn(); 10 | const mockGetParametersForRole = jest.fn(); 11 | const mockGetResponseLanguage = jest.fn(); 12 | const mockGetUserId = jest.fn(); 13 | const mockGetDebugFlag = jest.fn(); 14 | const mockIsApiKeySet = jest.fn(); 15 | 16 | // --- Mock MODEL_MAP Data --- 17 | // Provide a simplified structure sufficient for cost calculation tests 18 | const mockModelMap = { 19 | anthropic: [ 20 | { 21 | id: 'test-main-model', 22 | cost_per_1m_tokens: { input: 3, output: 15, currency: 'USD' } 23 | }, 24 | { 25 | id: 'test-fallback-model', 26 | cost_per_1m_tokens: { input: 3, output: 15, currency: 'USD' } 27 | } 28 | ], 29 | perplexity: [ 30 | { 31 | id: 'test-research-model', 32 | cost_per_1m_tokens: { input: 1, output: 1, currency: 'USD' } 33 | } 34 | ], 35 | openai: [ 36 | { 37 | id: 'test-openai-model', 38 | cost_per_1m_tokens: { input: 2, output: 6, currency: 'USD' } 39 | } 40 | ] 41 | // Add other providers/models if needed for specific tests 42 | }; 43 | const mockGetBaseUrlForRole = jest.fn(); 44 | const mockGetAllProviders = jest.fn(); 45 | const mockGetOllamaBaseURL = jest.fn(); 46 | const mockGetAzureBaseURL = jest.fn(); 47 | const mockGetBedrockBaseURL = jest.fn(); 48 | const mockGetVertexProjectId = jest.fn(); 49 | const mockGetVertexLocation = jest.fn(); 50 | const mockGetAvailableModels = jest.fn(); 51 | const mockValidateProvider = jest.fn(); 52 | const mockValidateProviderModelCombination = jest.fn(); 53 | const mockGetConfig = jest.fn(); 54 | const mockWriteConfig = jest.fn(); 55 | const mockIsConfigFilePresent = jest.fn(); 56 | const mockGetMcpApiKeyStatus = jest.fn(); 57 | const mockGetMainMaxTokens = jest.fn(); 58 | const mockGetMainTemperature = jest.fn(); 59 | const mockGetResearchMaxTokens = jest.fn(); 60 | const mockGetResearchTemperature = jest.fn(); 61 | const mockGetFallbackMaxTokens = jest.fn(); 62 | const mockGetFallbackTemperature = jest.fn(); 63 | const mockGetLogLevel = jest.fn(); 64 | const mockGetDefaultNumTasks = jest.fn(); 65 | const mockGetDefaultSubtasks = jest.fn(); 66 | const mockGetDefaultPriority = jest.fn(); 67 | const mockGetProjectName = jest.fn(); 68 | 69 | jest.unstable_mockModule('../../scripts/modules/config-manager.js', () => ({ 70 | // Core config access 71 | getConfig: mockGetConfig, 72 | writeConfig: mockWriteConfig, 73 | isConfigFilePresent: mockIsConfigFilePresent, 74 | ConfigurationError: class ConfigurationError extends Error { 75 | constructor(message) { 76 | super(message); 77 | this.name = 'ConfigurationError'; 78 | } 79 | }, 80 | 81 | // Validation 82 | validateProvider: mockValidateProvider, 83 | validateProviderModelCombination: mockValidateProviderModelCombination, 84 | VALID_PROVIDERS: ['anthropic', 'perplexity', 'openai', 'google'], 85 | MODEL_MAP: mockModelMap, 86 | getAvailableModels: mockGetAvailableModels, 87 | 88 | // Role-specific getters 89 | getMainProvider: mockGetMainProvider, 90 | getMainModelId: mockGetMainModelId, 91 | getMainMaxTokens: mockGetMainMaxTokens, 92 | getMainTemperature: mockGetMainTemperature, 93 | getResearchProvider: mockGetResearchProvider, 94 | getResearchModelId: mockGetResearchModelId, 95 | getResearchMaxTokens: mockGetResearchMaxTokens, 96 | getResearchTemperature: mockGetResearchTemperature, 97 | getFallbackProvider: mockGetFallbackProvider, 98 | getFallbackModelId: mockGetFallbackModelId, 99 | getFallbackMaxTokens: mockGetFallbackMaxTokens, 100 | getFallbackTemperature: mockGetFallbackTemperature, 101 | getParametersForRole: mockGetParametersForRole, 102 | getResponseLanguage: mockGetResponseLanguage, 103 | getUserId: mockGetUserId, 104 | getDebugFlag: mockGetDebugFlag, 105 | getBaseUrlForRole: mockGetBaseUrlForRole, 106 | 107 | // Global settings 108 | getLogLevel: mockGetLogLevel, 109 | getDefaultNumTasks: mockGetDefaultNumTasks, 110 | getDefaultSubtasks: mockGetDefaultSubtasks, 111 | getDefaultPriority: mockGetDefaultPriority, 112 | getProjectName: mockGetProjectName, 113 | 114 | // API Key and provider functions 115 | isApiKeySet: mockIsApiKeySet, 116 | getAllProviders: mockGetAllProviders, 117 | getOllamaBaseURL: mockGetOllamaBaseURL, 118 | getAzureBaseURL: mockGetAzureBaseURL, 119 | getBedrockBaseURL: mockGetBedrockBaseURL, 120 | getVertexProjectId: mockGetVertexProjectId, 121 | getVertexLocation: mockGetVertexLocation, 122 | getMcpApiKeyStatus: mockGetMcpApiKeyStatus, 123 | 124 | // Providers without API keys 125 | providersWithoutApiKeys: ['ollama', 'bedrock', 'gemini-cli'] 126 | })); 127 | 128 | // Mock AI Provider Classes with proper methods 129 | const mockAnthropicProvider = { 130 | generateText: jest.fn(), 131 | streamText: jest.fn(), 132 | generateObject: jest.fn(), 133 | getRequiredApiKeyName: jest.fn(() => 'ANTHROPIC_API_KEY'), 134 | isRequiredApiKey: jest.fn(() => true) 135 | }; 136 | 137 | const mockPerplexityProvider = { 138 | generateText: jest.fn(), 139 | streamText: jest.fn(), 140 | generateObject: jest.fn(), 141 | getRequiredApiKeyName: jest.fn(() => 'PERPLEXITY_API_KEY'), 142 | isRequiredApiKey: jest.fn(() => true) 143 | }; 144 | 145 | const mockOpenAIProvider = { 146 | generateText: jest.fn(), 147 | streamText: jest.fn(), 148 | generateObject: jest.fn(), 149 | getRequiredApiKeyName: jest.fn(() => 'OPENAI_API_KEY'), 150 | isRequiredApiKey: jest.fn(() => true) 151 | }; 152 | 153 | const mockOllamaProvider = { 154 | generateText: jest.fn(), 155 | streamText: jest.fn(), 156 | generateObject: jest.fn(), 157 | getRequiredApiKeyName: jest.fn(() => null), 158 | isRequiredApiKey: jest.fn(() => false) 159 | }; 160 | 161 | // Mock the provider classes to return our mock instances 162 | jest.unstable_mockModule('../../src/ai-providers/index.js', () => ({ 163 | AnthropicAIProvider: jest.fn(() => mockAnthropicProvider), 164 | PerplexityAIProvider: jest.fn(() => mockPerplexityProvider), 165 | GoogleAIProvider: jest.fn(() => ({ 166 | generateText: jest.fn(), 167 | streamText: jest.fn(), 168 | generateObject: jest.fn(), 169 | getRequiredApiKeyName: jest.fn(() => 'GOOGLE_GENERATIVE_AI_API_KEY'), 170 | isRequiredApiKey: jest.fn(() => true) 171 | })), 172 | OpenAIProvider: jest.fn(() => mockOpenAIProvider), 173 | XAIProvider: jest.fn(() => ({ 174 | generateText: jest.fn(), 175 | streamText: jest.fn(), 176 | generateObject: jest.fn(), 177 | getRequiredApiKeyName: jest.fn(() => 'XAI_API_KEY'), 178 | isRequiredApiKey: jest.fn(() => true) 179 | })), 180 | GroqProvider: jest.fn(() => ({ 181 | generateText: jest.fn(), 182 | streamText: jest.fn(), 183 | generateObject: jest.fn(), 184 | getRequiredApiKeyName: jest.fn(() => 'GROQ_API_KEY'), 185 | isRequiredApiKey: jest.fn(() => true) 186 | })), 187 | OpenRouterAIProvider: jest.fn(() => ({ 188 | generateText: jest.fn(), 189 | streamText: jest.fn(), 190 | generateObject: jest.fn(), 191 | getRequiredApiKeyName: jest.fn(() => 'OPENROUTER_API_KEY'), 192 | isRequiredApiKey: jest.fn(() => true) 193 | })), 194 | OllamaAIProvider: jest.fn(() => mockOllamaProvider), 195 | BedrockAIProvider: jest.fn(() => ({ 196 | generateText: jest.fn(), 197 | streamText: jest.fn(), 198 | generateObject: jest.fn(), 199 | getRequiredApiKeyName: jest.fn(() => 'AWS_ACCESS_KEY_ID'), 200 | isRequiredApiKey: jest.fn(() => false) 201 | })), 202 | AzureProvider: jest.fn(() => ({ 203 | generateText: jest.fn(), 204 | streamText: jest.fn(), 205 | generateObject: jest.fn(), 206 | getRequiredApiKeyName: jest.fn(() => 'AZURE_API_KEY'), 207 | isRequiredApiKey: jest.fn(() => true) 208 | })), 209 | VertexAIProvider: jest.fn(() => ({ 210 | generateText: jest.fn(), 211 | streamText: jest.fn(), 212 | generateObject: jest.fn(), 213 | getRequiredApiKeyName: jest.fn(() => null), 214 | isRequiredApiKey: jest.fn(() => false) 215 | })), 216 | ClaudeCodeProvider: jest.fn(() => ({ 217 | generateText: jest.fn(), 218 | streamText: jest.fn(), 219 | generateObject: jest.fn(), 220 | getRequiredApiKeyName: jest.fn(() => 'CLAUDE_CODE_API_KEY'), 221 | isRequiredApiKey: jest.fn(() => false) 222 | })), 223 | GeminiCliProvider: jest.fn(() => ({ 224 | generateText: jest.fn(), 225 | streamText: jest.fn(), 226 | generateObject: jest.fn(), 227 | getRequiredApiKeyName: jest.fn(() => 'GEMINI_API_KEY'), 228 | isRequiredApiKey: jest.fn(() => false) 229 | })), 230 | GrokCliProvider: jest.fn(() => ({ 231 | generateText: jest.fn(), 232 | streamText: jest.fn(), 233 | generateObject: jest.fn(), 234 | getRequiredApiKeyName: jest.fn(() => 'XAI_API_KEY'), 235 | isRequiredApiKey: jest.fn(() => false) 236 | })) 237 | })); 238 | 239 | // Mock utils logger, API key resolver, AND findProjectRoot 240 | const mockLog = jest.fn(); 241 | const mockResolveEnvVariable = jest.fn(); 242 | const mockFindProjectRoot = jest.fn(); 243 | const mockIsSilentMode = jest.fn(); 244 | const mockLogAiUsage = jest.fn(); 245 | const mockFindCycles = jest.fn(); 246 | const mockFormatTaskId = jest.fn(); 247 | const mockTaskExists = jest.fn(); 248 | const mockFindTaskById = jest.fn(); 249 | const mockTruncate = jest.fn(); 250 | const mockToKebabCase = jest.fn(); 251 | const mockDetectCamelCaseFlags = jest.fn(); 252 | const mockDisableSilentMode = jest.fn(); 253 | const mockEnableSilentMode = jest.fn(); 254 | const mockGetTaskManager = jest.fn(); 255 | const mockAddComplexityToTask = jest.fn(); 256 | const mockReadJSON = jest.fn(); 257 | const mockWriteJSON = jest.fn(); 258 | const mockSanitizePrompt = jest.fn(); 259 | const mockReadComplexityReport = jest.fn(); 260 | const mockFindTaskInComplexityReport = jest.fn(); 261 | const mockAggregateTelemetry = jest.fn(); 262 | const mockGetCurrentTag = jest.fn(() => 'master'); 263 | const mockResolveTag = jest.fn(() => 'master'); 264 | const mockGetTasksForTag = jest.fn(() => []); 265 | 266 | jest.unstable_mockModule('../../scripts/modules/utils.js', () => ({ 267 | LOG_LEVELS: { error: 0, warn: 1, info: 2, debug: 3 }, 268 | log: mockLog, 269 | resolveEnvVariable: mockResolveEnvVariable, 270 | findProjectRoot: mockFindProjectRoot, 271 | isSilentMode: mockIsSilentMode, 272 | logAiUsage: mockLogAiUsage, 273 | findCycles: mockFindCycles, 274 | formatTaskId: mockFormatTaskId, 275 | taskExists: mockTaskExists, 276 | findTaskById: mockFindTaskById, 277 | truncate: mockTruncate, 278 | toKebabCase: mockToKebabCase, 279 | detectCamelCaseFlags: mockDetectCamelCaseFlags, 280 | disableSilentMode: mockDisableSilentMode, 281 | enableSilentMode: mockEnableSilentMode, 282 | getTaskManager: mockGetTaskManager, 283 | addComplexityToTask: mockAddComplexityToTask, 284 | readJSON: mockReadJSON, 285 | writeJSON: mockWriteJSON, 286 | sanitizePrompt: mockSanitizePrompt, 287 | readComplexityReport: mockReadComplexityReport, 288 | findTaskInComplexityReport: mockFindTaskInComplexityReport, 289 | aggregateTelemetry: mockAggregateTelemetry, 290 | getCurrentTag: mockGetCurrentTag, 291 | resolveTag: mockResolveTag, 292 | getTasksForTag: mockGetTasksForTag 293 | })); 294 | 295 | // Import the module to test (AFTER mocks) 296 | const { generateTextService } = await import( 297 | '../../scripts/modules/ai-services-unified.js' 298 | ); 299 | 300 | describe('Unified AI Services', () => { 301 | const fakeProjectRoot = '/fake/project/root'; // Define for reuse 302 | 303 | beforeEach(() => { 304 | // Clear mocks before each test 305 | jest.clearAllMocks(); // Clears all mocks 306 | 307 | // Set default mock behaviors 308 | mockGetMainProvider.mockReturnValue('anthropic'); 309 | mockGetMainModelId.mockReturnValue('test-main-model'); 310 | mockGetResearchProvider.mockReturnValue('perplexity'); 311 | mockGetResearchModelId.mockReturnValue('test-research-model'); 312 | mockGetFallbackProvider.mockReturnValue('anthropic'); 313 | mockGetFallbackModelId.mockReturnValue('test-fallback-model'); 314 | mockGetParametersForRole.mockImplementation((role) => { 315 | if (role === 'main') return { maxTokens: 100, temperature: 0.5 }; 316 | if (role === 'research') return { maxTokens: 200, temperature: 0.3 }; 317 | if (role === 'fallback') return { maxTokens: 150, temperature: 0.6 }; 318 | return { maxTokens: 100, temperature: 0.5 }; // Default 319 | }); 320 | mockGetResponseLanguage.mockReturnValue('English'); 321 | mockResolveEnvVariable.mockImplementation((key) => { 322 | if (key === 'ANTHROPIC_API_KEY') return 'mock-anthropic-key'; 323 | if (key === 'PERPLEXITY_API_KEY') return 'mock-perplexity-key'; 324 | if (key === 'OPENAI_API_KEY') return 'mock-openai-key'; 325 | if (key === 'OLLAMA_API_KEY') return 'mock-ollama-key'; 326 | return null; 327 | }); 328 | 329 | // Set a default behavior for the new mock 330 | mockFindProjectRoot.mockReturnValue(fakeProjectRoot); 331 | mockGetDebugFlag.mockReturnValue(false); 332 | mockGetUserId.mockReturnValue('test-user-id'); // Add default mock for getUserId 333 | mockIsApiKeySet.mockReturnValue(true); // Default to true for most tests 334 | mockGetBaseUrlForRole.mockReturnValue(null); // Default to no base URL 335 | }); 336 | 337 | describe('generateTextService', () => { 338 | test('should use main provider/model and succeed', async () => { 339 | mockAnthropicProvider.generateText.mockResolvedValue({ 340 | text: 'Main provider response', 341 | usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 } 342 | }); 343 | 344 | const params = { 345 | role: 'main', 346 | session: { env: {} }, 347 | systemPrompt: 'System', 348 | prompt: 'Test' 349 | }; 350 | const result = await generateTextService(params); 351 | 352 | expect(result.mainResult).toBe('Main provider response'); 353 | expect(result).toHaveProperty('telemetryData'); 354 | expect(mockGetMainProvider).toHaveBeenCalledWith(fakeProjectRoot); 355 | expect(mockGetMainModelId).toHaveBeenCalledWith(fakeProjectRoot); 356 | expect(mockGetParametersForRole).toHaveBeenCalledWith( 357 | 'main', 358 | fakeProjectRoot 359 | ); 360 | expect(mockAnthropicProvider.generateText).toHaveBeenCalledTimes(1); 361 | expect(mockPerplexityProvider.generateText).not.toHaveBeenCalled(); 362 | }); 363 | 364 | test('should fall back to fallback provider if main fails', async () => { 365 | const mainError = new Error('Main provider failed'); 366 | mockAnthropicProvider.generateText 367 | .mockRejectedValueOnce(mainError) 368 | .mockResolvedValueOnce({ 369 | text: 'Fallback provider response', 370 | usage: { inputTokens: 15, outputTokens: 25, totalTokens: 40 } 371 | }); 372 | 373 | const explicitRoot = '/explicit/test/root'; 374 | const params = { 375 | role: 'main', 376 | prompt: 'Fallback test', 377 | projectRoot: explicitRoot 378 | }; 379 | const result = await generateTextService(params); 380 | 381 | expect(result.mainResult).toBe('Fallback provider response'); 382 | expect(result).toHaveProperty('telemetryData'); 383 | expect(mockGetMainProvider).toHaveBeenCalledWith(explicitRoot); 384 | expect(mockGetFallbackProvider).toHaveBeenCalledWith(explicitRoot); 385 | expect(mockGetParametersForRole).toHaveBeenCalledWith( 386 | 'main', 387 | explicitRoot 388 | ); 389 | expect(mockGetParametersForRole).toHaveBeenCalledWith( 390 | 'fallback', 391 | explicitRoot 392 | ); 393 | 394 | expect(mockAnthropicProvider.generateText).toHaveBeenCalledTimes(2); 395 | expect(mockPerplexityProvider.generateText).not.toHaveBeenCalled(); 396 | expect(mockLog).toHaveBeenCalledWith( 397 | 'error', 398 | expect.stringContaining('Service call failed for role main') 399 | ); 400 | expect(mockLog).toHaveBeenCalledWith( 401 | 'debug', 402 | expect.stringContaining('New AI service call with role: fallback') 403 | ); 404 | }); 405 | 406 | test('should fall back to research provider if main and fallback fail', async () => { 407 | const mainError = new Error('Main failed'); 408 | const fallbackError = new Error('Fallback failed'); 409 | mockAnthropicProvider.generateText 410 | .mockRejectedValueOnce(mainError) 411 | .mockRejectedValueOnce(fallbackError); 412 | mockPerplexityProvider.generateText.mockResolvedValue({ 413 | text: 'Research provider response', 414 | usage: { inputTokens: 20, outputTokens: 30, totalTokens: 50 } 415 | }); 416 | 417 | const params = { role: 'main', prompt: 'Research fallback test' }; 418 | const result = await generateTextService(params); 419 | 420 | expect(result.mainResult).toBe('Research provider response'); 421 | expect(result).toHaveProperty('telemetryData'); 422 | expect(mockGetMainProvider).toHaveBeenCalledWith(fakeProjectRoot); 423 | expect(mockGetFallbackProvider).toHaveBeenCalledWith(fakeProjectRoot); 424 | expect(mockGetResearchProvider).toHaveBeenCalledWith(fakeProjectRoot); 425 | expect(mockGetParametersForRole).toHaveBeenCalledWith( 426 | 'main', 427 | fakeProjectRoot 428 | ); 429 | expect(mockGetParametersForRole).toHaveBeenCalledWith( 430 | 'fallback', 431 | fakeProjectRoot 432 | ); 433 | expect(mockGetParametersForRole).toHaveBeenCalledWith( 434 | 'research', 435 | fakeProjectRoot 436 | ); 437 | 438 | expect(mockAnthropicProvider.generateText).toHaveBeenCalledTimes(2); 439 | expect(mockPerplexityProvider.generateText).toHaveBeenCalledTimes(1); 440 | expect(mockLog).toHaveBeenCalledWith( 441 | 'error', 442 | expect.stringContaining('Service call failed for role fallback') 443 | ); 444 | expect(mockLog).toHaveBeenCalledWith( 445 | 'debug', 446 | expect.stringContaining('New AI service call with role: research') 447 | ); 448 | }); 449 | 450 | test('should throw error if all providers in sequence fail', async () => { 451 | mockAnthropicProvider.generateText.mockRejectedValue( 452 | new Error('Anthropic failed') 453 | ); 454 | mockPerplexityProvider.generateText.mockRejectedValue( 455 | new Error('Perplexity failed') 456 | ); 457 | 458 | const params = { role: 'main', prompt: 'All fail test' }; 459 | 460 | await expect(generateTextService(params)).rejects.toThrow( 461 | 'Perplexity failed' // Error from the last attempt (research) 462 | ); 463 | 464 | expect(mockAnthropicProvider.generateText).toHaveBeenCalledTimes(2); // main, fallback 465 | expect(mockPerplexityProvider.generateText).toHaveBeenCalledTimes(1); // research 466 | }); 467 | 468 | test('should handle retryable errors correctly', async () => { 469 | const retryableError = new Error('Rate limit'); 470 | mockAnthropicProvider.generateText 471 | .mockRejectedValueOnce(retryableError) // Fails once 472 | .mockResolvedValueOnce({ 473 | // Succeeds on retry 474 | text: 'Success after retry', 475 | usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 } 476 | }); 477 | 478 | const params = { role: 'main', prompt: 'Retry success test' }; 479 | const result = await generateTextService(params); 480 | 481 | expect(result.mainResult).toBe('Success after retry'); 482 | expect(result).toHaveProperty('telemetryData'); 483 | expect(mockAnthropicProvider.generateText).toHaveBeenCalledTimes(2); // Initial + 1 retry 484 | expect(mockLog).toHaveBeenCalledWith( 485 | 'info', 486 | expect.stringContaining( 487 | 'Something went wrong on the provider side. Retrying' 488 | ) 489 | ); 490 | }); 491 | 492 | test('should use default project root or handle null if findProjectRoot returns null', async () => { 493 | mockFindProjectRoot.mockReturnValue(null); // Simulate not finding root 494 | mockAnthropicProvider.generateText.mockResolvedValue({ 495 | text: 'Response with no root', 496 | usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } 497 | }); 498 | 499 | const params = { role: 'main', prompt: 'No root test' }; // No explicit root passed 500 | await generateTextService(params); 501 | 502 | expect(mockGetMainProvider).toHaveBeenCalledWith(null); 503 | expect(mockGetParametersForRole).toHaveBeenCalledWith('main', null); 504 | expect(mockAnthropicProvider.generateText).toHaveBeenCalledTimes(1); 505 | }); 506 | 507 | test('should use configured responseLanguage in system prompt', async () => { 508 | mockGetResponseLanguage.mockReturnValue('中文'); 509 | mockAnthropicProvider.generateText.mockResolvedValue('中文回复'); 510 | 511 | const params = { 512 | role: 'main', 513 | systemPrompt: 'You are an assistant', 514 | prompt: 'Hello' 515 | }; 516 | await generateTextService(params); 517 | 518 | expect(mockAnthropicProvider.generateText).toHaveBeenCalledWith( 519 | expect.objectContaining({ 520 | messages: [ 521 | { 522 | role: 'system', 523 | content: expect.stringContaining('Always respond in 中文') 524 | }, 525 | { role: 'user', content: 'Hello' } 526 | ] 527 | }) 528 | ); 529 | expect(mockGetResponseLanguage).toHaveBeenCalledWith(fakeProjectRoot); 530 | }); 531 | 532 | test('should pass custom projectRoot to getResponseLanguage', async () => { 533 | const customRoot = '/custom/project/root'; 534 | mockGetResponseLanguage.mockReturnValue('Español'); 535 | mockAnthropicProvider.generateText.mockResolvedValue( 536 | 'Respuesta en Español' 537 | ); 538 | 539 | const params = { 540 | role: 'main', 541 | systemPrompt: 'You are an assistant', 542 | prompt: 'Hello', 543 | projectRoot: customRoot 544 | }; 545 | await generateTextService(params); 546 | 547 | expect(mockGetResponseLanguage).toHaveBeenCalledWith(customRoot); 548 | expect(mockAnthropicProvider.generateText).toHaveBeenCalledWith( 549 | expect.objectContaining({ 550 | messages: [ 551 | { 552 | role: 'system', 553 | content: expect.stringContaining('Always respond in Español') 554 | }, 555 | { role: 'user', content: 'Hello' } 556 | ] 557 | }) 558 | ); 559 | }); 560 | 561 | // Add more tests for edge cases: 562 | // - Missing API keys (should throw from _resolveApiKey) 563 | // - Unsupported provider configured (should skip and log) 564 | // - Missing provider/model config for a role (should skip and log) 565 | // - Missing prompt 566 | // - Different initial roles (research, fallback) 567 | // - generateObjectService (mock schema, check object result) 568 | // - streamTextService (more complex to test, might need stream helpers) 569 | test('should skip provider with missing API key and try next in fallback sequence', async () => { 570 | // Setup isApiKeySet to return false for anthropic but true for perplexity 571 | mockIsApiKeySet.mockImplementation((provider, session, root) => { 572 | if (provider === 'anthropic') return false; // Main provider has no key 573 | return true; // Other providers have keys 574 | }); 575 | 576 | // Mock perplexity text response (since we'll skip anthropic) 577 | mockPerplexityProvider.generateText.mockResolvedValue({ 578 | text: 'Perplexity response (skipped to research)', 579 | usage: { inputTokens: 20, outputTokens: 30, totalTokens: 50 } 580 | }); 581 | 582 | const params = { 583 | role: 'main', 584 | prompt: 'Skip main provider test', 585 | session: { env: {} } 586 | }; 587 | 588 | const result = await generateTextService(params); 589 | 590 | // Should have gotten the perplexity response 591 | expect(result.mainResult).toBe( 592 | 'Perplexity response (skipped to research)' 593 | ); 594 | 595 | // Should check API keys 596 | expect(mockIsApiKeySet).toHaveBeenCalledWith( 597 | 'anthropic', 598 | params.session, 599 | fakeProjectRoot 600 | ); 601 | expect(mockIsApiKeySet).toHaveBeenCalledWith( 602 | 'perplexity', 603 | params.session, 604 | fakeProjectRoot 605 | ); 606 | 607 | // Should log a warning 608 | expect(mockLog).toHaveBeenCalledWith( 609 | 'warn', 610 | expect.stringContaining( 611 | `Skipping role 'main' (Provider: anthropic): API key not set or invalid.` 612 | ) 613 | ); 614 | 615 | // Should NOT call anthropic provider 616 | expect(mockAnthropicProvider.generateText).not.toHaveBeenCalled(); 617 | 618 | // Should call perplexity provider 619 | expect(mockPerplexityProvider.generateText).toHaveBeenCalledTimes(1); 620 | }); 621 | 622 | test('should skip multiple providers with missing API keys and use first available', async () => { 623 | // Setup: Main and fallback providers have no keys, only research has a key 624 | mockIsApiKeySet.mockImplementation((provider, session, root) => { 625 | if (provider === 'anthropic') return false; // Main and fallback are both anthropic 626 | if (provider === 'perplexity') return true; // Research has a key 627 | return false; 628 | }); 629 | 630 | // Define different providers for testing multiple skips 631 | mockGetFallbackProvider.mockReturnValue('openai'); // Different from main 632 | mockGetFallbackModelId.mockReturnValue('test-openai-model'); 633 | 634 | // Mock isApiKeySet to return false for both main and fallback 635 | mockIsApiKeySet.mockImplementation((provider, session, root) => { 636 | if (provider === 'anthropic') return false; // Main provider has no key 637 | if (provider === 'openai') return false; // Fallback provider has no key 638 | return true; // Research provider has a key 639 | }); 640 | 641 | // Mock perplexity text response (since we'll skip to research) 642 | mockPerplexityProvider.generateText.mockResolvedValue({ 643 | text: 'Research response after skipping main and fallback', 644 | usage: { inputTokens: 20, outputTokens: 30, totalTokens: 50 } 645 | }); 646 | 647 | const params = { 648 | role: 'main', 649 | prompt: 'Skip multiple providers test', 650 | session: { env: {} } 651 | }; 652 | 653 | const result = await generateTextService(params); 654 | 655 | // Should have gotten the perplexity (research) response 656 | expect(result.mainResult).toBe( 657 | 'Research response after skipping main and fallback' 658 | ); 659 | 660 | // Should check API keys for all three roles 661 | expect(mockIsApiKeySet).toHaveBeenCalledWith( 662 | 'anthropic', 663 | params.session, 664 | fakeProjectRoot 665 | ); 666 | expect(mockIsApiKeySet).toHaveBeenCalledWith( 667 | 'openai', 668 | params.session, 669 | fakeProjectRoot 670 | ); 671 | expect(mockIsApiKeySet).toHaveBeenCalledWith( 672 | 'perplexity', 673 | params.session, 674 | fakeProjectRoot 675 | ); 676 | 677 | // Should log warnings for both skipped providers 678 | expect(mockLog).toHaveBeenCalledWith( 679 | 'warn', 680 | expect.stringContaining( 681 | `Skipping role 'main' (Provider: anthropic): API key not set or invalid.` 682 | ) 683 | ); 684 | expect(mockLog).toHaveBeenCalledWith( 685 | 'warn', 686 | expect.stringContaining( 687 | `Skipping role 'fallback' (Provider: openai): API key not set or invalid.` 688 | ) 689 | ); 690 | 691 | // Should NOT call skipped providers 692 | expect(mockAnthropicProvider.generateText).not.toHaveBeenCalled(); 693 | expect(mockOpenAIProvider.generateText).not.toHaveBeenCalled(); 694 | 695 | // Should call perplexity provider 696 | expect(mockPerplexityProvider.generateText).toHaveBeenCalledTimes(1); 697 | }); 698 | 699 | test('should throw error if all providers in sequence have missing API keys', async () => { 700 | // Mock all providers to have missing API keys 701 | mockIsApiKeySet.mockReturnValue(false); 702 | 703 | const params = { 704 | role: 'main', 705 | prompt: 'All API keys missing test', 706 | session: { env: {} } 707 | }; 708 | 709 | // Should throw error since all providers would be skipped 710 | await expect(generateTextService(params)).rejects.toThrow( 711 | 'AI service call failed for all configured roles' 712 | ); 713 | 714 | // Should log warnings for all skipped providers 715 | expect(mockLog).toHaveBeenCalledWith( 716 | 'warn', 717 | expect.stringContaining( 718 | `Skipping role 'main' (Provider: anthropic): API key not set or invalid.` 719 | ) 720 | ); 721 | expect(mockLog).toHaveBeenCalledWith( 722 | 'warn', 723 | expect.stringContaining( 724 | `Skipping role 'fallback' (Provider: anthropic): API key not set or invalid.` 725 | ) 726 | ); 727 | expect(mockLog).toHaveBeenCalledWith( 728 | 'warn', 729 | expect.stringContaining( 730 | `Skipping role 'research' (Provider: perplexity): API key not set or invalid.` 731 | ) 732 | ); 733 | 734 | // Should log final error 735 | expect(mockLog).toHaveBeenCalledWith( 736 | 'error', 737 | expect.stringContaining( 738 | 'All roles in the sequence [main, fallback, research] failed.' 739 | ) 740 | ); 741 | 742 | // Should NOT call any providers 743 | expect(mockAnthropicProvider.generateText).not.toHaveBeenCalled(); 744 | expect(mockPerplexityProvider.generateText).not.toHaveBeenCalled(); 745 | }); 746 | 747 | test('should not check API key for Ollama provider and try to use it', async () => { 748 | // Setup: Set main provider to ollama 749 | mockGetMainProvider.mockReturnValue('ollama'); 750 | mockGetMainModelId.mockReturnValue('llama3'); 751 | 752 | // Mock Ollama text generation to succeed 753 | mockOllamaProvider.generateText.mockResolvedValue({ 754 | text: 'Ollama response (no API key required)', 755 | usage: { inputTokens: 10, outputTokens: 10, totalTokens: 20 } 756 | }); 757 | 758 | const params = { 759 | role: 'main', 760 | prompt: 'Ollama special case test', 761 | session: { env: {} } 762 | }; 763 | 764 | const result = await generateTextService(params); 765 | 766 | // Should have gotten the Ollama response 767 | expect(result.mainResult).toBe('Ollama response (no API key required)'); 768 | 769 | // isApiKeySet shouldn't be called for Ollama 770 | // Note: This is indirect - the code just doesn't check isApiKeySet for ollama 771 | // so we're verifying ollama provider was called despite isApiKeySet being mocked to false 772 | mockIsApiKeySet.mockReturnValue(false); // Should be ignored for Ollama 773 | 774 | // Should call Ollama provider 775 | expect(mockOllamaProvider.generateText).toHaveBeenCalledTimes(1); 776 | }); 777 | 778 | test('should correctly use the provided session for API key check', async () => { 779 | // Mock custom session object with env vars 780 | const customSession = { env: { ANTHROPIC_API_KEY: 'session-api-key' } }; 781 | 782 | // Setup API key check to verify the session is passed correctly 783 | mockIsApiKeySet.mockImplementation((provider, session, root) => { 784 | // Only return true if the correct session was provided 785 | return session === customSession; 786 | }); 787 | 788 | // Mock the anthropic response 789 | mockAnthropicProvider.generateText.mockResolvedValue({ 790 | text: 'Anthropic response with session key', 791 | usage: { inputTokens: 10, outputTokens: 10, totalTokens: 20 } 792 | }); 793 | 794 | const params = { 795 | role: 'main', 796 | prompt: 'Session API key test', 797 | session: customSession 798 | }; 799 | 800 | const result = await generateTextService(params); 801 | 802 | // Should check API key with the custom session 803 | expect(mockIsApiKeySet).toHaveBeenCalledWith( 804 | 'anthropic', 805 | customSession, 806 | fakeProjectRoot 807 | ); 808 | 809 | // Should have gotten the anthropic response 810 | expect(result.mainResult).toBe('Anthropic response with session key'); 811 | }); 812 | }); 813 | }); 814 | ``` -------------------------------------------------------------------------------- /scripts/init.js: -------------------------------------------------------------------------------- ```javascript 1 | /** 2 | * Task Master 3 | * Copyright (c) 2025 Eyal Toledano, Ralph Khreish 4 | * 5 | * This software is licensed under the MIT License with Commons Clause. 6 | * You may use this software for any purpose, including commercial applications, 7 | * and modify and redistribute it freely, subject to the following restrictions: 8 | * 9 | * 1. You may not sell this software or offer it as a service. 10 | * 2. The origin of this software must not be misrepresented. 11 | * 3. Altered source versions must be plainly marked as such. 12 | * 13 | * For the full license text, see the LICENSE file in the root directory. 14 | */ 15 | 16 | import fs from 'fs'; 17 | import path from 'path'; 18 | import readline from 'readline'; 19 | import chalk from 'chalk'; 20 | import figlet from 'figlet'; 21 | import boxen from 'boxen'; 22 | import gradient from 'gradient-string'; 23 | import { isSilentMode } from './modules/utils.js'; 24 | import { insideGitWorkTree } from './modules/utils/git-utils.js'; 25 | import { manageGitignoreFile } from '../src/utils/manage-gitignore.js'; 26 | import { RULE_PROFILES } from '../src/constants/profiles.js'; 27 | import { 28 | convertAllRulesToProfileRules, 29 | getRulesProfile 30 | } from '../src/utils/rule-transformer.js'; 31 | import { updateConfigMaxTokens } from './modules/update-config-tokens.js'; 32 | 33 | // Import asset resolver 34 | import { assetExists, readAsset } from '../src/utils/asset-resolver.js'; 35 | 36 | import { execSync } from 'child_process'; 37 | import { 38 | EXAMPLE_PRD_FILE, 39 | TASKMASTER_CONFIG_FILE, 40 | TASKMASTER_TEMPLATES_DIR, 41 | TASKMASTER_DIR, 42 | TASKMASTER_TASKS_DIR, 43 | TASKMASTER_DOCS_DIR, 44 | TASKMASTER_REPORTS_DIR, 45 | TASKMASTER_STATE_FILE, 46 | ENV_EXAMPLE_FILE, 47 | GITIGNORE_FILE 48 | } from '../src/constants/paths.js'; 49 | 50 | // Define log levels 51 | const LOG_LEVELS = { 52 | debug: 0, 53 | info: 1, 54 | warn: 2, 55 | error: 3, 56 | success: 4 57 | }; 58 | 59 | // Determine log level from environment variable or default to 'info' 60 | const LOG_LEVEL = process.env.TASKMASTER_LOG_LEVEL 61 | ? LOG_LEVELS[process.env.TASKMASTER_LOG_LEVEL.toLowerCase()] 62 | : LOG_LEVELS.info; // Default to info 63 | 64 | // Create a color gradient for the banner 65 | const coolGradient = gradient(['#00b4d8', '#0077b6', '#03045e']); 66 | const warmGradient = gradient(['#fb8b24', '#e36414', '#9a031e']); 67 | 68 | // Display a fancy banner 69 | function displayBanner() { 70 | if (isSilentMode()) return; 71 | 72 | console.clear(); 73 | const bannerText = figlet.textSync('Task Master AI', { 74 | font: 'Standard', 75 | horizontalLayout: 'default', 76 | verticalLayout: 'default' 77 | }); 78 | 79 | console.log(coolGradient(bannerText)); 80 | 81 | // Add creator credit line below the banner 82 | console.log( 83 | chalk.dim('by ') + chalk.cyan.underline('https://x.com/eyaltoledano') 84 | ); 85 | 86 | console.log( 87 | boxen(chalk.white(`${chalk.bold('Initializing')} your new project`), { 88 | padding: 1, 89 | margin: { top: 0, bottom: 1 }, 90 | borderStyle: 'round', 91 | borderColor: 'cyan' 92 | }) 93 | ); 94 | } 95 | 96 | // Logging function with icons and colors 97 | function log(level, ...args) { 98 | const icons = { 99 | debug: chalk.gray('🔍'), 100 | info: chalk.blue('ℹ️'), 101 | warn: chalk.yellow('⚠️'), 102 | error: chalk.red('❌'), 103 | success: chalk.green('✅') 104 | }; 105 | 106 | if (LOG_LEVELS[level] >= LOG_LEVEL) { 107 | const icon = icons[level] || ''; 108 | 109 | // Only output to console if not in silent mode 110 | if (!isSilentMode()) { 111 | if (level === 'error') { 112 | console.error(icon, chalk.red(...args)); 113 | } else if (level === 'warn') { 114 | console.warn(icon, chalk.yellow(...args)); 115 | } else if (level === 'success') { 116 | console.log(icon, chalk.green(...args)); 117 | } else if (level === 'info') { 118 | console.log(icon, chalk.blue(...args)); 119 | } else { 120 | console.log(icon, ...args); 121 | } 122 | } 123 | } 124 | 125 | // Write to debug log if DEBUG=true 126 | if (process.env.DEBUG === 'true') { 127 | const logMessage = `[${level.toUpperCase()}] ${args.join(' ')}\n`; 128 | fs.appendFileSync('init-debug.log', logMessage); 129 | } 130 | } 131 | 132 | // Function to create directory if it doesn't exist 133 | function ensureDirectoryExists(dirPath) { 134 | if (!fs.existsSync(dirPath)) { 135 | fs.mkdirSync(dirPath, { recursive: true }); 136 | log('info', `Created directory: ${dirPath}`); 137 | } 138 | } 139 | 140 | // Function to add shell aliases to the user's shell configuration 141 | function addShellAliases() { 142 | const homeDir = process.env.HOME || process.env.USERPROFILE; 143 | let shellConfigFile; 144 | 145 | // Determine which shell config file to use 146 | if (process.env.SHELL?.includes('zsh')) { 147 | shellConfigFile = path.join(homeDir, '.zshrc'); 148 | } else if (process.env.SHELL?.includes('bash')) { 149 | shellConfigFile = path.join(homeDir, '.bashrc'); 150 | } else { 151 | log('warn', 'Could not determine shell type. Aliases not added.'); 152 | return false; 153 | } 154 | 155 | try { 156 | // Check if file exists 157 | if (!fs.existsSync(shellConfigFile)) { 158 | log( 159 | 'warn', 160 | `Shell config file ${shellConfigFile} not found. Aliases not added.` 161 | ); 162 | return false; 163 | } 164 | 165 | // Check if aliases already exist 166 | const configContent = fs.readFileSync(shellConfigFile, 'utf8'); 167 | if (configContent.includes("alias tm='task-master'")) { 168 | log('info', 'Task Master aliases already exist in shell config.'); 169 | return true; 170 | } 171 | 172 | // Add aliases to the shell config file 173 | const aliasBlock = ` 174 | # Task Master aliases added on ${new Date().toLocaleDateString()} 175 | alias tm='task-master' 176 | alias taskmaster='task-master' 177 | `; 178 | 179 | fs.appendFileSync(shellConfigFile, aliasBlock); 180 | log('success', `Added Task Master aliases to ${shellConfigFile}`); 181 | log( 182 | 'info', 183 | `To use the aliases in your current terminal, run: source ${shellConfigFile}` 184 | ); 185 | 186 | return true; 187 | } catch (error) { 188 | log('error', `Failed to add aliases: ${error.message}`); 189 | return false; 190 | } 191 | } 192 | 193 | // Function to create initial state.json file for tag management 194 | function createInitialStateFile(targetDir) { 195 | const stateFilePath = path.join(targetDir, TASKMASTER_STATE_FILE); 196 | 197 | // Check if state.json already exists 198 | if (fs.existsSync(stateFilePath)) { 199 | log('info', 'State file already exists, preserving current configuration'); 200 | return; 201 | } 202 | 203 | // Create initial state configuration 204 | const initialState = { 205 | currentTag: 'master', 206 | lastSwitched: new Date().toISOString(), 207 | branchTagMapping: {}, 208 | migrationNoticeShown: false 209 | }; 210 | 211 | try { 212 | fs.writeFileSync(stateFilePath, JSON.stringify(initialState, null, 2)); 213 | log('success', `Created initial state file: ${stateFilePath}`); 214 | log('info', 'Default tag set to "master" for task organization'); 215 | } catch (error) { 216 | log('error', `Failed to create state file: ${error.message}`); 217 | } 218 | } 219 | 220 | // Function to copy a file from the package to the target directory 221 | function copyTemplateFile(templateName, targetPath, replacements = {}) { 222 | // Get the file content from the appropriate source directory 223 | // Check if the asset exists 224 | if (!assetExists(templateName)) { 225 | log('error', `Source file not found: ${templateName}`); 226 | return; 227 | } 228 | 229 | // Read the asset content using the resolver 230 | let content = readAsset(templateName, 'utf8'); 231 | 232 | // Replace placeholders with actual values 233 | Object.entries(replacements).forEach(([key, value]) => { 234 | const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g'); 235 | content = content.replace(regex, value); 236 | }); 237 | 238 | // Handle special files that should be merged instead of overwritten 239 | if (fs.existsSync(targetPath)) { 240 | const filename = path.basename(targetPath); 241 | 242 | // Handle .gitignore - append lines that don't exist 243 | if (filename === '.gitignore') { 244 | log('info', `${targetPath} already exists, merging content...`); 245 | const existingContent = fs.readFileSync(targetPath, 'utf8'); 246 | const existingLines = new Set( 247 | existingContent.split('\n').map((line) => line.trim()) 248 | ); 249 | const newLines = content 250 | .split('\n') 251 | .filter((line) => !existingLines.has(line.trim())); 252 | 253 | if (newLines.length > 0) { 254 | // Add a comment to separate the original content from our additions 255 | const updatedContent = `${existingContent.trim()}\n\n# Added by Task Master AI\n${newLines.join('\n')}`; 256 | fs.writeFileSync(targetPath, updatedContent); 257 | log('success', `Updated ${targetPath} with additional entries`); 258 | } else { 259 | log('info', `No new content to add to ${targetPath}`); 260 | } 261 | return; 262 | } 263 | 264 | // Handle README.md - offer to preserve or create a different file 265 | if (filename === 'README-task-master.md') { 266 | log('info', `${targetPath} already exists`); 267 | // Create a separate README file specifically for this project 268 | const taskMasterReadmePath = path.join( 269 | path.dirname(targetPath), 270 | 'README-task-master.md' 271 | ); 272 | fs.writeFileSync(taskMasterReadmePath, content); 273 | log( 274 | 'success', 275 | `Created ${taskMasterReadmePath} (preserved original README-task-master.md)` 276 | ); 277 | return; 278 | } 279 | 280 | // For other files, warn and prompt before overwriting 281 | log('warn', `${targetPath} already exists, skipping.`); 282 | return; 283 | } 284 | 285 | // If the file doesn't exist, create it normally 286 | fs.writeFileSync(targetPath, content); 287 | log('info', `Created file: ${targetPath}`); 288 | } 289 | 290 | // Main function to initialize a new project 291 | async function initializeProject(options = {}) { 292 | // Receives options as argument 293 | // Only display banner if not in silent mode 294 | if (!isSilentMode()) { 295 | displayBanner(); 296 | } 297 | 298 | // Debug logging only if not in silent mode 299 | // if (!isSilentMode()) { 300 | // console.log('===== DEBUG: INITIALIZE PROJECT OPTIONS RECEIVED ====='); 301 | // console.log('Full options object:', JSON.stringify(options)); 302 | // console.log('options.yes:', options.yes); 303 | // console.log('=================================================='); 304 | // } 305 | 306 | // Handle boolean aliases flags 307 | if (options.aliases === true) { 308 | options.addAliases = true; // --aliases flag provided 309 | } else if (options.aliases === false) { 310 | options.addAliases = false; // --no-aliases flag provided 311 | } 312 | // If options.aliases and options.noAliases are undefined, we'll prompt for it 313 | 314 | // Handle boolean git flags 315 | if (options.git === true) { 316 | options.initGit = true; // --git flag provided 317 | } else if (options.git === false) { 318 | options.initGit = false; // --no-git flag provided 319 | } 320 | // If options.git and options.noGit are undefined, we'll prompt for it 321 | 322 | // Handle boolean gitTasks flags 323 | if (options.gitTasks === true) { 324 | options.storeTasksInGit = true; // --git-tasks flag provided 325 | } else if (options.gitTasks === false) { 326 | options.storeTasksInGit = false; // --no-git-tasks flag provided 327 | } 328 | // If options.gitTasks and options.noGitTasks are undefined, we'll prompt for it 329 | 330 | const skipPrompts = options.yes || (options.name && options.description); 331 | 332 | // if (!isSilentMode()) { 333 | // console.log('Skip prompts determined:', skipPrompts); 334 | // } 335 | 336 | let selectedRuleProfiles; 337 | if (options.rulesExplicitlyProvided) { 338 | // If --rules flag was used, always respect it. 339 | log( 340 | 'info', 341 | `Using rule profiles provided via command line: ${options.rules.join(', ')}` 342 | ); 343 | selectedRuleProfiles = options.rules; 344 | } else if (skipPrompts) { 345 | // If non-interactive (e.g., --yes) and no rules specified, default to ALL. 346 | log( 347 | 'info', 348 | `No rules specified in non-interactive mode, defaulting to all profiles.` 349 | ); 350 | selectedRuleProfiles = RULE_PROFILES; 351 | } else { 352 | // If interactive and no rules specified, default to NONE. 353 | // The 'rules --setup' wizard will handle selection. 354 | log( 355 | 'info', 356 | 'No rules specified; interactive setup will be launched to select profiles.' 357 | ); 358 | selectedRuleProfiles = []; 359 | } 360 | 361 | if (skipPrompts) { 362 | if (!isSilentMode()) { 363 | console.log('SKIPPING PROMPTS - Using defaults or provided values'); 364 | } 365 | 366 | // Use provided options or defaults 367 | const projectName = options.name || 'task-master-project'; 368 | const projectDescription = 369 | options.description || 'A project managed with Task Master AI'; 370 | const projectVersion = options.version || '0.1.0'; 371 | const authorName = options.author || 'Vibe coder'; 372 | const dryRun = options.dryRun || false; 373 | const addAliases = 374 | options.addAliases !== undefined ? options.addAliases : true; // Default to true if not specified 375 | const initGit = options.initGit !== undefined ? options.initGit : true; // Default to true if not specified 376 | const storeTasksInGit = 377 | options.storeTasksInGit !== undefined ? options.storeTasksInGit : true; // Default to true if not specified 378 | 379 | if (dryRun) { 380 | log('info', 'DRY RUN MODE: No files will be modified'); 381 | log('info', 'Would initialize Task Master project'); 382 | log('info', 'Would create/update necessary project files'); 383 | 384 | // Show flag-specific behavior 385 | log( 386 | 'info', 387 | `${addAliases ? 'Would add shell aliases (tm, taskmaster)' : 'Would skip shell aliases'}` 388 | ); 389 | log( 390 | 'info', 391 | `${initGit ? 'Would initialize Git repository' : 'Would skip Git initialization'}` 392 | ); 393 | log( 394 | 'info', 395 | `${storeTasksInGit ? 'Would store tasks in Git' : 'Would exclude tasks from Git'}` 396 | ); 397 | 398 | return { 399 | dryRun: true 400 | }; 401 | } 402 | 403 | createProjectStructure( 404 | addAliases, 405 | initGit, 406 | storeTasksInGit, 407 | dryRun, 408 | options, 409 | selectedRuleProfiles 410 | ); 411 | } else { 412 | // Interactive logic 413 | log('info', 'Required options not provided, proceeding with prompts.'); 414 | 415 | try { 416 | const rl = readline.createInterface({ 417 | input: process.stdin, 418 | output: process.stdout 419 | }); 420 | // Prompt for shell aliases (skip if --aliases or --no-aliases flag was provided) 421 | let addAliasesPrompted = true; // Default to true 422 | if (options.addAliases !== undefined) { 423 | addAliasesPrompted = options.addAliases; // Use flag value if provided 424 | } else { 425 | const addAliasesInput = await promptQuestion( 426 | rl, 427 | chalk.cyan( 428 | 'Add shell aliases for task-master? This lets you type "tm" instead of "task-master" (Y/n): ' 429 | ) 430 | ); 431 | addAliasesPrompted = addAliasesInput.trim().toLowerCase() !== 'n'; 432 | } 433 | 434 | // Prompt for Git initialization (skip if --git or --no-git flag was provided) 435 | let initGitPrompted = true; // Default to true 436 | if (options.initGit !== undefined) { 437 | initGitPrompted = options.initGit; // Use flag value if provided 438 | } else { 439 | const gitInitInput = await promptQuestion( 440 | rl, 441 | chalk.cyan('Initialize a Git repository in project root? (Y/n): ') 442 | ); 443 | initGitPrompted = gitInitInput.trim().toLowerCase() !== 'n'; 444 | } 445 | 446 | // Prompt for Git tasks storage (skip if --git-tasks or --no-git-tasks flag was provided) 447 | let storeGitPrompted = true; // Default to true 448 | if (options.storeTasksInGit !== undefined) { 449 | storeGitPrompted = options.storeTasksInGit; // Use flag value if provided 450 | } else { 451 | const gitTasksInput = await promptQuestion( 452 | rl, 453 | chalk.cyan( 454 | 'Store tasks in Git (tasks.json and tasks/ directory)? (Y/n): ' 455 | ) 456 | ); 457 | storeGitPrompted = gitTasksInput.trim().toLowerCase() !== 'n'; 458 | } 459 | 460 | // Confirm settings... 461 | console.log('\nTask Master Project settings:'); 462 | console.log( 463 | chalk.blue( 464 | 'Add shell aliases (so you can use "tm" instead of "task-master"):' 465 | ), 466 | chalk.white(addAliasesPrompted ? 'Yes' : 'No') 467 | ); 468 | console.log( 469 | chalk.blue('Initialize Git repository in project root:'), 470 | chalk.white(initGitPrompted ? 'Yes' : 'No') 471 | ); 472 | console.log( 473 | chalk.blue('Store tasks in Git (tasks.json and tasks/ directory):'), 474 | chalk.white(storeGitPrompted ? 'Yes' : 'No') 475 | ); 476 | 477 | const confirmInput = await promptQuestion( 478 | rl, 479 | chalk.yellow('\nDo you want to continue with these settings? (Y/n): ') 480 | ); 481 | const shouldContinue = confirmInput.trim().toLowerCase() !== 'n'; 482 | 483 | if (!shouldContinue) { 484 | rl.close(); 485 | log('info', 'Project initialization cancelled by user'); 486 | process.exit(0); 487 | return; 488 | } 489 | 490 | // Only run interactive rules if rules flag not provided via command line 491 | if (options.rulesExplicitlyProvided) { 492 | log( 493 | 'info', 494 | `Using rule profiles provided via command line: ${selectedRuleProfiles.join(', ')}` 495 | ); 496 | } 497 | 498 | const dryRun = options.dryRun || false; 499 | 500 | if (dryRun) { 501 | log('info', 'DRY RUN MODE: No files will be modified'); 502 | log('info', 'Would initialize Task Master project'); 503 | log('info', 'Would create/update necessary project files'); 504 | 505 | // Show flag-specific behavior 506 | log( 507 | 'info', 508 | `${addAliasesPrompted ? 'Would add shell aliases (tm, taskmaster)' : 'Would skip shell aliases'}` 509 | ); 510 | log( 511 | 'info', 512 | `${initGitPrompted ? 'Would initialize Git repository' : 'Would skip Git initialization'}` 513 | ); 514 | log( 515 | 'info', 516 | `${storeGitPrompted ? 'Would store tasks in Git' : 'Would exclude tasks from Git'}` 517 | ); 518 | 519 | return { 520 | dryRun: true 521 | }; 522 | } 523 | 524 | // Create structure using only necessary values 525 | createProjectStructure( 526 | addAliasesPrompted, 527 | initGitPrompted, 528 | storeGitPrompted, 529 | dryRun, 530 | options, 531 | selectedRuleProfiles 532 | ); 533 | rl.close(); 534 | } catch (error) { 535 | if (rl) { 536 | rl.close(); 537 | } 538 | log('error', `Error during initialization process: ${error.message}`); 539 | process.exit(1); 540 | } 541 | } 542 | } 543 | 544 | // Helper function to promisify readline question 545 | function promptQuestion(rl, question) { 546 | return new Promise((resolve) => { 547 | rl.question(question, (answer) => { 548 | resolve(answer); 549 | }); 550 | }); 551 | } 552 | 553 | // Function to create the project structure 554 | function createProjectStructure( 555 | addAliases, 556 | initGit, 557 | storeTasksInGit, 558 | dryRun, 559 | options, 560 | selectedRuleProfiles = RULE_PROFILES 561 | ) { 562 | const targetDir = process.cwd(); 563 | log('info', `Initializing project in ${targetDir}`); 564 | 565 | // Create NEW .taskmaster directory structure (using constants) 566 | ensureDirectoryExists(path.join(targetDir, TASKMASTER_DIR)); 567 | ensureDirectoryExists(path.join(targetDir, TASKMASTER_TASKS_DIR)); 568 | ensureDirectoryExists(path.join(targetDir, TASKMASTER_DOCS_DIR)); 569 | ensureDirectoryExists(path.join(targetDir, TASKMASTER_REPORTS_DIR)); 570 | ensureDirectoryExists(path.join(targetDir, TASKMASTER_TEMPLATES_DIR)); 571 | 572 | // Create initial state.json file for tag management 573 | createInitialStateFile(targetDir); 574 | 575 | // Copy template files with replacements 576 | const replacements = { 577 | year: new Date().getFullYear() 578 | }; 579 | 580 | // Helper function to create rule profiles 581 | function _processSingleProfile(profileName) { 582 | const profile = getRulesProfile(profileName); 583 | if (profile) { 584 | convertAllRulesToProfileRules(targetDir, profile); 585 | // Also triggers MCP config setup (if applicable) 586 | } else { 587 | log('warn', `Unknown rule profile: ${profileName}`); 588 | } 589 | } 590 | 591 | // Copy .env.example 592 | copyTemplateFile( 593 | 'env.example', 594 | path.join(targetDir, ENV_EXAMPLE_FILE), 595 | replacements 596 | ); 597 | 598 | // Copy config.json with project name to NEW location 599 | copyTemplateFile( 600 | 'config.json', 601 | path.join(targetDir, TASKMASTER_CONFIG_FILE), 602 | { 603 | ...replacements 604 | } 605 | ); 606 | 607 | // Update config.json with correct maxTokens values from supported-models.json 608 | const configPath = path.join(targetDir, TASKMASTER_CONFIG_FILE); 609 | if (updateConfigMaxTokens(configPath)) { 610 | log('info', 'Updated config with correct maxTokens values'); 611 | } else { 612 | log('warn', 'Could not update maxTokens in config'); 613 | } 614 | 615 | // Copy .gitignore with GitTasks preference 616 | try { 617 | const templateContent = readAsset('gitignore', 'utf8'); 618 | manageGitignoreFile( 619 | path.join(targetDir, GITIGNORE_FILE), 620 | templateContent, 621 | storeTasksInGit, 622 | log 623 | ); 624 | } catch (error) { 625 | log('error', `Failed to create .gitignore: ${error.message}`); 626 | } 627 | 628 | // Copy example_prd.txt to NEW location 629 | copyTemplateFile('example_prd.txt', path.join(targetDir, EXAMPLE_PRD_FILE)); 630 | 631 | // Initialize git repository if git is available 632 | try { 633 | if (initGit === false) { 634 | log('info', 'Git initialization skipped due to --no-git flag.'); 635 | } else if (initGit === true) { 636 | if (insideGitWorkTree()) { 637 | log( 638 | 'info', 639 | 'Existing Git repository detected – skipping git init despite --git flag.' 640 | ); 641 | } else { 642 | log('info', 'Initializing Git repository due to --git flag...'); 643 | execSync('git init', { cwd: targetDir, stdio: 'ignore' }); 644 | log('success', 'Git repository initialized'); 645 | } 646 | } else { 647 | // Default behavior when no flag is provided (from interactive prompt) 648 | if (insideGitWorkTree()) { 649 | log('info', 'Existing Git repository detected – skipping git init.'); 650 | } else { 651 | log( 652 | 'info', 653 | 'No Git repository detected. Initializing one in project root...' 654 | ); 655 | execSync('git init', { cwd: targetDir, stdio: 'ignore' }); 656 | log('success', 'Git repository initialized'); 657 | } 658 | } 659 | } catch (error) { 660 | log('warn', 'Git not available, skipping repository initialization'); 661 | } 662 | 663 | // Only run the manual transformer if rules were provided via flags. 664 | // The interactive `rules --setup` wizard handles its own installation. 665 | if (options.rulesExplicitlyProvided || options.yes) { 666 | log('info', 'Generating profile rules from command-line flags...'); 667 | for (const profileName of selectedRuleProfiles) { 668 | _processSingleProfile(profileName); 669 | } 670 | } 671 | 672 | // Add shell aliases if requested 673 | if (addAliases) { 674 | addShellAliases(); 675 | } 676 | 677 | // Run npm install automatically 678 | const npmInstallOptions = { 679 | cwd: targetDir, 680 | // Default to inherit for interactive CLI, change if silent 681 | stdio: 'inherit' 682 | }; 683 | 684 | if (isSilentMode()) { 685 | // If silent (MCP mode), suppress npm install output 686 | npmInstallOptions.stdio = 'ignore'; 687 | log('info', 'Running npm install silently...'); // Log our own message 688 | } else { 689 | // Interactive mode, show the boxen message 690 | console.log( 691 | boxen(chalk.cyan('Installing dependencies...'), { 692 | padding: 0.5, 693 | margin: 0.5, 694 | borderStyle: 'round', 695 | borderColor: 'blue' 696 | }) 697 | ); 698 | } 699 | 700 | // === Add Rule Profiles Setup Step === 701 | if ( 702 | !isSilentMode() && 703 | !dryRun && 704 | !options?.yes && 705 | !options.rulesExplicitlyProvided 706 | ) { 707 | console.log( 708 | boxen(chalk.cyan('Configuring Rule Profiles...'), { 709 | padding: 0.5, 710 | margin: { top: 1, bottom: 0.5 }, 711 | borderStyle: 'round', 712 | borderColor: 'blue' 713 | }) 714 | ); 715 | log( 716 | 'info', 717 | 'Running interactive rules setup. Please select which rule profiles to include.' 718 | ); 719 | try { 720 | // Correct command confirmed by you. 721 | execSync('npx task-master rules --setup', { 722 | stdio: 'inherit', 723 | cwd: targetDir 724 | }); 725 | log('success', 'Rule profiles configured.'); 726 | } catch (error) { 727 | log('error', 'Failed to configure rule profiles:', error.message); 728 | log('warn', 'You may need to run "task-master rules --setup" manually.'); 729 | } 730 | } else if (isSilentMode() || dryRun || options?.yes) { 731 | // This branch can log why setup was skipped, similar to the model setup logic. 732 | if (options.rulesExplicitlyProvided) { 733 | log( 734 | 'info', 735 | 'Skipping interactive rules setup because --rules flag was used.' 736 | ); 737 | } else { 738 | log('info', 'Skipping interactive rules setup in non-interactive mode.'); 739 | } 740 | } 741 | // ===================================== 742 | 743 | // === Add Response Language Step === 744 | if (!isSilentMode() && !dryRun && !options?.yes) { 745 | console.log( 746 | boxen(chalk.cyan('Configuring Response Language...'), { 747 | padding: 0.5, 748 | margin: { top: 1, bottom: 0.5 }, 749 | borderStyle: 'round', 750 | borderColor: 'blue' 751 | }) 752 | ); 753 | log( 754 | 'info', 755 | 'Running interactive response language setup. Please input your preferred language.' 756 | ); 757 | try { 758 | execSync('npx task-master lang --setup', { 759 | stdio: 'inherit', 760 | cwd: targetDir 761 | }); 762 | log('success', 'Response Language configured.'); 763 | } catch (error) { 764 | log('error', 'Failed to configure response language:', error.message); 765 | log('warn', 'You may need to run "task-master lang --setup" manually.'); 766 | } 767 | } else if (isSilentMode() && !dryRun) { 768 | log( 769 | 'info', 770 | 'Skipping interactive response language setup in silent (MCP) mode.' 771 | ); 772 | log( 773 | 'warn', 774 | 'Please configure response language using "task-master models --set-response-language" or the "models" MCP tool.' 775 | ); 776 | } else if (dryRun) { 777 | log('info', 'DRY RUN: Skipping interactive response language setup.'); 778 | } 779 | // ===================================== 780 | 781 | // === Add Model Configuration Step === 782 | if (!isSilentMode() && !dryRun && !options?.yes) { 783 | console.log( 784 | boxen(chalk.cyan('Configuring AI Models...'), { 785 | padding: 0.5, 786 | margin: { top: 1, bottom: 0.5 }, 787 | borderStyle: 'round', 788 | borderColor: 'blue' 789 | }) 790 | ); 791 | log( 792 | 'info', 793 | 'Running interactive model setup. Please select your preferred AI models.' 794 | ); 795 | try { 796 | execSync('npx task-master models --setup', { 797 | stdio: 'inherit', 798 | cwd: targetDir 799 | }); 800 | log('success', 'AI Models configured.'); 801 | } catch (error) { 802 | log('error', 'Failed to configure AI models:', error.message); 803 | log('warn', 'You may need to run "task-master models --setup" manually.'); 804 | } 805 | } else if (isSilentMode() && !dryRun) { 806 | log('info', 'Skipping interactive model setup in silent (MCP) mode.'); 807 | log( 808 | 'warn', 809 | 'Please configure AI models using "task-master models --set-..." or the "models" MCP tool.' 810 | ); 811 | } else if (dryRun) { 812 | log('info', 'DRY RUN: Skipping interactive model setup.'); 813 | } else if (options?.yes) { 814 | log('info', 'Skipping interactive model setup due to --yes flag.'); 815 | log( 816 | 'info', 817 | 'Default AI models will be used. You can configure different models later using "task-master models --setup" or "task-master models --set-..." commands.' 818 | ); 819 | } 820 | // ==================================== 821 | 822 | // Add shell aliases if requested 823 | if (addAliases && !dryRun) { 824 | log('info', 'Adding shell aliases...'); 825 | const aliasResult = addShellAliases(); 826 | if (aliasResult) { 827 | log('success', 'Shell aliases added successfully'); 828 | } 829 | } else if (addAliases && dryRun) { 830 | log('info', 'DRY RUN: Would add shell aliases (tm, taskmaster)'); 831 | } 832 | 833 | // Display success message 834 | if (!isSilentMode()) { 835 | console.log( 836 | boxen( 837 | `${warmGradient.multiline( 838 | figlet.textSync('Success!', { font: 'Standard' }) 839 | )}\n${chalk.green('Project initialized successfully!')}`, 840 | { 841 | padding: 1, 842 | margin: 1, 843 | borderStyle: 'double', 844 | borderColor: 'green' 845 | } 846 | ) 847 | ); 848 | } 849 | 850 | // Display next steps in a nice box 851 | if (!isSilentMode()) { 852 | console.log( 853 | boxen( 854 | `${chalk.cyan.bold('Things you should do next:')}\n\n${chalk.white('1. ')}${chalk.yellow( 855 | 'Configure AI models (if needed) and add API keys to `.env`' 856 | )}\n${chalk.white(' ├─ ')}${chalk.dim('Models: Use `task-master models` commands')}\n${chalk.white(' └─ ')}${chalk.dim( 857 | 'Keys: Add provider API keys to .env (or inside the MCP config file i.e. .cursor/mcp.json)' 858 | )}\n${chalk.white('2. ')}${chalk.yellow( 859 | 'Discuss your idea with AI and ask for a PRD using example_prd.txt, and save it to scripts/PRD.txt' 860 | )}\n${chalk.white('3. ')}${chalk.yellow( 861 | 'Ask Cursor Agent (or run CLI) to parse your PRD and generate initial tasks:' 862 | )}\n${chalk.white(' └─ ')}${chalk.dim('MCP Tool: ')}${chalk.cyan('parse_prd')}${chalk.dim(' | CLI: ')}${chalk.cyan('task-master parse-prd scripts/prd.txt')}\n${chalk.white('4. ')}${chalk.yellow( 863 | 'Ask Cursor to analyze the complexity of the tasks in your PRD using research' 864 | )}\n${chalk.white(' └─ ')}${chalk.dim('MCP Tool: ')}${chalk.cyan('analyze_project_complexity')}${chalk.dim(' | CLI: ')}${chalk.cyan('task-master analyze-complexity')}\n${chalk.white('5. ')}${chalk.yellow( 865 | 'Ask Cursor to expand all of your tasks using the complexity analysis' 866 | )}\n${chalk.white('6. ')}${chalk.yellow('Ask Cursor to begin working on the next task')}\n${chalk.white('7. ')}${chalk.yellow( 867 | 'Add new tasks anytime using the add-task command or MCP tool' 868 | )}\n${chalk.white('8. ')}${chalk.yellow( 869 | 'Ask Cursor to set the status of one or many tasks/subtasks at a time. Use the task id from the task lists.' 870 | )}\n${chalk.white('9. ')}${chalk.yellow( 871 | 'Ask Cursor to update all tasks from a specific task id based on new learnings or pivots in your project.' 872 | )}\n${chalk.white('10. ')}${chalk.green.bold('Ship it!')}\n\n${chalk.dim( 873 | '* Review the README.md file to learn how to use other commands via Cursor Agent.' 874 | )}\n${chalk.dim( 875 | '* Use the task-master command without arguments to see all available commands.' 876 | )}`, 877 | { 878 | padding: 1, 879 | margin: 1, 880 | borderStyle: 'round', 881 | borderColor: 'yellow', 882 | title: 'Getting Started', 883 | titleAlignment: 'center' 884 | } 885 | ) 886 | ); 887 | } 888 | } 889 | 890 | // Ensure necessary functions are exported 891 | export { initializeProject, log }; 892 | ```