This is page 49 of 69. 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
│ ├── commands
│ │ └── dedupe.md
│ └── TM_COMMANDS_GUIDE.md
├── .claude-plugin
│ └── marketplace.json
├── .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
│ │ └── validate-changesets.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
│ │ ├── autonomous-tdd-git-workflow.md
│ │ ├── 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
│ │ ├── tdd-workflow-phase-0-spike.md
│ │ ├── tdd-workflow-phase-1-core-rails.md
│ │ ├── tdd-workflow-phase-1-orchestrator.md
│ │ ├── tdd-workflow-phase-2-pr-resumability.md
│ │ ├── tdd-workflow-phase-3-extensibility-guardrails.md
│ │ ├── test-prd.txt
│ │ └── tm-core-phase-1.txt
│ ├── reports
│ │ ├── task-complexity-report_autonomous-tdd-git-workflow.json
│ │ ├── task-complexity-report_cc-kiro-hooks.json
│ │ ├── task-complexity-report_tdd-phase-1-core-rails.json
│ │ ├── task-complexity-report_tdd-workflow-phase-0.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_rpg.md
│ └── example_prd.md
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── apps
│ ├── cli
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── command-registry.ts
│ │ │ ├── commands
│ │ │ │ ├── auth.command.ts
│ │ │ │ ├── autopilot
│ │ │ │ │ ├── abort.command.ts
│ │ │ │ │ ├── commit.command.ts
│ │ │ │ │ ├── complete.command.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── next.command.ts
│ │ │ │ │ ├── resume.command.ts
│ │ │ │ │ ├── shared.ts
│ │ │ │ │ ├── start.command.ts
│ │ │ │ │ └── status.command.ts
│ │ │ │ ├── briefs.command.ts
│ │ │ │ ├── context.command.ts
│ │ │ │ ├── export.command.ts
│ │ │ │ ├── list.command.ts
│ │ │ │ ├── models
│ │ │ │ │ ├── custom-providers.ts
│ │ │ │ │ ├── fetchers.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── prompts.ts
│ │ │ │ │ ├── setup.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── next.command.ts
│ │ │ │ ├── set-status.command.ts
│ │ │ │ ├── show.command.ts
│ │ │ │ ├── start.command.ts
│ │ │ │ └── tags.command.ts
│ │ │ ├── index.ts
│ │ │ ├── lib
│ │ │ │ └── model-management.ts
│ │ │ ├── types
│ │ │ │ └── tag-management.d.ts
│ │ │ ├── ui
│ │ │ │ ├── components
│ │ │ │ │ ├── cardBox.component.ts
│ │ │ │ │ ├── dashboard.component.ts
│ │ │ │ │ ├── header.component.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── next-task.component.ts
│ │ │ │ │ ├── suggested-steps.component.ts
│ │ │ │ │ └── task-detail.component.ts
│ │ │ │ ├── display
│ │ │ │ │ ├── messages.ts
│ │ │ │ │ └── tables.ts
│ │ │ │ ├── formatters
│ │ │ │ │ ├── complexity-formatters.ts
│ │ │ │ │ ├── dependency-formatters.ts
│ │ │ │ │ ├── priority-formatters.ts
│ │ │ │ │ ├── status-formatters.spec.ts
│ │ │ │ │ └── status-formatters.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── layout
│ │ │ │ ├── helpers.spec.ts
│ │ │ │ └── helpers.ts
│ │ │ └── utils
│ │ │ ├── auth-helpers.ts
│ │ │ ├── auto-update.ts
│ │ │ ├── brief-selection.ts
│ │ │ ├── display-helpers.ts
│ │ │ ├── error-handler.ts
│ │ │ ├── index.ts
│ │ │ ├── project-root.ts
│ │ │ ├── task-status.ts
│ │ │ ├── ui.spec.ts
│ │ │ └── ui.ts
│ │ ├── tests
│ │ │ ├── integration
│ │ │ │ └── commands
│ │ │ │ └── autopilot
│ │ │ │ └── workflow.test.ts
│ │ │ └── unit
│ │ │ ├── commands
│ │ │ │ ├── autopilot
│ │ │ │ │ └── shared.test.ts
│ │ │ │ ├── list.command.spec.ts
│ │ │ │ └── show.command.spec.ts
│ │ │ └── ui
│ │ │ └── dashboard.component.spec.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── 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
│ │ │ ├── rpg-method.mdx
│ │ │ └── task-structure.mdx
│ │ ├── CHANGELOG.md
│ │ ├── command-reference.mdx
│ │ ├── configuration.mdx
│ │ ├── docs.json
│ │ ├── favicon.svg
│ │ ├── getting-started
│ │ │ ├── api-keys.mdx
│ │ │ ├── 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
│ │ ├── tdd-workflow
│ │ │ ├── ai-agent-integration.mdx
│ │ │ └── quickstart.mdx
│ │ ├── 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
│ └── mcp
│ ├── CHANGELOG.md
│ ├── package.json
│ ├── src
│ │ ├── index.ts
│ │ ├── shared
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ └── tools
│ │ ├── autopilot
│ │ │ ├── abort.tool.ts
│ │ │ ├── commit.tool.ts
│ │ │ ├── complete.tool.ts
│ │ │ ├── finalize.tool.ts
│ │ │ ├── index.ts
│ │ │ ├── next.tool.ts
│ │ │ ├── resume.tool.ts
│ │ │ ├── start.tool.ts
│ │ │ └── status.tool.ts
│ │ ├── README-ZOD-V3.md
│ │ └── tasks
│ │ ├── get-task.tool.ts
│ │ ├── get-tasks.tool.ts
│ │ └── index.ts
│ ├── tsconfig.json
│ └── vitest.config.ts
├── assets
│ ├── .windsurfrules
│ ├── AGENTS.md
│ ├── claude
│ │ └── TM_COMMANDS_GUIDE.md
│ ├── config.json
│ ├── env.example
│ ├── example_prd_rpg.txt
│ ├── example_prd.txt
│ ├── GEMINI.md
│ ├── 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_CODE_PLUGIN.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
│ ├── claude-code-integration.md
│ ├── CLI-COMMANDER-PATTERN.md
│ ├── command-reference.md
│ ├── configuration.md
│ ├── contributor-docs
│ │ ├── testing-roo-integration.md
│ │ └── worktree-setup.md
│ ├── cross-tag-task-movement.md
│ ├── examples
│ │ ├── claude-code-usage.md
│ │ └── codex-cli-usage.md
│ ├── examples.md
│ ├── licensing.md
│ ├── mcp-provider-guide.md
│ ├── mcp-provider.md
│ ├── migration-guide.md
│ ├── models.md
│ ├── providers
│ │ ├── codex-cli.md
│ │ └── gemini-cli.md
│ ├── README.md
│ ├── scripts
│ │ └── models-json-to-markdown.js
│ ├── task-structure.md
│ └── tutorial.md
├── images
│ ├── hamster-hiring.png
│ └── 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
│ │ │ ├── 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
│ │ │ ├── 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
│ ├── index.js
│ ├── initialize-project.js
│ ├── list-tags.js
│ ├── models.js
│ ├── move-task.js
│ ├── next-task.js
│ ├── parse-prd.js
│ ├── README-ZOD-V3.md
│ ├── 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
│ ├── tool-registry.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
│ ├── ai-sdk-provider-grok-cli
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── errors.test.ts
│ │ │ ├── errors.ts
│ │ │ ├── grok-cli-language-model.ts
│ │ │ ├── grok-cli-provider.test.ts
│ │ │ ├── grok-cli-provider.ts
│ │ │ ├── index.ts
│ │ │ ├── json-extractor.test.ts
│ │ │ ├── json-extractor.ts
│ │ │ ├── message-converter.test.ts
│ │ │ ├── message-converter.ts
│ │ │ └── types.ts
│ │ └── tsconfig.json
│ ├── build-config
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── src
│ │ │ └── tsdown.base.ts
│ │ └── tsconfig.json
│ ├── claude-code-plugin
│ │ ├── .claude-plugin
│ │ │ └── plugin.json
│ │ ├── .gitignore
│ │ ├── agents
│ │ │ ├── task-checker.md
│ │ │ ├── task-executor.md
│ │ │ └── task-orchestrator.md
│ │ ├── CHANGELOG.md
│ │ ├── commands
│ │ │ ├── add-dependency.md
│ │ │ ├── add-subtask.md
│ │ │ ├── add-task.md
│ │ │ ├── analyze-complexity.md
│ │ │ ├── analyze-project.md
│ │ │ ├── auto-implement-tasks.md
│ │ │ ├── command-pipeline.md
│ │ │ ├── complexity-report.md
│ │ │ ├── convert-task-to-subtask.md
│ │ │ ├── expand-all-tasks.md
│ │ │ ├── expand-task.md
│ │ │ ├── fix-dependencies.md
│ │ │ ├── generate-tasks.md
│ │ │ ├── help.md
│ │ │ ├── init-project-quick.md
│ │ │ ├── init-project.md
│ │ │ ├── install-taskmaster.md
│ │ │ ├── learn.md
│ │ │ ├── list-tasks-by-status.md
│ │ │ ├── list-tasks-with-subtasks.md
│ │ │ ├── list-tasks.md
│ │ │ ├── next-task.md
│ │ │ ├── parse-prd-with-research.md
│ │ │ ├── parse-prd.md
│ │ │ ├── project-status.md
│ │ │ ├── quick-install-taskmaster.md
│ │ │ ├── remove-all-subtasks.md
│ │ │ ├── remove-dependency.md
│ │ │ ├── remove-subtask.md
│ │ │ ├── remove-subtasks.md
│ │ │ ├── remove-task.md
│ │ │ ├── setup-models.md
│ │ │ ├── show-task.md
│ │ │ ├── smart-workflow.md
│ │ │ ├── sync-readme.md
│ │ │ ├── tm-main.md
│ │ │ ├── to-cancelled.md
│ │ │ ├── to-deferred.md
│ │ │ ├── to-done.md
│ │ │ ├── to-in-progress.md
│ │ │ ├── to-pending.md
│ │ │ ├── to-review.md
│ │ │ ├── update-single-task.md
│ │ │ ├── update-task.md
│ │ │ ├── update-tasks-from-id.md
│ │ │ ├── validate-dependencies.md
│ │ │ └── view-models.md
│ │ ├── mcp.json
│ │ └── package.json
│ ├── tm-bridge
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── add-tag-bridge.ts
│ │ │ ├── bridge-types.ts
│ │ │ ├── bridge-utils.ts
│ │ │ ├── expand-bridge.ts
│ │ │ ├── index.ts
│ │ │ ├── tags-bridge.ts
│ │ │ ├── update-bridge.ts
│ │ │ └── use-tag-bridge.ts
│ │ └── tsconfig.json
│ └── tm-core
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── docs
│ │ └── listTasks-architecture.md
│ ├── package.json
│ ├── POC-STATUS.md
│ ├── README.md
│ ├── src
│ │ ├── common
│ │ │ ├── constants
│ │ │ │ ├── index.ts
│ │ │ │ ├── paths.ts
│ │ │ │ └── providers.ts
│ │ │ ├── errors
│ │ │ │ ├── index.ts
│ │ │ │ └── task-master-error.ts
│ │ │ ├── interfaces
│ │ │ │ ├── configuration.interface.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── storage.interface.ts
│ │ │ ├── logger
│ │ │ │ ├── factory.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── logger.spec.ts
│ │ │ │ └── logger.ts
│ │ │ ├── mappers
│ │ │ │ ├── TaskMapper.test.ts
│ │ │ │ └── TaskMapper.ts
│ │ │ ├── types
│ │ │ │ ├── database.types.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── legacy.ts
│ │ │ │ └── repository-types.ts
│ │ │ └── utils
│ │ │ ├── git-utils.ts
│ │ │ ├── id-generator.ts
│ │ │ ├── index.ts
│ │ │ ├── path-helpers.ts
│ │ │ ├── path-normalizer.spec.ts
│ │ │ ├── path-normalizer.ts
│ │ │ ├── project-root-finder.spec.ts
│ │ │ ├── project-root-finder.ts
│ │ │ ├── run-id-generator.spec.ts
│ │ │ └── run-id-generator.ts
│ │ ├── index.ts
│ │ ├── modules
│ │ │ ├── ai
│ │ │ │ ├── index.ts
│ │ │ │ ├── interfaces
│ │ │ │ │ └── ai-provider.interface.ts
│ │ │ │ └── providers
│ │ │ │ ├── base-provider.ts
│ │ │ │ └── index.ts
│ │ │ ├── auth
│ │ │ │ ├── auth-domain.spec.ts
│ │ │ │ ├── auth-domain.ts
│ │ │ │ ├── config.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── managers
│ │ │ │ │ ├── auth-manager.spec.ts
│ │ │ │ │ └── auth-manager.ts
│ │ │ │ ├── services
│ │ │ │ │ ├── context-store.ts
│ │ │ │ │ ├── oauth-service.ts
│ │ │ │ │ ├── organization.service.ts
│ │ │ │ │ ├── supabase-session-storage.spec.ts
│ │ │ │ │ └── supabase-session-storage.ts
│ │ │ │ └── types.ts
│ │ │ ├── briefs
│ │ │ │ ├── briefs-domain.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── services
│ │ │ │ │ └── brief-service.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── utils
│ │ │ │ └── url-parser.ts
│ │ │ ├── commands
│ │ │ │ └── index.ts
│ │ │ ├── config
│ │ │ │ ├── config-domain.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── managers
│ │ │ │ │ ├── config-manager.spec.ts
│ │ │ │ │ └── config-manager.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
│ │ │ ├── dependencies
│ │ │ │ └── index.ts
│ │ │ ├── execution
│ │ │ │ ├── executors
│ │ │ │ │ ├── base-executor.ts
│ │ │ │ │ ├── claude-executor.ts
│ │ │ │ │ └── executor-factory.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── services
│ │ │ │ │ └── executor-service.ts
│ │ │ │ └── types.ts
│ │ │ ├── git
│ │ │ │ ├── adapters
│ │ │ │ │ ├── git-adapter.test.ts
│ │ │ │ │ └── git-adapter.ts
│ │ │ │ ├── git-domain.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── services
│ │ │ │ ├── branch-name-generator.spec.ts
│ │ │ │ ├── branch-name-generator.ts
│ │ │ │ ├── commit-message-generator.test.ts
│ │ │ │ ├── commit-message-generator.ts
│ │ │ │ ├── scope-detector.test.ts
│ │ │ │ ├── scope-detector.ts
│ │ │ │ ├── template-engine.test.ts
│ │ │ │ └── template-engine.ts
│ │ │ ├── integration
│ │ │ │ ├── clients
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── supabase-client.ts
│ │ │ │ ├── integration-domain.ts
│ │ │ │ └── services
│ │ │ │ ├── export.service.ts
│ │ │ │ ├── task-expansion.service.ts
│ │ │ │ └── task-retrieval.service.ts
│ │ │ ├── reports
│ │ │ │ ├── index.ts
│ │ │ │ ├── managers
│ │ │ │ │ └── complexity-report-manager.ts
│ │ │ │ └── types.ts
│ │ │ ├── storage
│ │ │ │ ├── adapters
│ │ │ │ │ ├── activity-logger.ts
│ │ │ │ │ ├── api-storage.ts
│ │ │ │ │ └── file-storage
│ │ │ │ │ ├── file-operations.ts
│ │ │ │ │ ├── file-storage.ts
│ │ │ │ │ ├── format-handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── path-resolver.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── services
│ │ │ │ │ └── storage-factory.ts
│ │ │ │ └── utils
│ │ │ │ └── api-client.ts
│ │ │ ├── tasks
│ │ │ │ ├── entities
│ │ │ │ │ └── task.entity.ts
│ │ │ │ ├── parser
│ │ │ │ │ └── index.ts
│ │ │ │ ├── repositories
│ │ │ │ │ ├── supabase
│ │ │ │ │ │ ├── dependency-fetcher.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── supabase-repository.ts
│ │ │ │ │ └── task-repository.interface.ts
│ │ │ │ ├── services
│ │ │ │ │ ├── preflight-checker.service.ts
│ │ │ │ │ ├── tag.service.ts
│ │ │ │ │ ├── task-execution-service.ts
│ │ │ │ │ ├── task-loader.service.ts
│ │ │ │ │ └── task-service.ts
│ │ │ │ └── tasks-domain.ts
│ │ │ ├── ui
│ │ │ │ └── index.ts
│ │ │ └── workflow
│ │ │ ├── managers
│ │ │ │ ├── workflow-state-manager.spec.ts
│ │ │ │ └── workflow-state-manager.ts
│ │ │ ├── orchestrators
│ │ │ │ ├── workflow-orchestrator.test.ts
│ │ │ │ └── workflow-orchestrator.ts
│ │ │ ├── services
│ │ │ │ ├── test-result-validator.test.ts
│ │ │ │ ├── test-result-validator.ts
│ │ │ │ ├── test-result-validator.types.ts
│ │ │ │ ├── workflow-activity-logger.ts
│ │ │ │ └── workflow.service.ts
│ │ │ ├── types.ts
│ │ │ └── workflow-domain.ts
│ │ ├── subpath-exports.test.ts
│ │ ├── tm-core.ts
│ │ └── utils
│ │ └── time.utils.ts
│ ├── tests
│ │ ├── auth
│ │ │ └── auth-refresh.test.ts
│ │ ├── integration
│ │ │ ├── auth-token-refresh.test.ts
│ │ │ ├── list-tasks.test.ts
│ │ │ └── storage
│ │ │ └── activity-logger.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
│ ├── create-worktree.sh
│ ├── dev.js
│ ├── init.js
│ ├── list-worktrees.sh
│ ├── modules
│ │ ├── ai-services-unified.js
│ │ ├── bridge-utils.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
├── sonar-project.properties
├── src
│ ├── ai-providers
│ │ ├── anthropic.js
│ │ ├── azure.js
│ │ ├── base-provider.js
│ │ ├── bedrock.js
│ │ ├── claude-code.js
│ │ ├── codex-cli.js
│ │ ├── gemini-cli.js
│ │ ├── google-vertex.js
│ │ ├── google.js
│ │ ├── grok-cli.js
│ │ ├── groq.js
│ │ ├── index.js
│ │ ├── lmstudio.js
│ │ ├── ollama.js
│ │ ├── openai-compatible.js
│ │ ├── openai.js
│ │ ├── openrouter.js
│ │ ├── perplexity.js
│ │ ├── xai.js
│ │ ├── zai-coding.js
│ │ └── zai.js
│ ├── constants
│ │ ├── commands.js
│ │ ├── paths.js
│ │ ├── profiles.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
│ ├── schemas
│ │ ├── add-task.js
│ │ ├── analyze-complexity.js
│ │ ├── base-schemas.js
│ │ ├── expand-task.js
│ │ ├── parse-prd.js
│ │ ├── registry.js
│ │ ├── update-subtask.js
│ │ ├── update-task.js
│ │ └── update-tasks.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
│ ├── fixtures
│ │ ├── .taskmasterconfig
│ │ ├── sample-claude-response.js
│ │ ├── sample-prd.txt
│ │ └── sample-tasks.js
│ ├── helpers
│ │ └── tool-counts.js
│ ├── integration
│ │ ├── claude-code-error-handling.test.js
│ │ ├── 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
│ │ └── providers
│ │ └── temperature-support.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
│ │ ├── base-provider.test.js
│ │ ├── claude-code.test.js
│ │ ├── codex-cli.test.js
│ │ ├── gemini-cli.test.js
│ │ ├── lmstudio.test.js
│ │ ├── mcp-components.test.js
│ │ ├── openai-compatible.test.js
│ │ ├── openai.test.js
│ │ ├── provider-registry.test.js
│ │ ├── zai-coding.test.js
│ │ ├── zai-provider.test.js
│ │ ├── zai-schema-introspection.test.js
│ │ └── zai.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
│ │ └── tool-registration.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
│ │ └── prompt-migration.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
│ │ │ ├── models-baseurl.test.js
│ │ │ ├── move-task-cross-tag.test.js
│ │ │ ├── move-task.test.js
│ │ │ ├── parse-prd-schema.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
└── update-task-migration-plan.md
```
# Files
--------------------------------------------------------------------------------
/scripts/modules/utils/contextGatherer.js:
--------------------------------------------------------------------------------
```javascript
1 | /**
2 | * contextGatherer.js
3 | * Comprehensive context gathering utility for Task Master AI operations
4 | * Supports task context, file context, project tree, and custom context
5 | */
6 |
7 | import fs from 'fs';
8 | import path from 'path';
9 | import pkg from 'gpt-tokens';
10 | import Fuse from 'fuse.js';
11 | import {
12 | readJSON,
13 | findTaskById,
14 | truncate,
15 | flattenTasksWithSubtasks
16 | } from '../utils.js';
17 |
18 | const { encode } = pkg;
19 |
20 | /**
21 | * Context Gatherer class for collecting and formatting context from various sources
22 | */
23 | export class ContextGatherer {
24 | constructor(projectRoot, tag) {
25 | this.projectRoot = projectRoot;
26 | this.tasksPath = path.join(
27 | projectRoot,
28 | '.taskmaster',
29 | 'tasks',
30 | 'tasks.json'
31 | );
32 | this.tag = tag;
33 | this.allTasks = this._loadAllTasks();
34 | }
35 |
36 | _loadAllTasks() {
37 | try {
38 | const data = readJSON(this.tasksPath, this.projectRoot, this.tag);
39 | const tasks = data?.tasks || [];
40 | return tasks;
41 | } catch (error) {
42 | console.warn(
43 | `Warning: Could not load tasks for ContextGatherer: ${error.message}`
44 | );
45 | return [];
46 | }
47 | }
48 |
49 | /**
50 | * Count tokens in a text string using gpt-tokens
51 | * @param {string} text - Text to count tokens for
52 | * @returns {number} Token count
53 | */
54 | countTokens(text) {
55 | if (!text || typeof text !== 'string') {
56 | return 0;
57 | }
58 | try {
59 | return encode(text).length;
60 | } catch (error) {
61 | // Fallback to rough character-based estimation if tokenizer fails
62 | // Rough estimate: ~4 characters per token for English text
63 | return Math.ceil(text.length / 4);
64 | }
65 | }
66 |
67 | /**
68 | * Main method to gather context from multiple sources
69 | * @param {Object} options - Context gathering options
70 | * @param {Array<string>} [options.tasks] - Task/subtask IDs to include
71 | * @param {Array<string>} [options.files] - File paths to include
72 | * @param {string} [options.customContext] - Additional custom context
73 | * @param {boolean} [options.includeProjectTree] - Include project file tree
74 | * @param {string} [options.format] - Output format: 'research', 'chat', 'system-prompt'
75 | * @param {boolean} [options.includeTokenCounts] - Whether to include token breakdown
76 | * @param {string} [options.semanticQuery] - A query string for semantic task searching.
77 | * @param {number} [options.maxSemanticResults] - Max number of semantic results.
78 | * @param {Array<number>} [options.dependencyTasks] - Array of task IDs to build dependency graphs from.
79 | * @returns {Promise<Object>} Object with context string and analysis data
80 | */
81 | async gather(options = {}) {
82 | const {
83 | tasks = [],
84 | files = [],
85 | customContext = '',
86 | includeProjectTree = false,
87 | format = 'research',
88 | includeTokenCounts = false,
89 | semanticQuery,
90 | maxSemanticResults = 10,
91 | dependencyTasks = []
92 | } = options;
93 |
94 | const contextSections = [];
95 | const finalTaskIds = new Set(tasks.map(String));
96 | let analysisData = null;
97 | let tokenBreakdown = null;
98 |
99 | // Initialize token breakdown if requested
100 | if (includeTokenCounts) {
101 | tokenBreakdown = {
102 | total: 0,
103 | customContext: null,
104 | tasks: [],
105 | files: [],
106 | projectTree: null
107 | };
108 | }
109 |
110 | // Semantic Search
111 | if (semanticQuery && this.allTasks.length > 0) {
112 | const semanticResults = this._performSemanticSearch(
113 | semanticQuery,
114 | maxSemanticResults
115 | );
116 |
117 | // Store the analysis data for UI display
118 | analysisData = semanticResults.analysisData;
119 |
120 | semanticResults.tasks.forEach((task) => {
121 | finalTaskIds.add(String(task.id));
122 | });
123 | }
124 |
125 | // Dependency Graph Analysis
126 | if (dependencyTasks.length > 0) {
127 | const dependencyResults = this._buildDependencyGraphs(dependencyTasks);
128 | dependencyResults.allRelatedTaskIds.forEach((id) =>
129 | finalTaskIds.add(String(id))
130 | );
131 | // We can format and add dependencyResults.graphVisualization later if needed
132 | }
133 |
134 | // Add custom context first
135 | if (customContext && customContext.trim()) {
136 | const formattedCustomContext = this._formatCustomContext(
137 | customContext,
138 | format
139 | );
140 | contextSections.push(formattedCustomContext);
141 |
142 | // Calculate tokens for custom context if requested
143 | if (includeTokenCounts) {
144 | tokenBreakdown.customContext = {
145 | tokens: this.countTokens(formattedCustomContext),
146 | characters: formattedCustomContext.length
147 | };
148 | tokenBreakdown.total += tokenBreakdown.customContext.tokens;
149 | }
150 | }
151 |
152 | // Gather context for the final list of tasks
153 | if (finalTaskIds.size > 0) {
154 | const taskContextResult = await this._gatherTaskContext(
155 | Array.from(finalTaskIds),
156 | format,
157 | includeTokenCounts
158 | );
159 | if (taskContextResult.context) {
160 | contextSections.push(taskContextResult.context);
161 |
162 | // Add task breakdown if token counting is enabled
163 | if (includeTokenCounts && taskContextResult.breakdown) {
164 | tokenBreakdown.tasks = taskContextResult.breakdown;
165 | const taskTokens = taskContextResult.breakdown.reduce(
166 | (sum, task) => sum + task.tokens,
167 | 0
168 | );
169 | tokenBreakdown.total += taskTokens;
170 | }
171 | }
172 | }
173 |
174 | // Add file context
175 | if (files.length > 0) {
176 | const fileContextResult = await this._gatherFileContext(
177 | files,
178 | format,
179 | includeTokenCounts
180 | );
181 | if (fileContextResult.context) {
182 | contextSections.push(fileContextResult.context);
183 |
184 | // Add file breakdown if token counting is enabled
185 | if (includeTokenCounts && fileContextResult.breakdown) {
186 | tokenBreakdown.files = fileContextResult.breakdown;
187 | const fileTokens = fileContextResult.breakdown.reduce(
188 | (sum, file) => sum + file.tokens,
189 | 0
190 | );
191 | tokenBreakdown.total += fileTokens;
192 | }
193 | }
194 | }
195 |
196 | // Add project tree context
197 | if (includeProjectTree) {
198 | const treeContextResult = await this._gatherProjectTreeContext(
199 | format,
200 | includeTokenCounts
201 | );
202 | if (treeContextResult.context) {
203 | contextSections.push(treeContextResult.context);
204 |
205 | // Add tree breakdown if token counting is enabled
206 | if (includeTokenCounts && treeContextResult.breakdown) {
207 | tokenBreakdown.projectTree = treeContextResult.breakdown;
208 | tokenBreakdown.total += treeContextResult.breakdown.tokens;
209 | }
210 | }
211 | }
212 |
213 | const finalContext = this._joinContextSections(contextSections, format);
214 |
215 | const result = {
216 | context: finalContext,
217 | analysisData: analysisData,
218 | contextSections: contextSections.length,
219 | finalTaskIds: Array.from(finalTaskIds)
220 | };
221 |
222 | // Only include tokenBreakdown if it was requested
223 | if (includeTokenCounts) {
224 | result.tokenBreakdown = tokenBreakdown;
225 | }
226 |
227 | return result;
228 | }
229 |
230 | _performSemanticSearch(query, maxResults) {
231 | const searchableTasks = this.allTasks.map((task) => {
232 | const dependencyTitles =
233 | task.dependencies?.length > 0
234 | ? task.dependencies
235 | .map((depId) => this.allTasks.find((t) => t.id === depId)?.title)
236 | .filter(Boolean)
237 | .join(' ')
238 | : '';
239 | return { ...task, dependencyTitles };
240 | });
241 |
242 | // Use the exact same approach as add-task.js
243 | const searchOptions = {
244 | includeScore: true, // Return match scores
245 | threshold: 0.4, // Lower threshold = stricter matching (range 0-1)
246 | keys: [
247 | { name: 'title', weight: 1.5 }, // Title is most important
248 | { name: 'description', weight: 2 }, // Description is very important
249 | { name: 'details', weight: 3 }, // Details is most important
250 | // Search dependencies to find tasks that depend on similar things
251 | { name: 'dependencyTitles', weight: 0.5 }
252 | ],
253 | // Sort matches by score (lower is better)
254 | shouldSort: true,
255 | // Allow searching in nested properties
256 | useExtendedSearch: true,
257 | // Return up to 50 matches
258 | limit: 50
259 | };
260 |
261 | // Create search index using Fuse.js
262 | const fuse = new Fuse(searchableTasks, searchOptions);
263 |
264 | // Extract significant words and phrases from the prompt (like add-task.js does)
265 | const promptWords = query
266 | .toLowerCase()
267 | .replace(/[^\w\s-]/g, ' ') // Replace non-alphanumeric chars with spaces
268 | .split(/\s+/)
269 | .filter((word) => word.length > 3); // Words at least 4 chars
270 |
271 | // Use the user's prompt for fuzzy search
272 | const fuzzyResults = fuse.search(query);
273 |
274 | // Also search for each significant word to catch different aspects
275 | const wordResults = [];
276 | for (const word of promptWords) {
277 | if (word.length > 5) {
278 | // Only use significant words
279 | const results = fuse.search(word);
280 | if (results.length > 0) {
281 | wordResults.push(...results);
282 | }
283 | }
284 | }
285 |
286 | // Merge and deduplicate results
287 | const mergedResults = [...fuzzyResults];
288 |
289 | // Add word results that aren't already in fuzzyResults
290 | for (const wordResult of wordResults) {
291 | if (!mergedResults.some((r) => r.item.id === wordResult.item.id)) {
292 | mergedResults.push(wordResult);
293 | }
294 | }
295 |
296 | // Group search results by relevance
297 | const highRelevance = mergedResults
298 | .filter((result) => result.score < 0.25)
299 | .map((result) => result.item);
300 |
301 | const mediumRelevance = mergedResults
302 | .filter((result) => result.score >= 0.25 && result.score < 0.4)
303 | .map((result) => result.item);
304 |
305 | // Get recent tasks (newest first)
306 | const recentTasks = [...this.allTasks]
307 | .sort((a, b) => b.id - a.id)
308 | .slice(0, 5);
309 |
310 | // Combine high relevance, medium relevance, and recent tasks
311 | // Prioritize high relevance first
312 | const allRelevantTasks = [...highRelevance];
313 |
314 | // Add medium relevance if not already included
315 | for (const task of mediumRelevance) {
316 | if (!allRelevantTasks.some((t) => t.id === task.id)) {
317 | allRelevantTasks.push(task);
318 | }
319 | }
320 |
321 | // Add recent tasks if not already included
322 | for (const task of recentTasks) {
323 | if (!allRelevantTasks.some((t) => t.id === task.id)) {
324 | allRelevantTasks.push(task);
325 | }
326 | }
327 |
328 | // Get top N results for context
329 | const finalResults = allRelevantTasks.slice(0, maxResults);
330 | return {
331 | tasks: finalResults,
332 | analysisData: {
333 | highRelevance: highRelevance,
334 | mediumRelevance: mediumRelevance,
335 | recentTasks: recentTasks,
336 | allRelevantTasks: allRelevantTasks
337 | }
338 | };
339 | }
340 |
341 | _buildDependencyContext(taskIds) {
342 | const { allRelatedTaskIds, graphs, depthMap } =
343 | this._buildDependencyGraphs(taskIds);
344 | if (allRelatedTaskIds.size === 0) return '';
345 |
346 | const dependentTasks = Array.from(allRelatedTaskIds)
347 | .map((id) => this.allTasks.find((t) => t.id === id))
348 | .filter(Boolean)
349 | .sort((a, b) => (depthMap.get(a.id) || 0) - (depthMap.get(b.id) || 0));
350 |
351 | const uniqueDetailedTasks = dependentTasks.slice(0, 8);
352 |
353 | let context = `\nThis task relates to a dependency structure with ${dependentTasks.length} related tasks in the chain.`;
354 |
355 | const directDeps = this.allTasks.filter((t) => taskIds.includes(t.id));
356 | if (directDeps.length > 0) {
357 | context += `\n\nDirect dependencies:\n${directDeps
358 | .map((t) => `- Task ${t.id}: ${t.title} - ${t.description}`)
359 | .join('\n')}`;
360 | }
361 |
362 | const indirectDeps = dependentTasks.filter((t) => !taskIds.includes(t.id));
363 | if (indirectDeps.length > 0) {
364 | context += `\n\nIndirect dependencies (dependencies of dependencies):\n${indirectDeps
365 | .slice(0, 5)
366 | .map((t) => `- Task ${t.id}: ${t.title} - ${t.description}`)
367 | .join('\n')}`;
368 | if (indirectDeps.length > 5)
369 | context += `\n- ... and ${
370 | indirectDeps.length - 5
371 | } more indirect dependencies`;
372 | }
373 |
374 | context += `\n\nDetailed information about dependencies:`;
375 | for (const depTask of uniqueDetailedTasks) {
376 | const isDirect = taskIds.includes(depTask.id)
377 | ? ' [DIRECT DEPENDENCY]'
378 | : '';
379 | context += `\n\n------ Task ${depTask.id}${isDirect}: ${depTask.title} ------\n`;
380 | context += `Description: ${depTask.description}\n`;
381 | if (depTask.dependencies?.length) {
382 | context += `Dependencies: ${depTask.dependencies.join(', ')}\n`;
383 | }
384 | if (depTask.details) {
385 | context += `Implementation Details: ${truncate(
386 | depTask.details,
387 | 400
388 | )}\n`;
389 | }
390 | }
391 |
392 | if (graphs.length > 0) {
393 | context += '\n\nDependency Chain Visualization:';
394 | context += graphs
395 | .map((graph) => this._formatDependencyChain(graph))
396 | .join('');
397 | }
398 |
399 | return context;
400 | }
401 |
402 | _buildDependencyGraphs(taskIds) {
403 | const visited = new Set();
404 | const depthMap = new Map();
405 | const graphs = [];
406 |
407 | for (const id of taskIds) {
408 | const graph = this._buildDependencyGraph(id, visited, depthMap);
409 | if (graph) graphs.push(graph);
410 | }
411 |
412 | return { allRelatedTaskIds: visited, graphs, depthMap };
413 | }
414 |
415 | _buildDependencyGraph(taskId, visited, depthMap, depth = 0) {
416 | if (visited.has(taskId) || depth > 5) return null; // Limit recursion depth
417 | const task = this.allTasks.find((t) => t.id === taskId);
418 | if (!task) return null;
419 |
420 | visited.add(taskId);
421 | if (!depthMap.has(taskId) || depth < depthMap.get(taskId)) {
422 | depthMap.set(taskId, depth);
423 | }
424 |
425 | const dependencies =
426 | task.dependencies
427 | ?.map((depId) =>
428 | this._buildDependencyGraph(depId, visited, depthMap, depth + 1)
429 | )
430 | .filter(Boolean) || [];
431 |
432 | return { ...task, dependencies };
433 | }
434 |
435 | _formatDependencyChain(node, prefix = '', isLast = true, depth = 0) {
436 | if (depth > 3) return '';
437 | const connector = isLast ? '└── ' : '├── ';
438 | let result = `${prefix}${connector}Task ${node.id}: ${node.title}`;
439 | if (node.dependencies?.length) {
440 | const childPrefix = prefix + (isLast ? ' ' : '│ ');
441 | result += node.dependencies
442 | .map((dep, index) =>
443 | this._formatDependencyChain(
444 | dep,
445 | childPrefix,
446 | index === node.dependencies.length - 1,
447 | depth + 1
448 | )
449 | )
450 | .join('');
451 | }
452 | return '\n' + result;
453 | }
454 |
455 | /**
456 | * Parse task ID strings into structured format
457 | * Supports formats: "15", "15.2", "16,17.1"
458 | * @param {Array<string>} taskIds - Array of task ID strings
459 | * @returns {Array<Object>} Parsed task identifiers
460 | */
461 | _parseTaskIds(taskIds) {
462 | const parsed = [];
463 |
464 | for (const idStr of taskIds) {
465 | if (idStr.includes('.')) {
466 | // Subtask format: "15.2"
467 | const [parentId, subtaskId] = idStr.split('.');
468 | parsed.push({
469 | type: 'subtask',
470 | parentId: parseInt(parentId, 10),
471 | subtaskId: parseInt(subtaskId, 10),
472 | fullId: idStr
473 | });
474 | } else {
475 | // Task format: "15"
476 | parsed.push({
477 | type: 'task',
478 | taskId: parseInt(idStr, 10),
479 | fullId: idStr
480 | });
481 | }
482 | }
483 |
484 | return parsed;
485 | }
486 |
487 | /**
488 | * Gather context from tasks and subtasks
489 | * @param {Array<string>} taskIds - Task/subtask IDs
490 | * @param {string} format - Output format
491 | * @param {boolean} includeTokenCounts - Whether to include token breakdown
492 | * @returns {Promise<Object>} Task context result with breakdown
493 | */
494 | async _gatherTaskContext(taskIds, format, includeTokenCounts = false) {
495 | try {
496 | if (!this.allTasks || this.allTasks.length === 0) {
497 | return { context: null, breakdown: [] };
498 | }
499 |
500 | const parsedIds = this._parseTaskIds(taskIds);
501 | const contextItems = [];
502 | const breakdown = [];
503 |
504 | for (const parsed of parsedIds) {
505 | let formattedItem = null;
506 | let itemInfo = null;
507 |
508 | if (parsed.type === 'task') {
509 | const result = findTaskById(this.allTasks, parsed.taskId);
510 | if (result.task) {
511 | formattedItem = this._formatTaskForContext(result.task, format);
512 | itemInfo = {
513 | id: parsed.fullId,
514 | type: 'task',
515 | title: result.task.title,
516 | tokens: includeTokenCounts ? this.countTokens(formattedItem) : 0,
517 | characters: formattedItem.length
518 | };
519 | }
520 | } else if (parsed.type === 'subtask') {
521 | const parentResult = findTaskById(this.allTasks, parsed.parentId);
522 | if (parentResult.task && parentResult.task.subtasks) {
523 | const subtask = parentResult.task.subtasks.find(
524 | (st) => st.id === parsed.subtaskId
525 | );
526 | if (subtask) {
527 | formattedItem = this._formatSubtaskForContext(
528 | subtask,
529 | parentResult.task,
530 | format
531 | );
532 | itemInfo = {
533 | id: parsed.fullId,
534 | type: 'subtask',
535 | title: subtask.title,
536 | parentTitle: parentResult.task.title,
537 | tokens: includeTokenCounts
538 | ? this.countTokens(formattedItem)
539 | : 0,
540 | characters: formattedItem.length
541 | };
542 | }
543 | }
544 | }
545 |
546 | if (formattedItem && itemInfo) {
547 | contextItems.push(formattedItem);
548 | if (includeTokenCounts) {
549 | breakdown.push(itemInfo);
550 | }
551 | }
552 | }
553 |
554 | if (contextItems.length === 0) {
555 | return { context: null, breakdown: [] };
556 | }
557 |
558 | const finalContext = this._formatTaskContextSection(contextItems, format);
559 | return {
560 | context: finalContext,
561 | breakdown: includeTokenCounts ? breakdown : []
562 | };
563 | } catch (error) {
564 | console.warn(`Warning: Could not gather task context: ${error.message}`);
565 | return { context: null, breakdown: [] };
566 | }
567 | }
568 |
569 | /**
570 | * Format a task for context inclusion
571 | * @param {Object} task - Task object
572 | * @param {string} format - Output format
573 | * @returns {string} Formatted task context
574 | */
575 | _formatTaskForContext(task, format) {
576 | const sections = [];
577 |
578 | sections.push(`**Task ${task.id}: ${task.title}**`);
579 | sections.push(`Description: ${task.description}`);
580 | sections.push(`Status: ${task.status || 'pending'}`);
581 | sections.push(`Priority: ${task.priority || 'medium'}`);
582 |
583 | if (task.dependencies && task.dependencies.length > 0) {
584 | sections.push(`Dependencies: ${task.dependencies.join(', ')}`);
585 | }
586 |
587 | if (task.details) {
588 | const details = truncate(task.details, 500);
589 | sections.push(`Implementation Details: ${details}`);
590 | }
591 |
592 | if (task.testStrategy) {
593 | const testStrategy = truncate(task.testStrategy, 300);
594 | sections.push(`Test Strategy: ${testStrategy}`);
595 | }
596 |
597 | if (task.subtasks && task.subtasks.length > 0) {
598 | sections.push(`Subtasks: ${task.subtasks.length} subtasks defined`);
599 | }
600 |
601 | return sections.join('\n');
602 | }
603 |
604 | /**
605 | * Format a subtask for context inclusion
606 | * @param {Object} subtask - Subtask object
607 | * @param {Object} parentTask - Parent task object
608 | * @param {string} format - Output format
609 | * @returns {string} Formatted subtask context
610 | */
611 | _formatSubtaskForContext(subtask, parentTask, format) {
612 | const sections = [];
613 |
614 | sections.push(
615 | `**Subtask ${parentTask.id}.${subtask.id}: ${subtask.title}**`
616 | );
617 | sections.push(`Parent Task: ${parentTask.title}`);
618 | sections.push(`Description: ${subtask.description}`);
619 | sections.push(`Status: ${subtask.status || 'pending'}`);
620 |
621 | if (subtask.dependencies && subtask.dependencies.length > 0) {
622 | sections.push(`Dependencies: ${subtask.dependencies.join(', ')}`);
623 | }
624 |
625 | if (subtask.details) {
626 | const details = truncate(subtask.details, 500);
627 | sections.push(`Implementation Details: ${details}`);
628 | }
629 |
630 | return sections.join('\n');
631 | }
632 |
633 | /**
634 | * Gather context from files
635 | * @param {Array<string>} filePaths - File paths to read
636 | * @param {string} format - Output format
637 | * @param {boolean} includeTokenCounts - Whether to include token breakdown
638 | * @returns {Promise<Object>} File context result with breakdown
639 | */
640 | async _gatherFileContext(filePaths, format, includeTokenCounts = false) {
641 | const fileContents = [];
642 | const breakdown = [];
643 |
644 | for (const filePath of filePaths) {
645 | try {
646 | const fullPath = path.isAbsolute(filePath)
647 | ? filePath
648 | : path.join(this.projectRoot, filePath);
649 |
650 | if (!fs.existsSync(fullPath)) {
651 | continue;
652 | }
653 |
654 | const stats = fs.statSync(fullPath);
655 | if (!stats.isFile()) {
656 | continue;
657 | }
658 |
659 | // Check file size (limit to 50KB for context)
660 | if (stats.size > 50 * 1024) {
661 | continue;
662 | }
663 |
664 | const content = fs.readFileSync(fullPath, 'utf-8');
665 | const relativePath = path.relative(this.projectRoot, fullPath);
666 |
667 | const fileData = {
668 | path: relativePath,
669 | size: stats.size,
670 | content: content,
671 | lastModified: stats.mtime
672 | };
673 |
674 | fileContents.push(fileData);
675 |
676 | // Calculate tokens for this individual file if requested
677 | if (includeTokenCounts) {
678 | const formattedFile = this._formatSingleFileForContext(
679 | fileData,
680 | format
681 | );
682 | breakdown.push({
683 | path: relativePath,
684 | sizeKB: Math.round(stats.size / 1024),
685 | tokens: this.countTokens(formattedFile),
686 | characters: formattedFile.length
687 | });
688 | }
689 | } catch (error) {
690 | console.warn(
691 | `Warning: Could not read file ${filePath}: ${error.message}`
692 | );
693 | }
694 | }
695 |
696 | if (fileContents.length === 0) {
697 | return { context: null, breakdown: [] };
698 | }
699 |
700 | const finalContext = this._formatFileContextSection(fileContents, format);
701 | return {
702 | context: finalContext,
703 | breakdown: includeTokenCounts ? breakdown : []
704 | };
705 | }
706 |
707 | /**
708 | * Generate project file tree context
709 | * @param {string} format - Output format
710 | * @param {boolean} includeTokenCounts - Whether to include token breakdown
711 | * @returns {Promise<Object>} Project tree context result with breakdown
712 | */
713 | async _gatherProjectTreeContext(format, includeTokenCounts = false) {
714 | try {
715 | const tree = this._generateFileTree(this.projectRoot, 5); // Max depth 5
716 | const finalContext = this._formatProjectTreeSection(tree, format);
717 |
718 | const breakdown = includeTokenCounts
719 | ? {
720 | tokens: this.countTokens(finalContext),
721 | characters: finalContext.length,
722 | fileCount: tree.fileCount || 0,
723 | dirCount: tree.dirCount || 0
724 | }
725 | : null;
726 |
727 | return {
728 | context: finalContext,
729 | breakdown: breakdown
730 | };
731 | } catch (error) {
732 | console.warn(
733 | `Warning: Could not generate project tree: ${error.message}`
734 | );
735 | return { context: null, breakdown: null };
736 | }
737 | }
738 |
739 | /**
740 | * Format a single file for context (used for token counting)
741 | * @param {Object} fileData - File data object
742 | * @param {string} format - Output format
743 | * @returns {string} Formatted file context
744 | */
745 | _formatSingleFileForContext(fileData, format) {
746 | const header = `**File: ${fileData.path}** (${Math.round(fileData.size / 1024)}KB)`;
747 | const content = `\`\`\`\n${fileData.content}\n\`\`\``;
748 | return `${header}\n\n${content}`;
749 | }
750 |
751 | /**
752 | * Generate file tree structure
753 | * @param {string} dirPath - Directory path
754 | * @param {number} maxDepth - Maximum depth to traverse
755 | * @param {number} currentDepth - Current depth
756 | * @returns {Object} File tree structure
757 | */
758 | _generateFileTree(dirPath, maxDepth, currentDepth = 0) {
759 | const ignoreDirs = [
760 | '.git',
761 | 'node_modules',
762 | '.env',
763 | 'coverage',
764 | 'dist',
765 | 'build'
766 | ];
767 | const ignoreFiles = ['.DS_Store', '.env', '.env.local', '.env.production'];
768 |
769 | if (currentDepth >= maxDepth) {
770 | return null;
771 | }
772 |
773 | try {
774 | const items = fs.readdirSync(dirPath);
775 | const tree = {
776 | name: path.basename(dirPath),
777 | type: 'directory',
778 | children: [],
779 | fileCount: 0,
780 | dirCount: 0
781 | };
782 |
783 | for (const item of items) {
784 | if (ignoreDirs.includes(item) || ignoreFiles.includes(item)) {
785 | continue;
786 | }
787 |
788 | const itemPath = path.join(dirPath, item);
789 | const stats = fs.statSync(itemPath);
790 |
791 | if (stats.isDirectory()) {
792 | tree.dirCount++;
793 | if (currentDepth < maxDepth - 1) {
794 | const subtree = this._generateFileTree(
795 | itemPath,
796 | maxDepth,
797 | currentDepth + 1
798 | );
799 | if (subtree) {
800 | tree.children.push(subtree);
801 | }
802 | }
803 | } else {
804 | tree.fileCount++;
805 | tree.children.push({
806 | name: item,
807 | type: 'file',
808 | size: stats.size
809 | });
810 | }
811 | }
812 |
813 | return tree;
814 | } catch (error) {
815 | return null;
816 | }
817 | }
818 |
819 | /**
820 | * Format custom context section
821 | * @param {string} customContext - Custom context string
822 | * @param {string} format - Output format
823 | * @returns {string} Formatted custom context
824 | */
825 | _formatCustomContext(customContext, format) {
826 | switch (format) {
827 | case 'research':
828 | return `## Additional Context\n\n${customContext}`;
829 | case 'chat':
830 | return `**Additional Context:**\n${customContext}`;
831 | case 'system-prompt':
832 | return `Additional context: ${customContext}`;
833 | default:
834 | return customContext;
835 | }
836 | }
837 |
838 | /**
839 | * Format task context section
840 | * @param {Array<string>} taskItems - Formatted task items
841 | * @param {string} format - Output format
842 | * @returns {string} Formatted task context section
843 | */
844 | _formatTaskContextSection(taskItems, format) {
845 | switch (format) {
846 | case 'research':
847 | return `## Task Context\n\n${taskItems.join('\n\n---\n\n')}`;
848 | case 'chat':
849 | return `**Task Context:**\n\n${taskItems.join('\n\n')}`;
850 | case 'system-prompt':
851 | return `Task context: ${taskItems.join(' | ')}`;
852 | default:
853 | return taskItems.join('\n\n');
854 | }
855 | }
856 |
857 | /**
858 | * Format file context section
859 | * @param {Array<Object>} fileContents - File content objects
860 | * @param {string} format - Output format
861 | * @returns {string} Formatted file context section
862 | */
863 | _formatFileContextSection(fileContents, format) {
864 | const fileItems = fileContents.map((file) => {
865 | const header = `**File: ${file.path}** (${Math.round(file.size / 1024)}KB)`;
866 | const content = `\`\`\`\n${file.content}\n\`\`\``;
867 | return `${header}\n\n${content}`;
868 | });
869 |
870 | switch (format) {
871 | case 'research':
872 | return `## File Context\n\n${fileItems.join('\n\n---\n\n')}`;
873 | case 'chat':
874 | return `**File Context:**\n\n${fileItems.join('\n\n')}`;
875 | case 'system-prompt':
876 | return `File context: ${fileContents.map((f) => `${f.path} (${f.content.substring(0, 200)}...)`).join(' | ')}`;
877 | default:
878 | return fileItems.join('\n\n');
879 | }
880 | }
881 |
882 | /**
883 | * Format project tree section
884 | * @param {Object} tree - File tree structure
885 | * @param {string} format - Output format
886 | * @returns {string} Formatted project tree section
887 | */
888 | _formatProjectTreeSection(tree, format) {
889 | const treeString = this._renderFileTree(tree);
890 |
891 | switch (format) {
892 | case 'research':
893 | return `## Project Structure\n\n\`\`\`\n${treeString}\n\`\`\``;
894 | case 'chat':
895 | return `**Project Structure:**\n\`\`\`\n${treeString}\n\`\`\``;
896 | case 'system-prompt':
897 | return `Project structure: ${treeString.replace(/\n/g, ' | ')}`;
898 | default:
899 | return treeString;
900 | }
901 | }
902 |
903 | /**
904 | * Render file tree as string
905 | * @param {Object} tree - File tree structure
906 | * @param {string} prefix - Current prefix for indentation
907 | * @returns {string} Rendered tree string
908 | */
909 | _renderFileTree(tree, prefix = '') {
910 | let result = `${prefix}${tree.name}/`;
911 |
912 | if (tree.fileCount > 0 || tree.dirCount > 0) {
913 | result += ` (${tree.fileCount} files, ${tree.dirCount} dirs)`;
914 | }
915 |
916 | result += '\n';
917 |
918 | if (tree.children) {
919 | tree.children.forEach((child, index) => {
920 | const isLast = index === tree.children.length - 1;
921 | const childPrefix = prefix + (isLast ? '└── ' : '├── ');
922 | const nextPrefix = prefix + (isLast ? ' ' : '│ ');
923 |
924 | if (child.type === 'directory') {
925 | result += this._renderFileTree(child, childPrefix);
926 | } else {
927 | result += `${childPrefix}${child.name}\n`;
928 | }
929 | });
930 | }
931 |
932 | return result;
933 | }
934 |
935 | /**
936 | * Join context sections based on format
937 | * @param {Array<string>} sections - Context sections
938 | * @param {string} format - Output format
939 | * @returns {string} Joined context string
940 | */
941 | _joinContextSections(sections, format) {
942 | if (sections.length === 0) {
943 | return '';
944 | }
945 |
946 | switch (format) {
947 | case 'research':
948 | return sections.join('\n\n---\n\n');
949 | case 'chat':
950 | return sections.join('\n\n');
951 | case 'system-prompt':
952 | return sections.join(' ');
953 | default:
954 | return sections.join('\n\n');
955 | }
956 | }
957 | }
958 |
959 | /**
960 | * Factory function to create a context gatherer instance
961 | * @param {string} projectRoot - Project root directory
962 | * @param {string} tag - Tag for the task
963 | * @returns {ContextGatherer} Context gatherer instance
964 | * @throws {Error} If tag is not provided
965 | */
966 | export function createContextGatherer(projectRoot, tag) {
967 | if (!tag) {
968 | throw new Error('Tag is required');
969 | }
970 | return new ContextGatherer(projectRoot, tag);
971 | }
972 |
973 | export default ContextGatherer;
974 |
```
--------------------------------------------------------------------------------
/scripts/modules/task-manager/models.js:
--------------------------------------------------------------------------------
```javascript
1 | /**
2 | * models.js
3 | * Core functionality for managing AI model configurations
4 | */
5 |
6 | import https from 'https';
7 | import http from 'http';
8 | import {
9 | getMainModelId,
10 | getResearchModelId,
11 | getFallbackModelId,
12 | getAvailableModels,
13 | getMainProvider,
14 | getResearchProvider,
15 | getFallbackProvider,
16 | isApiKeySet,
17 | getMcpApiKeyStatus,
18 | getConfig,
19 | writeConfig,
20 | isConfigFilePresent,
21 | getAllProviders,
22 | getBaseUrlForRole
23 | } from '../config-manager.js';
24 | import { findConfigPath } from '../../../src/utils/path-utils.js';
25 | import { log } from '../utils.js';
26 | import { CUSTOM_PROVIDERS } from '@tm/core';
27 |
28 | // Constants
29 | const CONFIG_MISSING_ERROR =
30 | 'The configuration file is missing. Run "task-master init" to create it.';
31 |
32 | /**
33 | * Fetches the list of models from OpenRouter API.
34 | * @returns {Promise<Array|null>} A promise that resolves with the list of model IDs or null if fetch fails.
35 | */
36 | function fetchOpenRouterModels() {
37 | return new Promise((resolve) => {
38 | const options = {
39 | hostname: 'openrouter.ai',
40 | path: '/api/v1/models',
41 | method: 'GET',
42 | headers: {
43 | Accept: 'application/json'
44 | }
45 | };
46 |
47 | const req = https.request(options, (res) => {
48 | let data = '';
49 | res.on('data', (chunk) => {
50 | data += chunk;
51 | });
52 | res.on('end', () => {
53 | if (res.statusCode === 200) {
54 | try {
55 | const parsedData = JSON.parse(data);
56 | resolve(parsedData.data || []); // Return the array of models
57 | } catch (e) {
58 | console.error('Error parsing OpenRouter response:', e);
59 | resolve(null); // Indicate failure
60 | }
61 | } else {
62 | console.error(
63 | `OpenRouter API request failed with status code: ${res.statusCode}`
64 | );
65 | resolve(null); // Indicate failure
66 | }
67 | });
68 | });
69 |
70 | req.on('error', (e) => {
71 | console.error('Error fetching OpenRouter models:', e);
72 | resolve(null); // Indicate failure
73 | });
74 | req.end();
75 | });
76 | }
77 |
78 | /**
79 | * Fetches the list of models from Ollama instance.
80 | * @param {string} baseURL - The base URL for the Ollama API (e.g., "http://localhost:11434/api")
81 | * @returns {Promise<Array|null>} A promise that resolves with the list of model objects or null if fetch fails.
82 | */
83 | function fetchOllamaModels(baseURL = 'http://localhost:11434/api') {
84 | return new Promise((resolve) => {
85 | try {
86 | // Parse the base URL to extract hostname, port, and base path
87 | const url = new URL(baseURL);
88 | const isHttps = url.protocol === 'https:';
89 | const port = url.port || (isHttps ? 443 : 80);
90 | const basePath = url.pathname.endsWith('/')
91 | ? url.pathname.slice(0, -1)
92 | : url.pathname;
93 |
94 | const options = {
95 | hostname: url.hostname,
96 | port: parseInt(port, 10),
97 | path: `${basePath}/tags`,
98 | method: 'GET',
99 | headers: {
100 | Accept: 'application/json'
101 | }
102 | };
103 |
104 | const requestLib = isHttps ? https : http;
105 | const req = requestLib.request(options, (res) => {
106 | let data = '';
107 | res.on('data', (chunk) => {
108 | data += chunk;
109 | });
110 | res.on('end', () => {
111 | if (res.statusCode === 200) {
112 | try {
113 | const parsedData = JSON.parse(data);
114 | resolve(parsedData.models || []); // Return the array of models
115 | } catch (e) {
116 | console.error('Error parsing Ollama response:', e);
117 | resolve(null); // Indicate failure
118 | }
119 | } else {
120 | console.error(
121 | `Ollama API request failed with status code: ${res.statusCode}`
122 | );
123 | resolve(null); // Indicate failure
124 | }
125 | });
126 | });
127 |
128 | req.on('error', (e) => {
129 | console.error('Error fetching Ollama models:', e);
130 | resolve(null); // Indicate failure
131 | });
132 | req.end();
133 | } catch (e) {
134 | console.error('Error parsing Ollama base URL:', e);
135 | resolve(null); // Indicate failure
136 | }
137 | });
138 | }
139 |
140 | /**
141 | * Get the current model configuration
142 | * @param {Object} [options] - Options for the operation
143 | * @param {Object} [options.session] - Session object containing environment variables (for MCP)
144 | * @param {Function} [options.mcpLog] - MCP logger object (for MCP)
145 | * @param {string} [options.projectRoot] - Project root directory
146 | * @returns {Object} RESTful response with current model configuration
147 | */
148 | async function getModelConfiguration(options = {}) {
149 | const { mcpLog, projectRoot, session } = options;
150 |
151 | const report = (level, ...args) => {
152 | if (mcpLog && typeof mcpLog[level] === 'function') {
153 | mcpLog[level](...args);
154 | }
155 | };
156 |
157 | if (!projectRoot) {
158 | throw new Error('Project root is required but not found.');
159 | }
160 |
161 | // Use centralized config path finding instead of hardcoded path
162 | const configPath = findConfigPath(null, { projectRoot });
163 | const configExists = isConfigFilePresent(projectRoot);
164 |
165 | log(
166 | 'debug',
167 | `Checking for config file using findConfigPath, found: ${configPath}`
168 | );
169 | log(
170 | 'debug',
171 | `Checking config file using isConfigFilePresent(), exists: ${configExists}`
172 | );
173 |
174 | if (!configExists) {
175 | throw new Error(CONFIG_MISSING_ERROR);
176 | }
177 |
178 | try {
179 | // Get current settings - these should use the config from the found path automatically
180 | const mainProvider = getMainProvider(projectRoot);
181 | const mainModelId = getMainModelId(projectRoot);
182 | const mainBaseURL = getBaseUrlForRole('main', projectRoot);
183 | const researchProvider = getResearchProvider(projectRoot);
184 | const researchModelId = getResearchModelId(projectRoot);
185 | const researchBaseURL = getBaseUrlForRole('research', projectRoot);
186 | const fallbackProvider = getFallbackProvider(projectRoot);
187 | const fallbackModelId = getFallbackModelId(projectRoot);
188 | const fallbackBaseURL = getBaseUrlForRole('fallback', projectRoot);
189 |
190 | // Check API keys
191 | const mainCliKeyOk = isApiKeySet(mainProvider, session, projectRoot);
192 | const mainMcpKeyOk = getMcpApiKeyStatus(mainProvider, projectRoot);
193 | const researchCliKeyOk = isApiKeySet(
194 | researchProvider,
195 | session,
196 | projectRoot
197 | );
198 | const researchMcpKeyOk = getMcpApiKeyStatus(researchProvider, projectRoot);
199 | const fallbackCliKeyOk = fallbackProvider
200 | ? isApiKeySet(fallbackProvider, session, projectRoot)
201 | : true;
202 | const fallbackMcpKeyOk = fallbackProvider
203 | ? getMcpApiKeyStatus(fallbackProvider, projectRoot)
204 | : true;
205 |
206 | // Get available models to find detailed info
207 | const availableModels = getAvailableModels(projectRoot);
208 |
209 | // Find model details
210 | const mainModelData = availableModels.find((m) => m.id === mainModelId);
211 | const researchModelData = availableModels.find(
212 | (m) => m.id === researchModelId
213 | );
214 | const fallbackModelData = fallbackModelId
215 | ? availableModels.find((m) => m.id === fallbackModelId)
216 | : null;
217 |
218 | // Return structured configuration data
219 | return {
220 | success: true,
221 | data: {
222 | activeModels: {
223 | main: {
224 | provider: mainProvider,
225 | modelId: mainModelId,
226 | baseURL: mainBaseURL,
227 | sweScore: mainModelData?.swe_score || null,
228 | cost: mainModelData?.cost_per_1m_tokens || null,
229 | keyStatus: {
230 | cli: mainCliKeyOk,
231 | mcp: mainMcpKeyOk
232 | }
233 | },
234 | research: {
235 | provider: researchProvider,
236 | modelId: researchModelId,
237 | baseURL: researchBaseURL,
238 | sweScore: researchModelData?.swe_score || null,
239 | cost: researchModelData?.cost_per_1m_tokens || null,
240 | keyStatus: {
241 | cli: researchCliKeyOk,
242 | mcp: researchMcpKeyOk
243 | }
244 | },
245 | fallback: fallbackProvider
246 | ? {
247 | provider: fallbackProvider,
248 | modelId: fallbackModelId,
249 | baseURL: fallbackBaseURL,
250 | sweScore: fallbackModelData?.swe_score || null,
251 | cost: fallbackModelData?.cost_per_1m_tokens || null,
252 | keyStatus: {
253 | cli: fallbackCliKeyOk,
254 | mcp: fallbackMcpKeyOk
255 | }
256 | }
257 | : null
258 | },
259 | message: 'Successfully retrieved current model configuration'
260 | }
261 | };
262 | } catch (error) {
263 | report('error', `Error getting model configuration: ${error.message}`);
264 | return {
265 | success: false,
266 | error: {
267 | code: 'CONFIG_ERROR',
268 | message: error.message
269 | }
270 | };
271 | }
272 | }
273 |
274 | /**
275 | * Get all available models not currently in use
276 | * @param {Object} [options] - Options for the operation
277 | * @param {Object} [options.session] - Session object containing environment variables (for MCP)
278 | * @param {Function} [options.mcpLog] - MCP logger object (for MCP)
279 | * @param {string} [options.projectRoot] - Project root directory
280 | * @returns {Object} RESTful response with available models
281 | */
282 | async function getAvailableModelsList(options = {}) {
283 | const { mcpLog, projectRoot } = options;
284 |
285 | const report = (level, ...args) => {
286 | if (mcpLog && typeof mcpLog[level] === 'function') {
287 | mcpLog[level](...args);
288 | }
289 | };
290 |
291 | if (!projectRoot) {
292 | throw new Error('Project root is required but not found.');
293 | }
294 |
295 | // Use centralized config path finding instead of hardcoded path
296 | const configPath = findConfigPath(null, { projectRoot });
297 | const configExists = isConfigFilePresent(projectRoot);
298 |
299 | log(
300 | 'debug',
301 | `Checking for config file using findConfigPath, found: ${configPath}`
302 | );
303 | log(
304 | 'debug',
305 | `Checking config file using isConfigFilePresent(), exists: ${configExists}`
306 | );
307 |
308 | if (!configExists) {
309 | throw new Error(CONFIG_MISSING_ERROR);
310 | }
311 |
312 | try {
313 | // Get all available models
314 | const allAvailableModels = getAvailableModels(projectRoot);
315 |
316 | if (!allAvailableModels || allAvailableModels.length === 0) {
317 | return {
318 | success: true,
319 | data: {
320 | models: [],
321 | message: 'No available models found'
322 | }
323 | };
324 | }
325 |
326 | // Get currently used model IDs
327 | const mainModelId = getMainModelId(projectRoot);
328 | const researchModelId = getResearchModelId(projectRoot);
329 | const fallbackModelId = getFallbackModelId(projectRoot);
330 |
331 | // Filter out placeholder models and active models
332 | const activeIds = [mainModelId, researchModelId, fallbackModelId].filter(
333 | Boolean
334 | );
335 | const otherAvailableModels = allAvailableModels.map((model) => ({
336 | provider: model.provider || 'N/A',
337 | modelId: model.id,
338 | sweScore: model.swe_score || null,
339 | cost: model.cost_per_1m_tokens || null,
340 | allowedRoles: model.allowed_roles || []
341 | }));
342 |
343 | return {
344 | success: true,
345 | data: {
346 | models: otherAvailableModels,
347 | message: `Successfully retrieved ${otherAvailableModels.length} available models`
348 | }
349 | };
350 | } catch (error) {
351 | report('error', `Error getting available models: ${error.message}`);
352 | return {
353 | success: false,
354 | error: {
355 | code: 'MODELS_LIST_ERROR',
356 | message: error.message
357 | }
358 | };
359 | }
360 | }
361 |
362 | /**
363 | * Update a specific model in the configuration
364 | * @param {string} role - The model role to update ('main', 'research', 'fallback')
365 | * @param {string} modelId - The model ID to set for the role
366 | * @param {Object} [options] - Options for the operation
367 | * @param {string} [options.providerHint] - Provider hint if already determined ('openrouter' or 'ollama')
368 | * @param {Object} [options.session] - Session object containing environment variables (for MCP)
369 | * @param {Function} [options.mcpLog] - MCP logger object (for MCP)
370 | * @param {string} [options.projectRoot] - Project root directory
371 | * @returns {Object} RESTful response with result of update operation
372 | */
373 | async function setModel(role, modelId, options = {}) {
374 | const { mcpLog, projectRoot, providerHint, baseURL } = options;
375 | let computedBaseURL = baseURL; // Track the computed baseURL separately
376 |
377 | const report = (level, ...args) => {
378 | if (mcpLog && typeof mcpLog[level] === 'function') {
379 | mcpLog[level](...args);
380 | }
381 | };
382 |
383 | if (!projectRoot) {
384 | throw new Error('Project root is required but not found.');
385 | }
386 |
387 | // Use centralized config path finding instead of hardcoded path
388 | const configPath = findConfigPath(null, { projectRoot });
389 | const configExists = isConfigFilePresent(projectRoot);
390 |
391 | log(
392 | 'debug',
393 | `Checking for config file using findConfigPath, found: ${configPath}`
394 | );
395 | log(
396 | 'debug',
397 | `Checking config file using isConfigFilePresent(), exists: ${configExists}`
398 | );
399 |
400 | if (!configExists) {
401 | throw new Error(CONFIG_MISSING_ERROR);
402 | }
403 |
404 | // Validate role
405 | if (!['main', 'research', 'fallback'].includes(role)) {
406 | return {
407 | success: false,
408 | error: {
409 | code: 'INVALID_ROLE',
410 | message: `Invalid role: ${role}. Must be one of: main, research, fallback.`
411 | }
412 | };
413 | }
414 |
415 | // Validate model ID
416 | if (typeof modelId !== 'string' || modelId.trim() === '') {
417 | return {
418 | success: false,
419 | error: {
420 | code: 'INVALID_MODEL_ID',
421 | message: `Invalid model ID: ${modelId}. Must be a non-empty string.`
422 | }
423 | };
424 | }
425 |
426 | try {
427 | const availableModels = getAvailableModels(projectRoot);
428 | const currentConfig = getConfig(projectRoot);
429 | let determinedProvider = null; // Initialize provider
430 | let warningMessage = null;
431 |
432 | // Find the model data in internal list
433 | // If we have a provider hint, search for exact provider+model match
434 | // Otherwise, just search by model ID (will get first match)
435 | let modelData;
436 | if (providerHint) {
437 | // Search for model with specific provider
438 | modelData = availableModels.find(
439 | (m) => m.id === modelId && m.provider === providerHint
440 | );
441 | } else {
442 | // Search by ID only
443 | modelData = availableModels.find((m) => m.id === modelId);
444 | }
445 |
446 | // --- Revised Logic: Prioritize providerHint --- //
447 |
448 | if (providerHint) {
449 | // Hint provided (from interactive setup or flag)
450 | if (modelData && modelData.provider === providerHint) {
451 | // Found internally with matching provider
452 | determinedProvider = providerHint;
453 | report(
454 | 'info',
455 | `Model ${modelId} found internally with provider ${determinedProvider}.`
456 | );
457 | } else {
458 | // Either not found internally, OR found but under a DIFFERENT provider than hinted.
459 | // Proceed with custom logic based ONLY on the hint.
460 | if (providerHint === CUSTOM_PROVIDERS.OPENROUTER) {
461 | // Check OpenRouter ONLY because hint was openrouter
462 | report('info', `Checking OpenRouter for ${modelId} (as hinted)...`);
463 | const openRouterModels = await fetchOpenRouterModels();
464 |
465 | if (
466 | openRouterModels &&
467 | openRouterModels.some((m) => m.id === modelId)
468 | ) {
469 | determinedProvider = CUSTOM_PROVIDERS.OPENROUTER;
470 |
471 | // Check if this is a free model (ends with :free)
472 | if (modelId.endsWith(':free')) {
473 | warningMessage = `Warning: OpenRouter free model '${modelId}' selected. Free models have significant limitations including lower context windows, reduced rate limits, and may not support advanced features like tool_use. Consider using the paid version '${modelId.replace(':free', '')}' for full functionality.`;
474 | } else {
475 | warningMessage = `Warning: Custom OpenRouter model '${modelId}' set. This model is not officially validated by Taskmaster and may not function as expected.`;
476 | }
477 |
478 | report('warn', warningMessage);
479 | } else {
480 | // Hinted as OpenRouter but not found in live check
481 | throw new Error(
482 | `Model ID "${modelId}" not found in the live OpenRouter model list. Please verify the ID and ensure it's available on OpenRouter.`
483 | );
484 | }
485 | } else if (providerHint === CUSTOM_PROVIDERS.OLLAMA) {
486 | // Check Ollama ONLY because hint was ollama
487 | report('info', `Checking Ollama for ${modelId} (as hinted)...`);
488 |
489 | // Get current provider for this role to check if we should preserve baseURL
490 | let currentProvider;
491 | if (role === 'main') {
492 | currentProvider = getMainProvider(projectRoot);
493 | } else if (role === 'research') {
494 | currentProvider = getResearchProvider(projectRoot);
495 | } else if (role === 'fallback') {
496 | currentProvider = getFallbackProvider(projectRoot);
497 | }
498 |
499 | // Only preserve baseURL if we're already using OLLAMA
500 | const existingBaseURL =
501 | currentProvider === CUSTOM_PROVIDERS.OLLAMA
502 | ? getBaseUrlForRole(role, projectRoot)
503 | : null;
504 |
505 | // Get the Ollama base URL - use provided, existing, or default
506 | const ollamaBaseURL =
507 | baseURL || existingBaseURL || 'http://localhost:11434/api';
508 | const ollamaModels = await fetchOllamaModels(ollamaBaseURL);
509 |
510 | if (ollamaModels === null) {
511 | // Connection failed - server probably not running
512 | throw new Error(
513 | `Unable to connect to Ollama server at ${ollamaBaseURL}. Please ensure Ollama is running and try again.`
514 | );
515 | } else if (ollamaModels.some((m) => m.model === modelId)) {
516 | determinedProvider = CUSTOM_PROVIDERS.OLLAMA;
517 | warningMessage = `Warning: Custom Ollama model '${modelId}' set. Ensure your Ollama server is running and has pulled this model. Taskmaster cannot guarantee compatibility.`;
518 | report('warn', warningMessage);
519 | // Store the computed baseURL so it gets saved in config
520 | computedBaseURL = ollamaBaseURL;
521 | } else {
522 | // Server is running but model not found
523 | const tagsUrl = `${ollamaBaseURL}/tags`;
524 | throw new Error(
525 | `Model ID "${modelId}" not found in the Ollama instance. Please verify the model is pulled and available. You can check available models with: curl ${tagsUrl}`
526 | );
527 | }
528 | } else if (providerHint === CUSTOM_PROVIDERS.BEDROCK) {
529 | // Set provider without model validation since Bedrock models are managed by AWS
530 | determinedProvider = CUSTOM_PROVIDERS.BEDROCK;
531 | warningMessage = `Warning: Custom Bedrock model '${modelId}' set. Please ensure the model ID is valid and accessible in your AWS account.`;
532 | report('warn', warningMessage);
533 | } else if (providerHint === CUSTOM_PROVIDERS.CLAUDE_CODE) {
534 | // Claude Code provider - check if model exists in our list
535 | determinedProvider = CUSTOM_PROVIDERS.CLAUDE_CODE;
536 | // Re-find modelData specifically for claude-code provider
537 | const claudeCodeModels = availableModels.filter(
538 | (m) => m.provider === 'claude-code'
539 | );
540 | const claudeCodeModelData = claudeCodeModels.find(
541 | (m) => m.id === modelId
542 | );
543 | if (claudeCodeModelData) {
544 | // Update modelData to the found claude-code model
545 | modelData = claudeCodeModelData;
546 | report('info', `Setting Claude Code model '${modelId}'.`);
547 | } else {
548 | warningMessage = `Warning: Claude Code model '${modelId}' not found in supported models. Setting without validation.`;
549 | report('warn', warningMessage);
550 | }
551 | } else if (providerHint === CUSTOM_PROVIDERS.AZURE) {
552 | // Set provider without model validation since Azure models are managed by Azure
553 | determinedProvider = CUSTOM_PROVIDERS.AZURE;
554 | warningMessage = `Warning: Custom Azure model '${modelId}' set. Please ensure the model deployment is valid and accessible in your Azure account.`;
555 | report('warn', warningMessage);
556 | } else if (providerHint === CUSTOM_PROVIDERS.VERTEX) {
557 | // Set provider without model validation since Vertex models are managed by Google Cloud
558 | determinedProvider = CUSTOM_PROVIDERS.VERTEX;
559 | warningMessage = `Warning: Custom Vertex AI model '${modelId}' set. Please ensure the model is valid and accessible in your Google Cloud project.`;
560 | report('warn', warningMessage);
561 | } else if (providerHint === CUSTOM_PROVIDERS.GEMINI_CLI) {
562 | // Gemini CLI provider - check if model exists in our list
563 | determinedProvider = CUSTOM_PROVIDERS.GEMINI_CLI;
564 | // Re-find modelData specifically for gemini-cli provider
565 | const geminiCliModels = availableModels.filter(
566 | (m) => m.provider === 'gemini-cli'
567 | );
568 | const geminiCliModelData = geminiCliModels.find(
569 | (m) => m.id === modelId
570 | );
571 | if (geminiCliModelData) {
572 | // Update modelData to the found gemini-cli model
573 | modelData = geminiCliModelData;
574 | report('info', `Setting Gemini CLI model '${modelId}'.`);
575 | } else {
576 | warningMessage = `Warning: Gemini CLI model '${modelId}' not found in supported models. Setting without validation.`;
577 | report('warn', warningMessage);
578 | }
579 | } else if (providerHint === CUSTOM_PROVIDERS.CODEX_CLI) {
580 | // Codex CLI provider - enforce supported model list
581 | determinedProvider = CUSTOM_PROVIDERS.CODEX_CLI;
582 | const codexCliModels = availableModels.filter(
583 | (m) => m.provider === 'codex-cli'
584 | );
585 | const codexCliModelData = codexCliModels.find(
586 | (m) => m.id === modelId
587 | );
588 | if (codexCliModelData) {
589 | modelData = codexCliModelData;
590 | report('info', `Setting Codex CLI model '${modelId}'.`);
591 | } else {
592 | warningMessage = `Warning: Codex CLI model '${modelId}' not found in supported models. Setting without validation.`;
593 | report('warn', warningMessage);
594 | }
595 | } else if (providerHint === CUSTOM_PROVIDERS.LMSTUDIO) {
596 | // LM Studio provider - set without validation since it's a local server
597 | determinedProvider = CUSTOM_PROVIDERS.LMSTUDIO;
598 |
599 | // Get current provider for this role to check if we should preserve baseURL
600 | let currentProvider;
601 | if (role === 'main') {
602 | currentProvider = getMainProvider(projectRoot);
603 | } else if (role === 'research') {
604 | currentProvider = getResearchProvider(projectRoot);
605 | } else if (role === 'fallback') {
606 | currentProvider = getFallbackProvider(projectRoot);
607 | }
608 |
609 | // Only preserve baseURL if we're already using LMSTUDIO
610 | const existingBaseURL =
611 | currentProvider === CUSTOM_PROVIDERS.LMSTUDIO
612 | ? getBaseUrlForRole(role, projectRoot)
613 | : null;
614 |
615 | const lmStudioBaseURL =
616 | baseURL || existingBaseURL || 'http://localhost:1234/v1';
617 | warningMessage = `Warning: Custom LM Studio model '${modelId}' set with base URL '${lmStudioBaseURL}'. Please ensure LM Studio server is running and has loaded this model. Taskmaster cannot guarantee compatibility.`;
618 | report('warn', warningMessage);
619 | // Store the computed baseURL so it gets saved in config
620 | computedBaseURL = lmStudioBaseURL;
621 | } else if (providerHint === CUSTOM_PROVIDERS.OPENAI_COMPATIBLE) {
622 | // OpenAI-compatible provider - set without validation, requires baseURL
623 | determinedProvider = CUSTOM_PROVIDERS.OPENAI_COMPATIBLE;
624 |
625 | // Get current provider for this role to check if we should preserve baseURL
626 | let currentProvider;
627 | if (role === 'main') {
628 | currentProvider = getMainProvider(projectRoot);
629 | } else if (role === 'research') {
630 | currentProvider = getResearchProvider(projectRoot);
631 | } else if (role === 'fallback') {
632 | currentProvider = getFallbackProvider(projectRoot);
633 | }
634 |
635 | // Only preserve baseURL if we're already using OPENAI_COMPATIBLE
636 | const existingBaseURL =
637 | currentProvider === CUSTOM_PROVIDERS.OPENAI_COMPATIBLE
638 | ? getBaseUrlForRole(role, projectRoot)
639 | : null;
640 |
641 | const resolvedBaseURL = baseURL || existingBaseURL;
642 | if (!resolvedBaseURL) {
643 | throw new Error(
644 | `Base URL is required for OpenAI-compatible providers. Please provide a baseURL.`
645 | );
646 | }
647 | warningMessage = `Warning: Custom OpenAI-compatible model '${modelId}' set with base URL '${resolvedBaseURL}'. Taskmaster cannot guarantee compatibility. Ensure your API endpoint follows the OpenAI API specification.`;
648 | report('warn', warningMessage);
649 | // Store the computed baseURL so it gets saved in config
650 | computedBaseURL = resolvedBaseURL;
651 | } else {
652 | // Invalid provider hint - should not happen with our constants
653 | throw new Error(`Invalid provider hint received: ${providerHint}`);
654 | }
655 | }
656 | } else {
657 | // No hint provided (flags not used)
658 | if (modelData) {
659 | // Found internally, use the provider from the internal list
660 | determinedProvider = modelData.provider;
661 | report(
662 | 'info',
663 | `Model ${modelId} found internally with provider ${determinedProvider}.`
664 | );
665 | } else {
666 | // Model not found and no provider hint was given
667 | return {
668 | success: false,
669 | error: {
670 | code: 'MODEL_NOT_FOUND_NO_HINT',
671 | message: `Model ID "${modelId}" not found in Taskmaster's supported models. If this is a custom model, please specify the provider using --openrouter, --ollama, --bedrock, --azure, --vertex, --lmstudio, --openai-compatible, --gemini-cli, or --codex-cli.`
672 | }
673 | };
674 | }
675 | }
676 |
677 | // --- End of Revised Logic --- //
678 |
679 | // At this point, we should have a determinedProvider if the model is valid (internally or custom)
680 | if (!determinedProvider) {
681 | // This case acts as a safeguard
682 | return {
683 | success: false,
684 | error: {
685 | code: 'PROVIDER_UNDETERMINED',
686 | message: `Could not determine the provider for model ID "${modelId}".`
687 | }
688 | };
689 | }
690 |
691 | // Update configuration
692 | currentConfig.models[role] = {
693 | ...currentConfig.models[role], // Keep existing params like temperature
694 | provider: determinedProvider,
695 | modelId: modelId
696 | };
697 |
698 | // Handle baseURL for providers that support it
699 | if (
700 | computedBaseURL &&
701 | (determinedProvider === CUSTOM_PROVIDERS.OPENAI_COMPATIBLE ||
702 | determinedProvider === CUSTOM_PROVIDERS.LMSTUDIO ||
703 | determinedProvider === CUSTOM_PROVIDERS.OLLAMA)
704 | ) {
705 | currentConfig.models[role].baseURL = computedBaseURL;
706 | } else {
707 | // Remove baseURL when switching to a provider that doesn't need it
708 | delete currentConfig.models[role].baseURL;
709 | }
710 |
711 | // If model data is available, update maxTokens from supported-models.json
712 | if (modelData && modelData.max_tokens) {
713 | currentConfig.models[role].maxTokens = modelData.max_tokens;
714 | }
715 |
716 | // Write updated configuration
717 | const writeResult = writeConfig(currentConfig, projectRoot);
718 | if (!writeResult) {
719 | return {
720 | success: false,
721 | error: {
722 | code: 'CONFIG_WRITE_ERROR',
723 | message: 'Error writing updated configuration to configuration file'
724 | }
725 | };
726 | }
727 |
728 | const successMessage = `Successfully set ${role} model to ${modelId} (Provider: ${determinedProvider})`;
729 | report('info', successMessage);
730 |
731 | return {
732 | success: true,
733 | data: {
734 | role,
735 | provider: determinedProvider,
736 | modelId,
737 | message: successMessage,
738 | warning: warningMessage // Include warning in the response data
739 | }
740 | };
741 | } catch (error) {
742 | report('error', `Error setting ${role} model: ${error.message}`);
743 | return {
744 | success: false,
745 | error: {
746 | code: 'SET_MODEL_ERROR',
747 | message: error.message
748 | }
749 | };
750 | }
751 | }
752 |
753 | /**
754 | * Get API key status for all known providers.
755 | * @param {Object} [options] - Options for the operation
756 | * @param {Object} [options.session] - Session object containing environment variables (for MCP)
757 | * @param {Function} [options.mcpLog] - MCP logger object (for MCP)
758 | * @param {string} [options.projectRoot] - Project root directory
759 | * @returns {Object} RESTful response with API key status report
760 | */
761 | async function getApiKeyStatusReport(options = {}) {
762 | const { mcpLog, projectRoot, session } = options;
763 | const report = (level, ...args) => {
764 | if (mcpLog && typeof mcpLog[level] === 'function') {
765 | mcpLog[level](...args);
766 | }
767 | };
768 |
769 | try {
770 | const providers = getAllProviders();
771 | const providersToCheck = providers.filter(
772 | (p) => p.toLowerCase() !== 'ollama'
773 | ); // Ollama is not a provider, it's a service, doesn't need an api key usually
774 | const statusReport = providersToCheck.map((provider) => {
775 | // Use provided projectRoot for MCP status check
776 | const cliOk = isApiKeySet(provider, session, projectRoot); // Pass session and projectRoot for CLI check
777 | const mcpOk = getMcpApiKeyStatus(provider, projectRoot);
778 | return {
779 | provider,
780 | cli: cliOk,
781 | mcp: mcpOk
782 | };
783 | });
784 |
785 | report('info', 'Successfully generated API key status report.');
786 | return {
787 | success: true,
788 | data: {
789 | report: statusReport,
790 | message: 'API key status report generated.'
791 | }
792 | };
793 | } catch (error) {
794 | report('error', `Error generating API key status report: ${error.message}`);
795 | return {
796 | success: false,
797 | error: {
798 | code: 'API_KEY_STATUS_ERROR',
799 | message: error.message
800 | }
801 | };
802 | }
803 | }
804 |
805 | export {
806 | getModelConfiguration,
807 | getAvailableModelsList,
808 | setModel,
809 | getApiKeyStatusReport
810 | };
811 |
```
--------------------------------------------------------------------------------
/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 |
```