#
tokens: 48126/50000 4/821 files (page 38/52)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 38/52FirstPrevNextLast