This is page 28 of 52. Use http://codebase.md/eyaltoledano/claude-task-master?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .changeset │ ├── config.json │ └── README.md ├── .claude │ ├── agents │ │ ├── task-checker.md │ │ ├── task-executor.md │ │ └── task-orchestrator.md │ ├── commands │ │ ├── dedupe.md │ │ └── tm │ │ ├── add-dependency │ │ │ └── add-dependency.md │ │ ├── add-subtask │ │ │ ├── add-subtask.md │ │ │ └── convert-task-to-subtask.md │ │ ├── add-task │ │ │ └── add-task.md │ │ ├── analyze-complexity │ │ │ └── analyze-complexity.md │ │ ├── complexity-report │ │ │ └── complexity-report.md │ │ ├── expand │ │ │ ├── expand-all-tasks.md │ │ │ └── expand-task.md │ │ ├── fix-dependencies │ │ │ └── fix-dependencies.md │ │ ├── generate │ │ │ └── generate-tasks.md │ │ ├── help.md │ │ ├── init │ │ │ ├── init-project-quick.md │ │ │ └── init-project.md │ │ ├── learn.md │ │ ├── list │ │ │ ├── list-tasks-by-status.md │ │ │ ├── list-tasks-with-subtasks.md │ │ │ └── list-tasks.md │ │ ├── models │ │ │ ├── setup-models.md │ │ │ └── view-models.md │ │ ├── next │ │ │ └── next-task.md │ │ ├── parse-prd │ │ │ ├── parse-prd-with-research.md │ │ │ └── parse-prd.md │ │ ├── remove-dependency │ │ │ └── remove-dependency.md │ │ ├── remove-subtask │ │ │ └── remove-subtask.md │ │ ├── remove-subtasks │ │ │ ├── remove-all-subtasks.md │ │ │ └── remove-subtasks.md │ │ ├── remove-task │ │ │ └── remove-task.md │ │ ├── set-status │ │ │ ├── to-cancelled.md │ │ │ ├── to-deferred.md │ │ │ ├── to-done.md │ │ │ ├── to-in-progress.md │ │ │ ├── to-pending.md │ │ │ └── to-review.md │ │ ├── setup │ │ │ ├── install-taskmaster.md │ │ │ └── quick-install-taskmaster.md │ │ ├── show │ │ │ └── show-task.md │ │ ├── status │ │ │ └── project-status.md │ │ ├── sync-readme │ │ │ └── sync-readme.md │ │ ├── tm-main.md │ │ ├── update │ │ │ ├── update-single-task.md │ │ │ ├── update-task.md │ │ │ └── update-tasks-from-id.md │ │ ├── utils │ │ │ └── analyze-project.md │ │ ├── validate-dependencies │ │ │ └── validate-dependencies.md │ │ └── workflows │ │ ├── auto-implement-tasks.md │ │ ├── command-pipeline.md │ │ └── smart-workflow.md │ └── TM_COMMANDS_GUIDE.md ├── .coderabbit.yaml ├── .cursor │ ├── mcp.json │ └── rules │ ├── ai_providers.mdc │ ├── ai_services.mdc │ ├── architecture.mdc │ ├── changeset.mdc │ ├── commands.mdc │ ├── context_gathering.mdc │ ├── cursor_rules.mdc │ ├── dependencies.mdc │ ├── dev_workflow.mdc │ ├── git_workflow.mdc │ ├── glossary.mdc │ ├── mcp.mdc │ ├── new_features.mdc │ ├── self_improve.mdc │ ├── tags.mdc │ ├── taskmaster.mdc │ ├── tasks.mdc │ ├── telemetry.mdc │ ├── test_workflow.mdc │ ├── tests.mdc │ ├── ui.mdc │ └── utilities.mdc ├── .cursorignore ├── .env.example ├── .github │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.md │ │ ├── enhancements---feature-requests.md │ │ └── feedback.md │ ├── PULL_REQUEST_TEMPLATE │ │ ├── bugfix.md │ │ ├── config.yml │ │ ├── feature.md │ │ └── integration.md │ ├── PULL_REQUEST_TEMPLATE.md │ ├── scripts │ │ ├── auto-close-duplicates.mjs │ │ ├── backfill-duplicate-comments.mjs │ │ ├── check-pre-release-mode.mjs │ │ ├── parse-metrics.mjs │ │ ├── release.mjs │ │ ├── tag-extension.mjs │ │ └── utils.mjs │ └── workflows │ ├── auto-close-duplicates.yml │ ├── backfill-duplicate-comments.yml │ ├── ci.yml │ ├── claude-dedupe-issues.yml │ ├── claude-docs-trigger.yml │ ├── claude-docs-updater.yml │ ├── claude-issue-triage.yml │ ├── claude.yml │ ├── extension-ci.yml │ ├── extension-release.yml │ ├── log-issue-events.yml │ ├── pre-release.yml │ ├── release-check.yml │ ├── release.yml │ ├── update-models-md.yml │ └── weekly-metrics-discord.yml ├── .gitignore ├── .kiro │ ├── hooks │ │ ├── tm-code-change-task-tracker.kiro.hook │ │ ├── tm-complexity-analyzer.kiro.hook │ │ ├── tm-daily-standup-assistant.kiro.hook │ │ ├── tm-git-commit-task-linker.kiro.hook │ │ ├── tm-pr-readiness-checker.kiro.hook │ │ ├── tm-task-dependency-auto-progression.kiro.hook │ │ └── tm-test-success-task-completer.kiro.hook │ ├── settings │ │ └── mcp.json │ └── steering │ ├── dev_workflow.md │ ├── kiro_rules.md │ ├── self_improve.md │ ├── taskmaster_hooks_workflow.md │ └── taskmaster.md ├── .manypkg.json ├── .mcp.json ├── .npmignore ├── .nvmrc ├── .taskmaster │ ├── CLAUDE.md │ ├── config.json │ ├── docs │ │ ├── MIGRATION-ROADMAP.md │ │ ├── prd-tm-start.txt │ │ ├── prd.txt │ │ ├── README.md │ │ ├── research │ │ │ ├── 2025-06-14_how-can-i-improve-the-scope-up-and-scope-down-comm.md │ │ │ ├── 2025-06-14_should-i-be-using-any-specific-libraries-for-this.md │ │ │ ├── 2025-06-14_test-save-functionality.md │ │ │ ├── 2025-06-14_test-the-fix-for-duplicate-saves-final-test.md │ │ │ └── 2025-08-01_do-we-need-to-add-new-commands-or-can-we-just-weap.md │ │ ├── task-template-importing-prd.txt │ │ ├── test-prd.txt │ │ └── tm-core-phase-1.txt │ ├── reports │ │ ├── task-complexity-report_cc-kiro-hooks.json │ │ ├── task-complexity-report_test-prd-tag.json │ │ ├── task-complexity-report_tm-core-phase-1.json │ │ ├── task-complexity-report.json │ │ └── tm-core-complexity.json │ ├── state.json │ ├── tasks │ │ ├── task_001_tm-start.txt │ │ ├── task_002_tm-start.txt │ │ ├── task_003_tm-start.txt │ │ ├── task_004_tm-start.txt │ │ ├── task_007_tm-start.txt │ │ └── tasks.json │ └── templates │ └── example_prd.txt ├── .vscode │ ├── extensions.json │ └── settings.json ├── apps │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── auth.command.ts │ │ │ │ ├── context.command.ts │ │ │ │ ├── list.command.ts │ │ │ │ ├── set-status.command.ts │ │ │ │ ├── show.command.ts │ │ │ │ └── start.command.ts │ │ │ ├── index.ts │ │ │ ├── ui │ │ │ │ ├── components │ │ │ │ │ ├── dashboard.component.ts │ │ │ │ │ ├── header.component.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── next-task.component.ts │ │ │ │ │ ├── suggested-steps.component.ts │ │ │ │ │ └── task-detail.component.ts │ │ │ │ └── index.ts │ │ │ └── utils │ │ │ ├── auto-update.ts │ │ │ └── ui.ts │ │ └── tsconfig.json │ ├── docs │ │ ├── archive │ │ │ ├── ai-client-utils-example.mdx │ │ │ ├── ai-development-workflow.mdx │ │ │ ├── command-reference.mdx │ │ │ ├── configuration.mdx │ │ │ ├── cursor-setup.mdx │ │ │ ├── examples.mdx │ │ │ └── Installation.mdx │ │ ├── best-practices │ │ │ ├── advanced-tasks.mdx │ │ │ ├── configuration-advanced.mdx │ │ │ └── index.mdx │ │ ├── capabilities │ │ │ ├── cli-root-commands.mdx │ │ │ ├── index.mdx │ │ │ ├── mcp.mdx │ │ │ └── task-structure.mdx │ │ ├── CHANGELOG.md │ │ ├── docs.json │ │ ├── favicon.svg │ │ ├── getting-started │ │ │ ├── contribute.mdx │ │ │ ├── faq.mdx │ │ │ └── quick-start │ │ │ ├── configuration-quick.mdx │ │ │ ├── execute-quick.mdx │ │ │ ├── installation.mdx │ │ │ ├── moving-forward.mdx │ │ │ ├── prd-quick.mdx │ │ │ ├── quick-start.mdx │ │ │ ├── requirements.mdx │ │ │ ├── rules-quick.mdx │ │ │ └── tasks-quick.mdx │ │ ├── introduction.mdx │ │ ├── licensing.md │ │ ├── logo │ │ │ ├── dark.svg │ │ │ ├── light.svg │ │ │ └── task-master-logo.png │ │ ├── package.json │ │ ├── README.md │ │ ├── style.css │ │ ├── vercel.json │ │ └── whats-new.mdx │ └── extension │ ├── .vscodeignore │ ├── assets │ │ ├── banner.png │ │ ├── icon-dark.svg │ │ ├── icon-light.svg │ │ ├── icon.png │ │ ├── screenshots │ │ │ ├── kanban-board.png │ │ │ └── task-details.png │ │ └── sidebar-icon.svg │ ├── CHANGELOG.md │ ├── components.json │ ├── docs │ │ ├── extension-CI-setup.md │ │ └── extension-development-guide.md │ ├── esbuild.js │ ├── LICENSE │ ├── package.json │ ├── package.mjs │ ├── package.publish.json │ ├── README.md │ ├── src │ │ ├── components │ │ │ ├── ConfigView.tsx │ │ │ ├── constants.ts │ │ │ ├── TaskDetails │ │ │ │ ├── AIActionsSection.tsx │ │ │ │ ├── DetailsSection.tsx │ │ │ │ ├── PriorityBadge.tsx │ │ │ │ ├── SubtasksSection.tsx │ │ │ │ ├── TaskMetadataSidebar.tsx │ │ │ │ └── useTaskDetails.ts │ │ │ ├── TaskDetailsView.tsx │ │ │ ├── TaskMasterLogo.tsx │ │ │ └── ui │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── CollapsibleSection.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── label.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── separator.tsx │ │ │ ├── shadcn-io │ │ │ │ └── kanban │ │ │ │ └── index.tsx │ │ │ └── textarea.tsx │ │ ├── extension.ts │ │ ├── index.ts │ │ ├── lib │ │ │ └── utils.ts │ │ ├── services │ │ │ ├── config-service.ts │ │ │ ├── error-handler.ts │ │ │ ├── notification-preferences.ts │ │ │ ├── polling-service.ts │ │ │ ├── polling-strategies.ts │ │ │ ├── sidebar-webview-manager.ts │ │ │ ├── task-repository.ts │ │ │ ├── terminal-manager.ts │ │ │ └── webview-manager.ts │ │ ├── test │ │ │ └── extension.test.ts │ │ ├── utils │ │ │ ├── configManager.ts │ │ │ ├── connectionManager.ts │ │ │ ├── errorHandler.ts │ │ │ ├── event-emitter.ts │ │ │ ├── logger.ts │ │ │ ├── mcpClient.ts │ │ │ ├── notificationPreferences.ts │ │ │ └── task-master-api │ │ │ ├── cache │ │ │ │ └── cache-manager.ts │ │ │ ├── index.ts │ │ │ ├── mcp-client.ts │ │ │ ├── transformers │ │ │ │ └── task-transformer.ts │ │ │ └── types │ │ │ └── index.ts │ │ └── webview │ │ ├── App.tsx │ │ ├── components │ │ │ ├── AppContent.tsx │ │ │ ├── EmptyState.tsx │ │ │ ├── ErrorBoundary.tsx │ │ │ ├── PollingStatus.tsx │ │ │ ├── PriorityBadge.tsx │ │ │ ├── SidebarView.tsx │ │ │ ├── TagDropdown.tsx │ │ │ ├── TaskCard.tsx │ │ │ ├── TaskEditModal.tsx │ │ │ ├── TaskMasterKanban.tsx │ │ │ ├── ToastContainer.tsx │ │ │ └── ToastNotification.tsx │ │ ├── constants │ │ │ └── index.ts │ │ ├── contexts │ │ │ └── VSCodeContext.tsx │ │ ├── hooks │ │ │ ├── useTaskQueries.ts │ │ │ ├── useVSCodeMessages.ts │ │ │ └── useWebviewHeight.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── providers │ │ │ └── QueryProvider.tsx │ │ ├── reducers │ │ │ └── appReducer.ts │ │ ├── sidebar.tsx │ │ ├── types │ │ │ └── index.ts │ │ └── utils │ │ ├── logger.ts │ │ └── toast.ts │ └── tsconfig.json ├── assets │ ├── .windsurfrules │ ├── AGENTS.md │ ├── claude │ │ ├── agents │ │ │ ├── task-checker.md │ │ │ ├── task-executor.md │ │ │ └── task-orchestrator.md │ │ ├── commands │ │ │ └── tm │ │ │ ├── add-dependency │ │ │ │ └── add-dependency.md │ │ │ ├── add-subtask │ │ │ │ ├── add-subtask.md │ │ │ │ └── convert-task-to-subtask.md │ │ │ ├── add-task │ │ │ │ └── add-task.md │ │ │ ├── analyze-complexity │ │ │ │ └── analyze-complexity.md │ │ │ ├── clear-subtasks │ │ │ │ ├── clear-all-subtasks.md │ │ │ │ └── clear-subtasks.md │ │ │ ├── complexity-report │ │ │ │ └── complexity-report.md │ │ │ ├── expand │ │ │ │ ├── expand-all-tasks.md │ │ │ │ └── expand-task.md │ │ │ ├── fix-dependencies │ │ │ │ └── fix-dependencies.md │ │ │ ├── generate │ │ │ │ └── generate-tasks.md │ │ │ ├── help.md │ │ │ ├── init │ │ │ │ ├── init-project-quick.md │ │ │ │ └── init-project.md │ │ │ ├── learn.md │ │ │ ├── list │ │ │ │ ├── list-tasks-by-status.md │ │ │ │ ├── list-tasks-with-subtasks.md │ │ │ │ └── list-tasks.md │ │ │ ├── models │ │ │ │ ├── setup-models.md │ │ │ │ └── view-models.md │ │ │ ├── next │ │ │ │ └── next-task.md │ │ │ ├── parse-prd │ │ │ │ ├── parse-prd-with-research.md │ │ │ │ └── parse-prd.md │ │ │ ├── remove-dependency │ │ │ │ └── remove-dependency.md │ │ │ ├── remove-subtask │ │ │ │ └── remove-subtask.md │ │ │ ├── remove-subtasks │ │ │ │ ├── remove-all-subtasks.md │ │ │ │ └── remove-subtasks.md │ │ │ ├── remove-task │ │ │ │ └── remove-task.md │ │ │ ├── set-status │ │ │ │ ├── to-cancelled.md │ │ │ │ ├── to-deferred.md │ │ │ │ ├── to-done.md │ │ │ │ ├── to-in-progress.md │ │ │ │ ├── to-pending.md │ │ │ │ └── to-review.md │ │ │ ├── setup │ │ │ │ ├── install-taskmaster.md │ │ │ │ └── quick-install-taskmaster.md │ │ │ ├── show │ │ │ │ └── show-task.md │ │ │ ├── status │ │ │ │ └── project-status.md │ │ │ ├── sync-readme │ │ │ │ └── sync-readme.md │ │ │ ├── tm-main.md │ │ │ ├── update │ │ │ │ ├── update-single-task.md │ │ │ │ ├── update-task.md │ │ │ │ └── update-tasks-from-id.md │ │ │ ├── utils │ │ │ │ └── analyze-project.md │ │ │ ├── validate-dependencies │ │ │ │ └── validate-dependencies.md │ │ │ └── workflows │ │ │ ├── auto-implement-tasks.md │ │ │ ├── command-pipeline.md │ │ │ └── smart-workflow.md │ │ └── TM_COMMANDS_GUIDE.md │ ├── config.json │ ├── env.example │ ├── example_prd.txt │ ├── gitignore │ ├── kiro-hooks │ │ ├── tm-code-change-task-tracker.kiro.hook │ │ ├── tm-complexity-analyzer.kiro.hook │ │ ├── tm-daily-standup-assistant.kiro.hook │ │ ├── tm-git-commit-task-linker.kiro.hook │ │ ├── tm-pr-readiness-checker.kiro.hook │ │ ├── tm-task-dependency-auto-progression.kiro.hook │ │ └── tm-test-success-task-completer.kiro.hook │ ├── roocode │ │ ├── .roo │ │ │ ├── rules-architect │ │ │ │ └── architect-rules │ │ │ ├── rules-ask │ │ │ │ └── ask-rules │ │ │ ├── rules-code │ │ │ │ └── code-rules │ │ │ ├── rules-debug │ │ │ │ └── debug-rules │ │ │ ├── rules-orchestrator │ │ │ │ └── orchestrator-rules │ │ │ └── rules-test │ │ │ └── test-rules │ │ └── .roomodes │ ├── rules │ │ ├── cursor_rules.mdc │ │ ├── dev_workflow.mdc │ │ ├── self_improve.mdc │ │ ├── taskmaster_hooks_workflow.mdc │ │ └── taskmaster.mdc │ └── scripts_README.md ├── bin │ └── task-master.js ├── biome.json ├── CHANGELOG.md ├── CLAUDE.md ├── context │ ├── chats │ │ ├── add-task-dependencies-1.md │ │ └── max-min-tokens.txt.md │ ├── fastmcp-core.txt │ ├── fastmcp-docs.txt │ ├── MCP_INTEGRATION.md │ ├── mcp-js-sdk-docs.txt │ ├── mcp-protocol-repo.txt │ ├── mcp-protocol-schema-03262025.json │ └── mcp-protocol-spec.txt ├── CONTRIBUTING.md ├── docs │ ├── CLI-COMMANDER-PATTERN.md │ ├── command-reference.md │ ├── configuration.md │ ├── contributor-docs │ │ └── testing-roo-integration.md │ ├── cross-tag-task-movement.md │ ├── examples │ │ └── claude-code-usage.md │ ├── examples.md │ ├── licensing.md │ ├── mcp-provider-guide.md │ ├── mcp-provider.md │ ├── migration-guide.md │ ├── models.md │ ├── providers │ │ └── gemini-cli.md │ ├── README.md │ ├── scripts │ │ └── models-json-to-markdown.js │ ├── task-structure.md │ └── tutorial.md ├── images │ └── logo.png ├── index.js ├── jest.config.js ├── jest.resolver.cjs ├── LICENSE ├── llms-install.md ├── mcp-server │ ├── server.js │ └── src │ ├── core │ │ ├── __tests__ │ │ │ └── context-manager.test.js │ │ ├── context-manager.js │ │ ├── direct-functions │ │ │ ├── add-dependency.js │ │ │ ├── add-subtask.js │ │ │ ├── add-tag.js │ │ │ ├── add-task.js │ │ │ ├── analyze-task-complexity.js │ │ │ ├── cache-stats.js │ │ │ ├── clear-subtasks.js │ │ │ ├── complexity-report.js │ │ │ ├── copy-tag.js │ │ │ ├── create-tag-from-branch.js │ │ │ ├── delete-tag.js │ │ │ ├── expand-all-tasks.js │ │ │ ├── expand-task.js │ │ │ ├── fix-dependencies.js │ │ │ ├── generate-task-files.js │ │ │ ├── initialize-project.js │ │ │ ├── list-tags.js │ │ │ ├── list-tasks.js │ │ │ ├── models.js │ │ │ ├── move-task-cross-tag.js │ │ │ ├── move-task.js │ │ │ ├── next-task.js │ │ │ ├── parse-prd.js │ │ │ ├── remove-dependency.js │ │ │ ├── remove-subtask.js │ │ │ ├── remove-task.js │ │ │ ├── rename-tag.js │ │ │ ├── research.js │ │ │ ├── response-language.js │ │ │ ├── rules.js │ │ │ ├── scope-down.js │ │ │ ├── scope-up.js │ │ │ ├── set-task-status.js │ │ │ ├── show-task.js │ │ │ ├── update-subtask-by-id.js │ │ │ ├── update-task-by-id.js │ │ │ ├── update-tasks.js │ │ │ ├── use-tag.js │ │ │ └── validate-dependencies.js │ │ ├── task-master-core.js │ │ └── utils │ │ ├── env-utils.js │ │ └── path-utils.js │ ├── custom-sdk │ │ ├── errors.js │ │ ├── index.js │ │ ├── json-extractor.js │ │ ├── language-model.js │ │ ├── message-converter.js │ │ └── schema-converter.js │ ├── index.js │ ├── logger.js │ ├── providers │ │ └── mcp-provider.js │ └── tools │ ├── add-dependency.js │ ├── add-subtask.js │ ├── add-tag.js │ ├── add-task.js │ ├── analyze.js │ ├── clear-subtasks.js │ ├── complexity-report.js │ ├── copy-tag.js │ ├── delete-tag.js │ ├── expand-all.js │ ├── expand-task.js │ ├── fix-dependencies.js │ ├── generate.js │ ├── get-operation-status.js │ ├── get-task.js │ ├── get-tasks.js │ ├── index.js │ ├── initialize-project.js │ ├── list-tags.js │ ├── models.js │ ├── move-task.js │ ├── next-task.js │ ├── parse-prd.js │ ├── remove-dependency.js │ ├── remove-subtask.js │ ├── remove-task.js │ ├── rename-tag.js │ ├── research.js │ ├── response-language.js │ ├── rules.js │ ├── scope-down.js │ ├── scope-up.js │ ├── set-task-status.js │ ├── update-subtask.js │ ├── update-task.js │ ├── update.js │ ├── use-tag.js │ ├── utils.js │ └── validate-dependencies.js ├── mcp-test.js ├── output.json ├── package-lock.json ├── package.json ├── packages │ ├── build-config │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ └── tsdown.base.ts │ │ └── tsconfig.json │ └── tm-core │ ├── .gitignore │ ├── CHANGELOG.md │ ├── docs │ │ └── listTasks-architecture.md │ ├── package.json │ ├── POC-STATUS.md │ ├── README.md │ ├── src │ │ ├── auth │ │ │ ├── auth-manager.test.ts │ │ │ ├── auth-manager.ts │ │ │ ├── config.ts │ │ │ ├── credential-store.test.ts │ │ │ ├── credential-store.ts │ │ │ ├── index.ts │ │ │ ├── oauth-service.ts │ │ │ ├── supabase-session-storage.ts │ │ │ └── types.ts │ │ ├── clients │ │ │ ├── index.ts │ │ │ └── supabase-client.ts │ │ ├── config │ │ │ ├── config-manager.spec.ts │ │ │ ├── config-manager.ts │ │ │ ├── index.ts │ │ │ └── services │ │ │ ├── config-loader.service.spec.ts │ │ │ ├── config-loader.service.ts │ │ │ ├── config-merger.service.spec.ts │ │ │ ├── config-merger.service.ts │ │ │ ├── config-persistence.service.spec.ts │ │ │ ├── config-persistence.service.ts │ │ │ ├── environment-config-provider.service.spec.ts │ │ │ ├── environment-config-provider.service.ts │ │ │ ├── index.ts │ │ │ ├── runtime-state-manager.service.spec.ts │ │ │ └── runtime-state-manager.service.ts │ │ ├── constants │ │ │ └── index.ts │ │ ├── entities │ │ │ └── task.entity.ts │ │ ├── errors │ │ │ ├── index.ts │ │ │ └── task-master-error.ts │ │ ├── executors │ │ │ ├── base-executor.ts │ │ │ ├── claude-executor.ts │ │ │ ├── executor-factory.ts │ │ │ ├── executor-service.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── index.ts │ │ ├── interfaces │ │ │ ├── ai-provider.interface.ts │ │ │ ├── configuration.interface.ts │ │ │ ├── index.ts │ │ │ └── storage.interface.ts │ │ ├── logger │ │ │ ├── factory.ts │ │ │ ├── index.ts │ │ │ └── logger.ts │ │ ├── mappers │ │ │ └── TaskMapper.ts │ │ ├── parser │ │ │ └── index.ts │ │ ├── providers │ │ │ ├── ai │ │ │ │ ├── base-provider.ts │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── repositories │ │ │ ├── supabase-task-repository.ts │ │ │ └── task-repository.interface.ts │ │ ├── services │ │ │ ├── index.ts │ │ │ ├── organization.service.ts │ │ │ ├── task-execution-service.ts │ │ │ └── task-service.ts │ │ ├── storage │ │ │ ├── api-storage.ts │ │ │ ├── file-storage │ │ │ │ ├── file-operations.ts │ │ │ │ ├── file-storage.ts │ │ │ │ ├── format-handler.ts │ │ │ │ ├── index.ts │ │ │ │ └── path-resolver.ts │ │ │ ├── index.ts │ │ │ └── storage-factory.ts │ │ ├── subpath-exports.test.ts │ │ ├── task-master-core.ts │ │ ├── types │ │ │ ├── database.types.ts │ │ │ ├── index.ts │ │ │ └── legacy.ts │ │ └── utils │ │ ├── id-generator.ts │ │ └── index.ts │ ├── tests │ │ ├── integration │ │ │ └── list-tasks.test.ts │ │ ├── mocks │ │ │ └── mock-provider.ts │ │ ├── setup.ts │ │ └── unit │ │ ├── base-provider.test.ts │ │ ├── executor.test.ts │ │ └── smoke.test.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── README-task-master.md ├── README.md ├── scripts │ ├── dev.js │ ├── init.js │ ├── modules │ │ ├── ai-services-unified.js │ │ ├── commands.js │ │ ├── config-manager.js │ │ ├── dependency-manager.js │ │ ├── index.js │ │ ├── prompt-manager.js │ │ ├── supported-models.json │ │ ├── sync-readme.js │ │ ├── task-manager │ │ │ ├── add-subtask.js │ │ │ ├── add-task.js │ │ │ ├── analyze-task-complexity.js │ │ │ ├── clear-subtasks.js │ │ │ ├── expand-all-tasks.js │ │ │ ├── expand-task.js │ │ │ ├── find-next-task.js │ │ │ ├── generate-task-files.js │ │ │ ├── is-task-dependent.js │ │ │ ├── list-tasks.js │ │ │ ├── migrate.js │ │ │ ├── models.js │ │ │ ├── move-task.js │ │ │ ├── parse-prd │ │ │ │ ├── index.js │ │ │ │ ├── parse-prd-config.js │ │ │ │ ├── parse-prd-helpers.js │ │ │ │ ├── parse-prd-non-streaming.js │ │ │ │ ├── parse-prd-streaming.js │ │ │ │ └── parse-prd.js │ │ │ ├── remove-subtask.js │ │ │ ├── remove-task.js │ │ │ ├── research.js │ │ │ ├── response-language.js │ │ │ ├── scope-adjustment.js │ │ │ ├── set-task-status.js │ │ │ ├── tag-management.js │ │ │ ├── task-exists.js │ │ │ ├── update-single-task-status.js │ │ │ ├── update-subtask-by-id.js │ │ │ ├── update-task-by-id.js │ │ │ └── update-tasks.js │ │ ├── task-manager.js │ │ ├── ui.js │ │ ├── update-config-tokens.js │ │ ├── utils │ │ │ ├── contextGatherer.js │ │ │ ├── fuzzyTaskSearch.js │ │ │ └── git-utils.js │ │ └── utils.js │ ├── task-complexity-report.json │ ├── test-claude-errors.js │ └── test-claude.js ├── src │ ├── ai-providers │ │ ├── anthropic.js │ │ ├── azure.js │ │ ├── base-provider.js │ │ ├── bedrock.js │ │ ├── claude-code.js │ │ ├── custom-sdk │ │ │ ├── claude-code │ │ │ │ ├── errors.js │ │ │ │ ├── index.js │ │ │ │ ├── json-extractor.js │ │ │ │ ├── language-model.js │ │ │ │ ├── message-converter.js │ │ │ │ └── types.js │ │ │ └── grok-cli │ │ │ ├── errors.js │ │ │ ├── index.js │ │ │ ├── json-extractor.js │ │ │ ├── language-model.js │ │ │ ├── message-converter.js │ │ │ └── types.js │ │ ├── gemini-cli.js │ │ ├── google-vertex.js │ │ ├── google.js │ │ ├── grok-cli.js │ │ ├── groq.js │ │ ├── index.js │ │ ├── ollama.js │ │ ├── openai.js │ │ ├── openrouter.js │ │ ├── perplexity.js │ │ └── xai.js │ ├── constants │ │ ├── commands.js │ │ ├── paths.js │ │ ├── profiles.js │ │ ├── providers.js │ │ ├── rules-actions.js │ │ ├── task-priority.js │ │ └── task-status.js │ ├── profiles │ │ ├── amp.js │ │ ├── base-profile.js │ │ ├── claude.js │ │ ├── cline.js │ │ ├── codex.js │ │ ├── cursor.js │ │ ├── gemini.js │ │ ├── index.js │ │ ├── kilo.js │ │ ├── kiro.js │ │ ├── opencode.js │ │ ├── roo.js │ │ ├── trae.js │ │ ├── vscode.js │ │ ├── windsurf.js │ │ └── zed.js │ ├── progress │ │ ├── base-progress-tracker.js │ │ ├── cli-progress-factory.js │ │ ├── parse-prd-tracker.js │ │ ├── progress-tracker-builder.js │ │ └── tracker-ui.js │ ├── prompts │ │ ├── add-task.json │ │ ├── analyze-complexity.json │ │ ├── expand-task.json │ │ ├── parse-prd.json │ │ ├── README.md │ │ ├── research.json │ │ ├── schemas │ │ │ ├── parameter.schema.json │ │ │ ├── prompt-template.schema.json │ │ │ ├── README.md │ │ │ └── variant.schema.json │ │ ├── update-subtask.json │ │ ├── update-task.json │ │ └── update-tasks.json │ ├── provider-registry │ │ └── index.js │ ├── task-master.js │ ├── ui │ │ ├── confirm.js │ │ ├── indicators.js │ │ └── parse-prd.js │ └── utils │ ├── asset-resolver.js │ ├── create-mcp-config.js │ ├── format.js │ ├── getVersion.js │ ├── logger-utils.js │ ├── manage-gitignore.js │ ├── path-utils.js │ ├── profiles.js │ ├── rule-transformer.js │ ├── stream-parser.js │ └── timeout-manager.js ├── test-clean-tags.js ├── test-config-manager.js ├── test-prd.txt ├── test-tag-functions.js ├── test-version-check-full.js ├── test-version-check.js ├── tests │ ├── e2e │ │ ├── e2e_helpers.sh │ │ ├── parse_llm_output.cjs │ │ ├── run_e2e.sh │ │ ├── run_fallback_verification.sh │ │ └── test_llm_analysis.sh │ ├── fixture │ │ └── test-tasks.json │ ├── fixtures │ │ ├── .taskmasterconfig │ │ ├── sample-claude-response.js │ │ ├── sample-prd.txt │ │ └── sample-tasks.js │ ├── integration │ │ ├── claude-code-optional.test.js │ │ ├── cli │ │ │ ├── commands.test.js │ │ │ ├── complex-cross-tag-scenarios.test.js │ │ │ └── move-cross-tag.test.js │ │ ├── manage-gitignore.test.js │ │ ├── mcp-server │ │ │ └── direct-functions.test.js │ │ ├── move-task-cross-tag.integration.test.js │ │ ├── move-task-simple.integration.test.js │ │ └── profiles │ │ ├── amp-init-functionality.test.js │ │ ├── claude-init-functionality.test.js │ │ ├── cline-init-functionality.test.js │ │ ├── codex-init-functionality.test.js │ │ ├── cursor-init-functionality.test.js │ │ ├── gemini-init-functionality.test.js │ │ ├── opencode-init-functionality.test.js │ │ ├── roo-files-inclusion.test.js │ │ ├── roo-init-functionality.test.js │ │ ├── rules-files-inclusion.test.js │ │ ├── trae-init-functionality.test.js │ │ ├── vscode-init-functionality.test.js │ │ └── windsurf-init-functionality.test.js │ ├── manual │ │ ├── progress │ │ │ ├── parse-prd-analysis.js │ │ │ ├── test-parse-prd.js │ │ │ └── TESTING_GUIDE.md │ │ └── prompts │ │ ├── prompt-test.js │ │ └── README.md │ ├── README.md │ ├── setup.js │ └── unit │ ├── ai-providers │ │ ├── claude-code.test.js │ │ ├── custom-sdk │ │ │ └── claude-code │ │ │ └── language-model.test.js │ │ ├── gemini-cli.test.js │ │ ├── mcp-components.test.js │ │ └── openai.test.js │ ├── ai-services-unified.test.js │ ├── commands.test.js │ ├── config-manager.test.js │ ├── config-manager.test.mjs │ ├── dependency-manager.test.js │ ├── init.test.js │ ├── initialize-project.test.js │ ├── kebab-case-validation.test.js │ ├── manage-gitignore.test.js │ ├── mcp │ │ └── tools │ │ ├── __mocks__ │ │ │ └── move-task.js │ │ ├── add-task.test.js │ │ ├── analyze-complexity.test.js │ │ ├── expand-all.test.js │ │ ├── get-tasks.test.js │ │ ├── initialize-project.test.js │ │ ├── move-task-cross-tag-options.test.js │ │ ├── move-task-cross-tag.test.js │ │ └── remove-task.test.js │ ├── mcp-providers │ │ ├── mcp-components.test.js │ │ └── mcp-provider.test.js │ ├── parse-prd.test.js │ ├── profiles │ │ ├── amp-integration.test.js │ │ ├── claude-integration.test.js │ │ ├── cline-integration.test.js │ │ ├── codex-integration.test.js │ │ ├── cursor-integration.test.js │ │ ├── gemini-integration.test.js │ │ ├── kilo-integration.test.js │ │ ├── kiro-integration.test.js │ │ ├── mcp-config-validation.test.js │ │ ├── opencode-integration.test.js │ │ ├── profile-safety-check.test.js │ │ ├── roo-integration.test.js │ │ ├── rule-transformer-cline.test.js │ │ ├── rule-transformer-cursor.test.js │ │ ├── rule-transformer-gemini.test.js │ │ ├── rule-transformer-kilo.test.js │ │ ├── rule-transformer-kiro.test.js │ │ ├── rule-transformer-opencode.test.js │ │ ├── rule-transformer-roo.test.js │ │ ├── rule-transformer-trae.test.js │ │ ├── rule-transformer-vscode.test.js │ │ ├── rule-transformer-windsurf.test.js │ │ ├── rule-transformer-zed.test.js │ │ ├── rule-transformer.test.js │ │ ├── selective-profile-removal.test.js │ │ ├── subdirectory-support.test.js │ │ ├── trae-integration.test.js │ │ ├── vscode-integration.test.js │ │ ├── windsurf-integration.test.js │ │ └── zed-integration.test.js │ ├── progress │ │ └── base-progress-tracker.test.js │ ├── prompt-manager.test.js │ ├── prompts │ │ └── expand-task-prompt.test.js │ ├── providers │ │ └── provider-registry.test.js │ ├── scripts │ │ └── modules │ │ ├── commands │ │ │ ├── move-cross-tag.test.js │ │ │ └── README.md │ │ ├── dependency-manager │ │ │ ├── circular-dependencies.test.js │ │ │ ├── cross-tag-dependencies.test.js │ │ │ └── fix-dependencies-command.test.js │ │ ├── task-manager │ │ │ ├── add-subtask.test.js │ │ │ ├── add-task.test.js │ │ │ ├── analyze-task-complexity.test.js │ │ │ ├── clear-subtasks.test.js │ │ │ ├── complexity-report-tag-isolation.test.js │ │ │ ├── expand-all-tasks.test.js │ │ │ ├── expand-task.test.js │ │ │ ├── find-next-task.test.js │ │ │ ├── generate-task-files.test.js │ │ │ ├── list-tasks.test.js │ │ │ ├── move-task-cross-tag.test.js │ │ │ ├── move-task.test.js │ │ │ ├── parse-prd.test.js │ │ │ ├── remove-subtask.test.js │ │ │ ├── remove-task.test.js │ │ │ ├── research.test.js │ │ │ ├── scope-adjustment.test.js │ │ │ ├── set-task-status.test.js │ │ │ ├── setup.js │ │ │ ├── update-single-task-status.test.js │ │ │ ├── update-subtask-by-id.test.js │ │ │ ├── update-task-by-id.test.js │ │ │ └── update-tasks.test.js │ │ ├── ui │ │ │ └── cross-tag-error-display.test.js │ │ └── utils-tag-aware-paths.test.js │ ├── task-finder.test.js │ ├── task-manager │ │ ├── clear-subtasks.test.js │ │ ├── move-task.test.js │ │ ├── tag-boundary.test.js │ │ └── tag-management.test.js │ ├── task-master.test.js │ ├── ui │ │ └── indicators.test.js │ ├── ui.test.js │ ├── utils-strip-ansi.test.js │ └── utils.test.js ├── tsconfig.json ├── tsdown.config.ts └── turbo.json ``` # Files -------------------------------------------------------------------------------- /scripts/modules/task-manager/update-subtask-by-id.js: -------------------------------------------------------------------------------- ```javascript 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import chalk from 'chalk'; 4 | import boxen from 'boxen'; 5 | import Table from 'cli-table3'; 6 | 7 | import { 8 | getStatusWithColor, 9 | startLoadingIndicator, 10 | stopLoadingIndicator, 11 | displayAiUsageSummary 12 | } from '../ui.js'; 13 | import { 14 | log as consoleLog, 15 | readJSON, 16 | writeJSON, 17 | truncate, 18 | isSilentMode, 19 | findProjectRoot, 20 | flattenTasksWithSubtasks 21 | } from '../utils.js'; 22 | import { generateTextService } from '../ai-services-unified.js'; 23 | import { getDebugFlag, hasCodebaseAnalysis } from '../config-manager.js'; 24 | import { getPromptManager } from '../prompt-manager.js'; 25 | import generateTaskFiles from './generate-task-files.js'; 26 | import { ContextGatherer } from '../utils/contextGatherer.js'; 27 | import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js'; 28 | 29 | /** 30 | * Update a subtask by appending additional timestamped information using the unified AI service. 31 | * @param {string} tasksPath - Path to the tasks.json file 32 | * @param {string} subtaskId - ID of the subtask to update in format "parentId.subtaskId" 33 | * @param {string} prompt - Prompt for generating additional information 34 | * @param {boolean} [useResearch=false] - Whether to use the research AI role. 35 | * @param {Object} context - Context object containing session and mcpLog. 36 | * @param {Object} [context.session] - Session object from MCP server. 37 | * @param {Object} [context.mcpLog] - MCP logger object. 38 | * @param {string} [context.projectRoot] - Project root path (needed for AI service key resolution). 39 | * @param {string} [context.tag] - Tag for the task 40 | * @param {string} [outputFormat='text'] - Output format ('text' or 'json'). Automatically 'json' if mcpLog is present. 41 | * @returns {Promise<Object|null>} - The updated subtask or null if update failed. 42 | */ 43 | async function updateSubtaskById( 44 | tasksPath, 45 | subtaskId, 46 | prompt, 47 | useResearch = false, 48 | context = {}, 49 | outputFormat = context.mcpLog ? 'json' : 'text' 50 | ) { 51 | const { session, mcpLog, projectRoot: providedProjectRoot, tag } = context; 52 | const logFn = mcpLog || consoleLog; 53 | const isMCP = !!mcpLog; 54 | 55 | // Report helper 56 | const report = (level, ...args) => { 57 | if (isMCP) { 58 | if (typeof logFn[level] === 'function') logFn[level](...args); 59 | else logFn.info(...args); 60 | } else if (!isSilentMode()) { 61 | logFn(level, ...args); 62 | } 63 | }; 64 | 65 | let loadingIndicator = null; 66 | 67 | try { 68 | report('info', `Updating subtask ${subtaskId} with prompt: "${prompt}"`); 69 | 70 | if ( 71 | !subtaskId || 72 | typeof subtaskId !== 'string' || 73 | !subtaskId.includes('.') 74 | ) { 75 | throw new Error( 76 | `Invalid subtask ID format: ${subtaskId}. Subtask ID must be in format "parentId.subtaskId"` 77 | ); 78 | } 79 | 80 | if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') { 81 | throw new Error( 82 | 'Prompt cannot be empty. Please provide context for the subtask update.' 83 | ); 84 | } 85 | 86 | if (!fs.existsSync(tasksPath)) { 87 | throw new Error(`Tasks file not found at path: ${tasksPath}`); 88 | } 89 | 90 | const projectRoot = providedProjectRoot || findProjectRoot(); 91 | if (!projectRoot) { 92 | throw new Error('Could not determine project root directory'); 93 | } 94 | 95 | const data = readJSON(tasksPath, projectRoot, tag); 96 | if (!data || !data.tasks) { 97 | throw new Error( 98 | `No valid tasks found in ${tasksPath}. The file may be corrupted or have an invalid format.` 99 | ); 100 | } 101 | 102 | const [parentIdStr, subtaskIdStr] = subtaskId.split('.'); 103 | const parentId = parseInt(parentIdStr, 10); 104 | const subtaskIdNum = parseInt(subtaskIdStr, 10); 105 | 106 | if ( 107 | Number.isNaN(parentId) || 108 | parentId <= 0 || 109 | Number.isNaN(subtaskIdNum) || 110 | subtaskIdNum <= 0 111 | ) { 112 | throw new Error( 113 | `Invalid subtask ID format: ${subtaskId}. Both parent ID and subtask ID must be positive integers.` 114 | ); 115 | } 116 | 117 | const parentTask = data.tasks.find((task) => task.id === parentId); 118 | if (!parentTask) { 119 | throw new Error( 120 | `Parent task with ID ${parentId} not found. Please verify the task ID and try again.` 121 | ); 122 | } 123 | 124 | if (!parentTask.subtasks || !Array.isArray(parentTask.subtasks)) { 125 | throw new Error(`Parent task ${parentId} has no subtasks.`); 126 | } 127 | 128 | const subtaskIndex = parentTask.subtasks.findIndex( 129 | (st) => st.id === subtaskIdNum 130 | ); 131 | if (subtaskIndex === -1) { 132 | throw new Error( 133 | `Subtask with ID ${subtaskId} not found. Please verify the subtask ID and try again.` 134 | ); 135 | } 136 | 137 | const subtask = parentTask.subtasks[subtaskIndex]; 138 | 139 | // --- Context Gathering --- 140 | let gatheredContext = ''; 141 | try { 142 | const contextGatherer = new ContextGatherer(projectRoot, tag); 143 | const allTasksFlat = flattenTasksWithSubtasks(data.tasks); 144 | const fuzzySearch = new FuzzyTaskSearch(allTasksFlat, 'update-subtask'); 145 | const searchQuery = `${parentTask.title} ${subtask.title} ${prompt}`; 146 | const searchResults = fuzzySearch.findRelevantTasks(searchQuery, { 147 | maxResults: 5, 148 | includeSelf: true 149 | }); 150 | const relevantTaskIds = fuzzySearch.getTaskIds(searchResults); 151 | 152 | const finalTaskIds = [ 153 | ...new Set([subtaskId.toString(), ...relevantTaskIds]) 154 | ]; 155 | 156 | if (finalTaskIds.length > 0) { 157 | const contextResult = await contextGatherer.gather({ 158 | tasks: finalTaskIds, 159 | format: 'research' 160 | }); 161 | gatheredContext = contextResult.context || ''; 162 | } 163 | } catch (contextError) { 164 | report('warn', `Could not gather context: ${contextError.message}`); 165 | } 166 | // --- End Context Gathering --- 167 | 168 | if (outputFormat === 'text') { 169 | const table = new Table({ 170 | head: [ 171 | chalk.cyan.bold('ID'), 172 | chalk.cyan.bold('Title'), 173 | chalk.cyan.bold('Status') 174 | ], 175 | colWidths: [10, 55, 10] 176 | }); 177 | table.push([ 178 | subtaskId, 179 | truncate(subtask.title, 52), 180 | getStatusWithColor(subtask.status) 181 | ]); 182 | console.log( 183 | boxen(chalk.white.bold(`Updating Subtask #${subtaskId}`), { 184 | padding: 1, 185 | borderColor: 'blue', 186 | borderStyle: 'round', 187 | margin: { top: 1, bottom: 0 } 188 | }) 189 | ); 190 | console.log(table.toString()); 191 | loadingIndicator = startLoadingIndicator( 192 | useResearch 193 | ? 'Updating subtask with research...' 194 | : 'Updating subtask...' 195 | ); 196 | } 197 | 198 | let generatedContentString = ''; 199 | let newlyAddedSnippet = ''; 200 | let aiServiceResponse = null; 201 | 202 | try { 203 | const parentContext = { 204 | id: parentTask.id, 205 | title: parentTask.title 206 | }; 207 | const prevSubtask = 208 | subtaskIndex > 0 209 | ? { 210 | id: `${parentTask.id}.${parentTask.subtasks[subtaskIndex - 1].id}`, 211 | title: parentTask.subtasks[subtaskIndex - 1].title, 212 | status: parentTask.subtasks[subtaskIndex - 1].status 213 | } 214 | : undefined; 215 | const nextSubtask = 216 | subtaskIndex < parentTask.subtasks.length - 1 217 | ? { 218 | id: `${parentTask.id}.${parentTask.subtasks[subtaskIndex + 1].id}`, 219 | title: parentTask.subtasks[subtaskIndex + 1].title, 220 | status: parentTask.subtasks[subtaskIndex + 1].status 221 | } 222 | : undefined; 223 | 224 | // Build prompts using PromptManager 225 | const promptManager = getPromptManager(); 226 | 227 | const promptParams = { 228 | parentTask: parentContext, 229 | prevSubtask: prevSubtask, 230 | nextSubtask: nextSubtask, 231 | currentDetails: subtask.details || '(No existing details)', 232 | updatePrompt: prompt, 233 | useResearch: useResearch, 234 | gatheredContext: gatheredContext || '', 235 | hasCodebaseAnalysis: hasCodebaseAnalysis( 236 | useResearch, 237 | projectRoot, 238 | session 239 | ), 240 | projectRoot: projectRoot 241 | }; 242 | 243 | const variantKey = useResearch ? 'research' : 'default'; 244 | const { systemPrompt, userPrompt } = await promptManager.loadPrompt( 245 | 'update-subtask', 246 | promptParams, 247 | variantKey 248 | ); 249 | 250 | const role = useResearch ? 'research' : 'main'; 251 | report('info', `Using AI text service with role: ${role}`); 252 | 253 | aiServiceResponse = await generateTextService({ 254 | prompt: userPrompt, 255 | systemPrompt: systemPrompt, 256 | role, 257 | session, 258 | projectRoot, 259 | maxRetries: 2, 260 | commandName: 'update-subtask', 261 | outputType: isMCP ? 'mcp' : 'cli' 262 | }); 263 | 264 | if ( 265 | aiServiceResponse && 266 | aiServiceResponse.mainResult && 267 | typeof aiServiceResponse.mainResult === 'string' 268 | ) { 269 | generatedContentString = aiServiceResponse.mainResult; 270 | } else { 271 | generatedContentString = ''; 272 | report( 273 | 'warn', 274 | 'AI service response did not contain expected text string.' 275 | ); 276 | } 277 | 278 | if (outputFormat === 'text' && loadingIndicator) { 279 | stopLoadingIndicator(loadingIndicator); 280 | loadingIndicator = null; 281 | } 282 | } catch (aiError) { 283 | report('error', `AI service call failed: ${aiError.message}`); 284 | if (outputFormat === 'text' && loadingIndicator) { 285 | stopLoadingIndicator(loadingIndicator); 286 | loadingIndicator = null; 287 | } 288 | throw aiError; 289 | } 290 | 291 | if (generatedContentString && generatedContentString.trim()) { 292 | // Check if the string is not empty 293 | const timestamp = new Date().toISOString(); 294 | const formattedBlock = `<info added on ${timestamp}>\n${generatedContentString.trim()}\n</info added on ${timestamp}>`; 295 | newlyAddedSnippet = formattedBlock; // <--- ADD THIS LINE: Store for display 296 | 297 | subtask.details = 298 | (subtask.details ? subtask.details + '\n' : '') + formattedBlock; 299 | } else { 300 | report( 301 | 'warn', 302 | 'AI response was empty or whitespace after trimming. Original details remain unchanged.' 303 | ); 304 | newlyAddedSnippet = 'No new details were added by the AI.'; 305 | } 306 | 307 | const updatedSubtask = parentTask.subtasks[subtaskIndex]; 308 | 309 | if (outputFormat === 'text' && getDebugFlag(session)) { 310 | console.log( 311 | '>>> DEBUG: Subtask details AFTER AI update:', 312 | updatedSubtask.details 313 | ); 314 | } 315 | 316 | if (updatedSubtask.description) { 317 | if (prompt.length < 100) { 318 | if (outputFormat === 'text' && getDebugFlag(session)) { 319 | console.log( 320 | '>>> DEBUG: Subtask description BEFORE append:', 321 | updatedSubtask.description 322 | ); 323 | } 324 | updatedSubtask.description += ` [Updated: ${new Date().toLocaleDateString()}]`; 325 | if (outputFormat === 'text' && getDebugFlag(session)) { 326 | console.log( 327 | '>>> DEBUG: Subtask description AFTER append:', 328 | updatedSubtask.description 329 | ); 330 | } 331 | } 332 | } 333 | 334 | if (outputFormat === 'text' && getDebugFlag(session)) { 335 | console.log('>>> DEBUG: About to call writeJSON with updated data...'); 336 | } 337 | writeJSON(tasksPath, data, projectRoot, tag); 338 | if (outputFormat === 'text' && getDebugFlag(session)) { 339 | console.log('>>> DEBUG: writeJSON call completed.'); 340 | } 341 | 342 | report('success', `Successfully updated subtask ${subtaskId}`); 343 | // Updated function call to make sure if uncommented it will generate the task files for the updated subtask based on the tag 344 | // await generateTaskFiles(tasksPath, path.dirname(tasksPath), { 345 | // tag: tag, 346 | // projectRoot: projectRoot 347 | // }); 348 | 349 | if (outputFormat === 'text') { 350 | if (loadingIndicator) { 351 | stopLoadingIndicator(loadingIndicator); 352 | loadingIndicator = null; 353 | } 354 | console.log( 355 | boxen( 356 | chalk.green(`Successfully updated subtask #${subtaskId}`) + 357 | '\n\n' + 358 | chalk.white.bold('Title:') + 359 | ' ' + 360 | updatedSubtask.title + 361 | '\n\n' + 362 | chalk.white.bold('Newly Added Snippet:') + 363 | '\n' + 364 | chalk.white(newlyAddedSnippet), 365 | { padding: 1, borderColor: 'green', borderStyle: 'round' } 366 | ) 367 | ); 368 | } 369 | 370 | if (outputFormat === 'text' && aiServiceResponse.telemetryData) { 371 | displayAiUsageSummary(aiServiceResponse.telemetryData, 'cli'); 372 | } 373 | 374 | return { 375 | updatedSubtask: updatedSubtask, 376 | telemetryData: aiServiceResponse.telemetryData, 377 | tagInfo: aiServiceResponse.tagInfo 378 | }; 379 | } catch (error) { 380 | if (outputFormat === 'text' && loadingIndicator) { 381 | stopLoadingIndicator(loadingIndicator); 382 | loadingIndicator = null; 383 | } 384 | report('error', `Error updating subtask: ${error.message}`); 385 | if (outputFormat === 'text') { 386 | console.error(chalk.red(`Error: ${error.message}`)); 387 | if (error.message?.includes('ANTHROPIC_API_KEY')) { 388 | console.log( 389 | chalk.yellow('\nTo fix this issue, set your Anthropic API key:') 390 | ); 391 | console.log(' export ANTHROPIC_API_KEY=your_api_key_here'); 392 | } else if (error.message?.includes('PERPLEXITY_API_KEY')) { 393 | console.log(chalk.yellow('\nTo fix this issue:')); 394 | console.log( 395 | ' 1. Set your Perplexity API key: export PERPLEXITY_API_KEY=your_api_key_here' 396 | ); 397 | console.log( 398 | ' 2. Or run without the research flag: task-master update-subtask --id=<id> --prompt="..."' 399 | ); 400 | } else if (error.message?.includes('overloaded')) { 401 | console.log( 402 | chalk.yellow( 403 | '\nAI model overloaded, and fallback failed or was unavailable:' 404 | ) 405 | ); 406 | console.log(' 1. Try again in a few minutes.'); 407 | console.log(' 2. Ensure PERPLEXITY_API_KEY is set for fallback.'); 408 | } else if (error.message?.includes('not found')) { 409 | console.log(chalk.yellow('\nTo fix this issue:')); 410 | console.log( 411 | ' 1. Run task-master list --with-subtasks to see all available subtask IDs' 412 | ); 413 | console.log( 414 | ' 2. Use a valid subtask ID with the --id parameter in format "parentId.subtaskId"' 415 | ); 416 | } else if ( 417 | error.message?.includes('empty stream response') || 418 | error.message?.includes('AI did not return a valid text string') 419 | ) { 420 | console.log( 421 | chalk.yellow( 422 | '\nThe AI model returned an empty or invalid response. This might be due to the prompt or API issues. Try rephrasing or trying again later.' 423 | ) 424 | ); 425 | } 426 | if (getDebugFlag(session)) { 427 | console.error(error); 428 | } 429 | } else { 430 | throw error; 431 | } 432 | return null; 433 | } 434 | } 435 | 436 | export default updateSubtaskById; 437 | ``` -------------------------------------------------------------------------------- /tests/unit/mcp/tools/remove-task.test.js: -------------------------------------------------------------------------------- ```javascript 1 | /** 2 | * Tests for the remove-task MCP tool 3 | * 4 | * Note: This test does NOT test the actual implementation. It tests that: 5 | * 1. The tool is registered correctly with the correct parameters 6 | * 2. Arguments are passed correctly to removeTaskDirect 7 | * 3. Error handling works as expected 8 | * 4. Tag parameter is properly handled and passed through 9 | * 10 | * We do NOT import the real implementation - everything is mocked 11 | */ 12 | 13 | import { jest } from '@jest/globals'; 14 | 15 | // Mock EVERYTHING 16 | const mockRemoveTaskDirect = jest.fn(); 17 | jest.mock('../../../../mcp-server/src/core/task-master-core.js', () => ({ 18 | removeTaskDirect: mockRemoveTaskDirect 19 | })); 20 | 21 | const mockHandleApiResult = jest.fn((result) => result); 22 | const mockWithNormalizedProjectRoot = jest.fn((fn) => fn); 23 | const mockCreateErrorResponse = jest.fn((msg) => ({ 24 | success: false, 25 | error: { code: 'ERROR', message: msg } 26 | })); 27 | const mockFindTasksPath = jest.fn(() => '/mock/project/tasks.json'); 28 | 29 | jest.mock('../../../../mcp-server/src/tools/utils.js', () => ({ 30 | handleApiResult: mockHandleApiResult, 31 | createErrorResponse: mockCreateErrorResponse, 32 | withNormalizedProjectRoot: mockWithNormalizedProjectRoot 33 | })); 34 | 35 | jest.mock('../../../../mcp-server/src/core/utils/path-utils.js', () => ({ 36 | findTasksPath: mockFindTasksPath 37 | })); 38 | 39 | // Mock the z object from zod 40 | const mockZod = { 41 | object: jest.fn(() => mockZod), 42 | string: jest.fn(() => mockZod), 43 | boolean: jest.fn(() => mockZod), 44 | optional: jest.fn(() => mockZod), 45 | describe: jest.fn(() => mockZod), 46 | _def: { 47 | shape: () => ({ 48 | id: {}, 49 | file: {}, 50 | projectRoot: {}, 51 | confirm: {}, 52 | tag: {} 53 | }) 54 | } 55 | }; 56 | 57 | jest.mock('zod', () => ({ 58 | z: mockZod 59 | })); 60 | 61 | // DO NOT import the real module - create a fake implementation 62 | // This is the fake implementation of registerRemoveTaskTool 63 | const registerRemoveTaskTool = (server) => { 64 | // Create simplified version of the tool config 65 | const toolConfig = { 66 | name: 'remove_task', 67 | description: 'Remove a task or subtask permanently from the tasks list', 68 | parameters: mockZod, 69 | 70 | // Create a simplified mock of the execute function 71 | execute: mockWithNormalizedProjectRoot(async (args, context) => { 72 | const { log, session } = context; 73 | 74 | try { 75 | log.info && log.info(`Removing task(s) with ID(s): ${args.id}`); 76 | 77 | // Use args.projectRoot directly (guaranteed by withNormalizedProjectRoot) 78 | let tasksJsonPath; 79 | try { 80 | tasksJsonPath = mockFindTasksPath( 81 | { projectRoot: args.projectRoot, file: args.file }, 82 | log 83 | ); 84 | } catch (error) { 85 | log.error && log.error(`Error finding tasks.json: ${error.message}`); 86 | return mockCreateErrorResponse( 87 | `Failed to find tasks.json: ${error.message}` 88 | ); 89 | } 90 | 91 | log.info && log.info(`Using tasks file path: ${tasksJsonPath}`); 92 | 93 | const result = await mockRemoveTaskDirect( 94 | { 95 | tasksJsonPath: tasksJsonPath, 96 | id: args.id, 97 | projectRoot: args.projectRoot, 98 | tag: args.tag 99 | }, 100 | log, 101 | { session } 102 | ); 103 | 104 | if (result.success) { 105 | log.info && log.info(`Successfully removed task: ${args.id}`); 106 | } else { 107 | log.error && 108 | log.error(`Failed to remove task: ${result.error.message}`); 109 | } 110 | 111 | return mockHandleApiResult( 112 | result, 113 | log, 114 | 'Error removing task', 115 | undefined, 116 | args.projectRoot 117 | ); 118 | } catch (error) { 119 | log.error && log.error(`Error in remove-task tool: ${error.message}`); 120 | return mockCreateErrorResponse(error.message); 121 | } 122 | }) 123 | }; 124 | 125 | // Register the tool with the server 126 | server.addTool(toolConfig); 127 | }; 128 | 129 | describe('MCP Tool: remove-task', () => { 130 | // Create mock server 131 | let mockServer; 132 | let executeFunction; 133 | 134 | // Create mock logger 135 | const mockLogger = { 136 | debug: jest.fn(), 137 | info: jest.fn(), 138 | warn: jest.fn(), 139 | error: jest.fn() 140 | }; 141 | 142 | // Test data 143 | const validArgs = { 144 | id: '5', 145 | projectRoot: '/mock/project/root', 146 | file: '/mock/project/tasks.json', 147 | confirm: true, 148 | tag: 'feature-branch' 149 | }; 150 | 151 | const multipleTaskArgs = { 152 | id: '5,6.1,7', 153 | projectRoot: '/mock/project/root', 154 | tag: 'master' 155 | }; 156 | 157 | // Standard responses 158 | const successResponse = { 159 | success: true, 160 | data: { 161 | totalTasks: 1, 162 | successful: 1, 163 | failed: 0, 164 | removedTasks: [ 165 | { 166 | id: 5, 167 | title: 'Removed Task', 168 | status: 'pending' 169 | } 170 | ], 171 | messages: ["Successfully removed task 5 from tag 'feature-branch'"], 172 | errors: [], 173 | tasksPath: '/mock/project/tasks.json', 174 | tag: 'feature-branch' 175 | } 176 | }; 177 | 178 | const multipleTasksSuccessResponse = { 179 | success: true, 180 | data: { 181 | totalTasks: 3, 182 | successful: 3, 183 | failed: 0, 184 | removedTasks: [ 185 | { id: 5, title: 'Task 5', status: 'pending' }, 186 | { id: 1, title: 'Subtask 6.1', status: 'done', parentTaskId: 6 }, 187 | { id: 7, title: 'Task 7', status: 'in-progress' } 188 | ], 189 | messages: [ 190 | "Successfully removed task 5 from tag 'master'", 191 | "Successfully removed subtask 6.1 from tag 'master'", 192 | "Successfully removed task 7 from tag 'master'" 193 | ], 194 | errors: [], 195 | tasksPath: '/mock/project/tasks.json', 196 | tag: 'master' 197 | } 198 | }; 199 | 200 | const errorResponse = { 201 | success: false, 202 | error: { 203 | code: 'INVALID_TASK_ID', 204 | message: "The following tasks were not found in tag 'feature-branch': 999" 205 | } 206 | }; 207 | 208 | const pathErrorResponse = { 209 | success: false, 210 | error: { 211 | code: 'PATH_ERROR', 212 | message: 'Failed to find tasks.json: No tasks.json found' 213 | } 214 | }; 215 | 216 | beforeEach(() => { 217 | // Reset all mocks 218 | jest.clearAllMocks(); 219 | 220 | // Create mock server 221 | mockServer = { 222 | addTool: jest.fn((config) => { 223 | executeFunction = config.execute; 224 | }) 225 | }; 226 | 227 | // Setup default successful response 228 | mockRemoveTaskDirect.mockResolvedValue(successResponse); 229 | mockFindTasksPath.mockReturnValue('/mock/project/tasks.json'); 230 | 231 | // Register the tool 232 | registerRemoveTaskTool(mockServer); 233 | }); 234 | 235 | test('should register the tool correctly', () => { 236 | // Verify tool was registered 237 | expect(mockServer.addTool).toHaveBeenCalledWith( 238 | expect.objectContaining({ 239 | name: 'remove_task', 240 | description: 'Remove a task or subtask permanently from the tasks list', 241 | parameters: expect.any(Object), 242 | execute: expect.any(Function) 243 | }) 244 | ); 245 | 246 | // Verify the tool config was passed 247 | const toolConfig = mockServer.addTool.mock.calls[0][0]; 248 | expect(toolConfig).toHaveProperty('parameters'); 249 | expect(toolConfig).toHaveProperty('execute'); 250 | }); 251 | 252 | test('should execute the tool with valid parameters including tag', async () => { 253 | // Setup context 254 | const mockContext = { 255 | log: mockLogger, 256 | session: { workingDirectory: '/mock/dir' } 257 | }; 258 | 259 | // Execute the function 260 | await executeFunction(validArgs, mockContext); 261 | 262 | // Verify findTasksPath was called with correct arguments 263 | expect(mockFindTasksPath).toHaveBeenCalledWith( 264 | { 265 | projectRoot: validArgs.projectRoot, 266 | file: validArgs.file 267 | }, 268 | mockLogger 269 | ); 270 | 271 | // Verify removeTaskDirect was called with correct arguments including tag 272 | expect(mockRemoveTaskDirect).toHaveBeenCalledWith( 273 | expect.objectContaining({ 274 | tasksJsonPath: '/mock/project/tasks.json', 275 | id: validArgs.id, 276 | projectRoot: validArgs.projectRoot, 277 | tag: validArgs.tag // This is the key test - tag parameter should be passed through 278 | }), 279 | mockLogger, 280 | { 281 | session: mockContext.session 282 | } 283 | ); 284 | 285 | // Verify handleApiResult was called 286 | expect(mockHandleApiResult).toHaveBeenCalledWith( 287 | successResponse, 288 | mockLogger, 289 | 'Error removing task', 290 | undefined, 291 | validArgs.projectRoot 292 | ); 293 | }); 294 | 295 | test('should handle multiple task IDs with tag context', async () => { 296 | // Setup multiple tasks response 297 | mockRemoveTaskDirect.mockResolvedValueOnce(multipleTasksSuccessResponse); 298 | 299 | // Setup context 300 | const mockContext = { 301 | log: mockLogger, 302 | session: { workingDirectory: '/mock/dir' } 303 | }; 304 | 305 | // Execute the function 306 | await executeFunction(multipleTaskArgs, mockContext); 307 | 308 | // Verify removeTaskDirect was called with comma-separated IDs and tag 309 | expect(mockRemoveTaskDirect).toHaveBeenCalledWith( 310 | expect.objectContaining({ 311 | id: '5,6.1,7', 312 | tag: 'master' 313 | }), 314 | mockLogger, 315 | expect.any(Object) 316 | ); 317 | 318 | // Verify successful handling of multiple tasks 319 | expect(mockHandleApiResult).toHaveBeenCalledWith( 320 | multipleTasksSuccessResponse, 321 | mockLogger, 322 | 'Error removing task', 323 | undefined, 324 | multipleTaskArgs.projectRoot 325 | ); 326 | }); 327 | 328 | test('should handle missing tag parameter (defaults to current tag)', async () => { 329 | const argsWithoutTag = { 330 | id: '5', 331 | projectRoot: '/mock/project/root' 332 | }; 333 | 334 | // Setup context 335 | const mockContext = { 336 | log: mockLogger, 337 | session: { workingDirectory: '/mock/dir' } 338 | }; 339 | 340 | // Execute the function 341 | await executeFunction(argsWithoutTag, mockContext); 342 | 343 | // Verify removeTaskDirect was called with undefined tag (should default to current tag) 344 | expect(mockRemoveTaskDirect).toHaveBeenCalledWith( 345 | expect.objectContaining({ 346 | id: '5', 347 | projectRoot: '/mock/project/root', 348 | tag: undefined // Should be undefined when not provided 349 | }), 350 | mockLogger, 351 | expect.any(Object) 352 | ); 353 | }); 354 | 355 | test('should handle errors from removeTaskDirect', async () => { 356 | // Setup error response 357 | mockRemoveTaskDirect.mockResolvedValueOnce(errorResponse); 358 | 359 | // Setup context 360 | const mockContext = { 361 | log: mockLogger, 362 | session: { workingDirectory: '/mock/dir' } 363 | }; 364 | 365 | // Execute the function 366 | await executeFunction(validArgs, mockContext); 367 | 368 | // Verify removeTaskDirect was called 369 | expect(mockRemoveTaskDirect).toHaveBeenCalled(); 370 | 371 | // Verify error logging 372 | expect(mockLogger.error).toHaveBeenCalledWith( 373 | "Failed to remove task: The following tasks were not found in tag 'feature-branch': 999" 374 | ); 375 | 376 | // Verify handleApiResult was called with error response 377 | expect(mockHandleApiResult).toHaveBeenCalledWith( 378 | errorResponse, 379 | mockLogger, 380 | 'Error removing task', 381 | undefined, 382 | validArgs.projectRoot 383 | ); 384 | }); 385 | 386 | test('should handle path finding errors', async () => { 387 | // Setup path finding error 388 | mockFindTasksPath.mockImplementationOnce(() => { 389 | throw new Error('No tasks.json found'); 390 | }); 391 | 392 | // Setup context 393 | const mockContext = { 394 | log: mockLogger, 395 | session: { workingDirectory: '/mock/dir' } 396 | }; 397 | 398 | // Execute the function 399 | const result = await executeFunction(validArgs, mockContext); 400 | 401 | // Verify error logging 402 | expect(mockLogger.error).toHaveBeenCalledWith( 403 | 'Error finding tasks.json: No tasks.json found' 404 | ); 405 | 406 | // Verify error response was returned 407 | expect(mockCreateErrorResponse).toHaveBeenCalledWith( 408 | 'Failed to find tasks.json: No tasks.json found' 409 | ); 410 | 411 | // Verify removeTaskDirect was NOT called 412 | expect(mockRemoveTaskDirect).not.toHaveBeenCalled(); 413 | }); 414 | 415 | test('should handle unexpected errors in execute function', async () => { 416 | // Setup unexpected error 417 | mockRemoveTaskDirect.mockImplementationOnce(() => { 418 | throw new Error('Unexpected error'); 419 | }); 420 | 421 | // Setup context 422 | const mockContext = { 423 | log: mockLogger, 424 | session: { workingDirectory: '/mock/dir' } 425 | }; 426 | 427 | // Execute the function 428 | await executeFunction(validArgs, mockContext); 429 | 430 | // Verify error logging 431 | expect(mockLogger.error).toHaveBeenCalledWith( 432 | 'Error in remove-task tool: Unexpected error' 433 | ); 434 | 435 | // Verify error response was returned 436 | expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unexpected error'); 437 | }); 438 | 439 | test('should properly handle withNormalizedProjectRoot wrapper', () => { 440 | // Verify that withNormalizedProjectRoot was called with the execute function 441 | expect(mockWithNormalizedProjectRoot).toHaveBeenCalledWith( 442 | expect.any(Function) 443 | ); 444 | }); 445 | 446 | test('should log appropriate info messages for successful operations', async () => { 447 | // Setup context 448 | const mockContext = { 449 | log: mockLogger, 450 | session: { workingDirectory: '/mock/dir' } 451 | }; 452 | 453 | // Execute the function 454 | await executeFunction(validArgs, mockContext); 455 | 456 | // Verify appropriate logging 457 | expect(mockLogger.info).toHaveBeenCalledWith( 458 | 'Removing task(s) with ID(s): 5' 459 | ); 460 | expect(mockLogger.info).toHaveBeenCalledWith( 461 | 'Using tasks file path: /mock/project/tasks.json' 462 | ); 463 | expect(mockLogger.info).toHaveBeenCalledWith( 464 | 'Successfully removed task: 5' 465 | ); 466 | }); 467 | 468 | test('should handle subtask removal with proper tag context', async () => { 469 | const subtaskArgs = { 470 | id: '5.2', 471 | projectRoot: '/mock/project/root', 472 | tag: 'feature-branch' 473 | }; 474 | 475 | const subtaskSuccessResponse = { 476 | success: true, 477 | data: { 478 | totalTasks: 1, 479 | successful: 1, 480 | failed: 0, 481 | removedTasks: [ 482 | { 483 | id: 2, 484 | title: 'Removed Subtask', 485 | status: 'pending', 486 | parentTaskId: 5 487 | } 488 | ], 489 | messages: [ 490 | "Successfully removed subtask 5.2 from tag 'feature-branch'" 491 | ], 492 | errors: [], 493 | tasksPath: '/mock/project/tasks.json', 494 | tag: 'feature-branch' 495 | } 496 | }; 497 | 498 | mockRemoveTaskDirect.mockResolvedValueOnce(subtaskSuccessResponse); 499 | 500 | // Setup context 501 | const mockContext = { 502 | log: mockLogger, 503 | session: { workingDirectory: '/mock/dir' } 504 | }; 505 | 506 | // Execute the function 507 | await executeFunction(subtaskArgs, mockContext); 508 | 509 | // Verify removeTaskDirect was called with subtask ID and tag 510 | expect(mockRemoveTaskDirect).toHaveBeenCalledWith( 511 | expect.objectContaining({ 512 | id: '5.2', 513 | tag: 'feature-branch' 514 | }), 515 | mockLogger, 516 | expect.any(Object) 517 | ); 518 | 519 | // Verify successful handling 520 | expect(mockHandleApiResult).toHaveBeenCalledWith( 521 | subtaskSuccessResponse, 522 | mockLogger, 523 | 'Error removing task', 524 | undefined, 525 | subtaskArgs.projectRoot 526 | ); 527 | }); 528 | }); 529 | ``` -------------------------------------------------------------------------------- /apps/extension/src/services/webview-manager.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Webview Manager - Simplified 3 | * Manages webview panels and message handling 4 | */ 5 | 6 | import * as vscode from 'vscode'; 7 | import type { EventEmitter } from '../utils/event-emitter'; 8 | import type { ExtensionLogger } from '../utils/logger'; 9 | import type { ConfigService } from './config-service'; 10 | import type { TaskRepository } from './task-repository'; 11 | import type { TerminalManager } from './terminal-manager'; 12 | 13 | export class WebviewManager { 14 | private panels = new Set<vscode.WebviewPanel>(); 15 | private configService?: ConfigService; 16 | private mcpClient?: any; 17 | private api?: any; 18 | 19 | constructor( 20 | private context: vscode.ExtensionContext, 21 | private repository: TaskRepository, 22 | private events: EventEmitter, 23 | private logger: ExtensionLogger, 24 | private terminalManager: TerminalManager 25 | ) {} 26 | 27 | setConfigService(configService: ConfigService): void { 28 | this.configService = configService; 29 | } 30 | 31 | setMCPClient(mcpClient: any): void { 32 | this.mcpClient = mcpClient; 33 | } 34 | 35 | setApi(api: any): void { 36 | this.api = api; 37 | } 38 | 39 | async createOrShowPanel(): Promise<void> { 40 | // Find existing panel 41 | const existing = Array.from(this.panels).find( 42 | (p) => p.title === 'TaskMaster Kanban' 43 | ); 44 | if (existing) { 45 | existing.reveal(); 46 | return; 47 | } 48 | 49 | // Create new panel 50 | const panel = vscode.window.createWebviewPanel( 51 | 'taskrKanban', 52 | 'TaskMaster Kanban', 53 | vscode.ViewColumn.One, 54 | { 55 | enableScripts: true, 56 | retainContextWhenHidden: true, 57 | localResourceRoots: [ 58 | vscode.Uri.joinPath(this.context.extensionUri, 'dist') 59 | ] 60 | } 61 | ); 62 | 63 | // Set the icon for the webview tab 64 | panel.iconPath = { 65 | light: vscode.Uri.joinPath( 66 | this.context.extensionUri, 67 | 'assets', 68 | 'icon-light.svg' 69 | ), 70 | dark: vscode.Uri.joinPath( 71 | this.context.extensionUri, 72 | 'assets', 73 | 'icon-dark.svg' 74 | ) 75 | }; 76 | 77 | this.panels.add(panel); 78 | panel.webview.html = this.getWebviewContent(panel.webview); 79 | 80 | // Handle messages 81 | panel.webview.onDidReceiveMessage(async (message) => { 82 | await this.handleMessage(panel, message); 83 | }); 84 | 85 | // Handle disposal 86 | panel.onDidDispose(() => { 87 | this.panels.delete(panel); 88 | this.events.emit('webview:closed'); 89 | }); 90 | 91 | this.events.emit('webview:opened'); 92 | vscode.window.showInformationMessage('TaskMaster Kanban opened!'); 93 | } 94 | 95 | broadcast(type: string, data: any): void { 96 | this.panels.forEach((panel) => { 97 | panel.webview.postMessage({ type, data }); 98 | }); 99 | } 100 | 101 | getPanelCount(): number { 102 | return this.panels.size; 103 | } 104 | 105 | dispose(): void { 106 | this.panels.forEach((panel) => panel.dispose()); 107 | this.panels.clear(); 108 | } 109 | 110 | private async handleMessage( 111 | panel: vscode.WebviewPanel, 112 | message: any 113 | ): Promise<void> { 114 | // Validate message structure 115 | if (!message || typeof message !== 'object') { 116 | this.logger.error('Invalid message received:', message); 117 | return; 118 | } 119 | 120 | const { type, data, requestId } = message; 121 | this.logger.debug(`Webview message: ${type}`, message); 122 | 123 | try { 124 | let response: any; 125 | 126 | switch (type) { 127 | case 'ready': 128 | // Webview is ready, send current connection status 129 | const isConnected = this.mcpClient?.getStatus()?.isRunning || false; 130 | panel.webview.postMessage({ 131 | type: 'connectionStatus', 132 | data: { 133 | isConnected: isConnected, 134 | status: isConnected ? 'Connected' : 'Disconnected' 135 | } 136 | }); 137 | // No response needed for ready message 138 | return; 139 | 140 | case 'getTasks': 141 | // Pass options to getAll including tag if specified 142 | response = await this.repository.getAll({ 143 | tag: data?.tag, 144 | withSubtasks: data?.withSubtasks ?? true 145 | }); 146 | break; 147 | 148 | case 'updateTaskStatus': 149 | await this.repository.updateStatus(data.taskId, data.newStatus); 150 | response = { success: true }; 151 | break; 152 | 153 | case 'getConfig': 154 | if (this.configService) { 155 | response = await this.configService.getSafeConfig(); 156 | } else { 157 | response = null; 158 | } 159 | break; 160 | 161 | case 'readTaskFileData': 162 | // For now, return the task data from repository 163 | // In the future, this could read from actual task files 164 | const task = await this.repository.getById(data.taskId); 165 | if (task) { 166 | response = { 167 | details: task.details || '', 168 | testStrategy: task.testStrategy || '' 169 | }; 170 | } else { 171 | response = { 172 | details: '', 173 | testStrategy: '' 174 | }; 175 | } 176 | break; 177 | 178 | case 'updateTask': 179 | // Handle task content updates with MCP 180 | if (this.mcpClient) { 181 | try { 182 | const { taskId, updates, options = {} } = data; 183 | 184 | // Use the update_task MCP tool 185 | await this.mcpClient.callTool('update_task', { 186 | id: String(taskId), 187 | prompt: updates.description || '', 188 | append: options.append || false, 189 | research: options.research || false, 190 | projectRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath 191 | }); 192 | 193 | response = { success: true }; 194 | } catch (error) { 195 | this.logger.error('Failed to update task via MCP:', error); 196 | throw error; 197 | } 198 | } else { 199 | throw new Error('MCP client not initialized'); 200 | } 201 | break; 202 | 203 | case 'updateSubtask': 204 | // Handle subtask content updates with MCP 205 | if (this.mcpClient) { 206 | try { 207 | const { taskId, prompt, options = {} } = data; 208 | 209 | // Use the update_subtask MCP tool 210 | await this.mcpClient.callTool('update_subtask', { 211 | id: String(taskId), 212 | prompt: prompt, 213 | research: options.research || false, 214 | projectRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath 215 | }); 216 | 217 | response = { success: true }; 218 | } catch (error) { 219 | this.logger.error('Failed to update subtask via MCP:', error); 220 | throw error; 221 | } 222 | } else { 223 | throw new Error('MCP client not initialized'); 224 | } 225 | break; 226 | 227 | case 'getComplexity': 228 | // For backward compatibility - redirect to mcpRequest 229 | this.logger.debug( 230 | `getComplexity request for task ${data.taskId}, mcpClient available: ${!!this.mcpClient}` 231 | ); 232 | if (this.mcpClient && data.taskId) { 233 | try { 234 | const complexityResult = await this.mcpClient.callTool( 235 | 'complexity_report', 236 | { 237 | projectRoot: 238 | vscode.workspace.workspaceFolders?.[0]?.uri.fsPath 239 | } 240 | ); 241 | 242 | if (complexityResult?.report?.complexityAnalysis?.tasks) { 243 | const task = 244 | complexityResult.report.complexityAnalysis.tasks.find( 245 | (t: any) => t.id === data.taskId 246 | ); 247 | response = task ? { score: task.complexityScore } : {}; 248 | } else { 249 | response = {}; 250 | } 251 | } catch (error) { 252 | this.logger.error('Failed to get complexity', error); 253 | response = {}; 254 | } 255 | } else { 256 | this.logger.warn( 257 | `Cannot get complexity: mcpClient=${!!this.mcpClient}, taskId=${data.taskId}` 258 | ); 259 | response = {}; 260 | } 261 | break; 262 | 263 | case 'mcpRequest': 264 | // Handle MCP tool calls 265 | try { 266 | // The tool and params come directly in the message 267 | const tool = message.tool; 268 | const params = message.params || {}; 269 | 270 | if (!this.mcpClient) { 271 | throw new Error('MCP client not initialized'); 272 | } 273 | 274 | if (!tool) { 275 | throw new Error('Tool name not specified in mcpRequest'); 276 | } 277 | 278 | // Add projectRoot if not provided 279 | if (!params.projectRoot) { 280 | params.projectRoot = 281 | vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; 282 | } 283 | 284 | const result = await this.mcpClient.callTool(tool, params); 285 | response = { data: result }; 286 | } catch (error) { 287 | this.logger.error('MCP request failed:', error); 288 | // Re-throw with cleaner error message 289 | throw new Error( 290 | error instanceof Error ? error.message : 'Unknown error' 291 | ); 292 | } 293 | break; 294 | 295 | case 'getTags': 296 | // Get available tags 297 | if (this.mcpClient) { 298 | try { 299 | const result = await this.mcpClient.callTool('list_tags', { 300 | projectRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, 301 | showMetadata: false 302 | }); 303 | // The MCP response has a specific structure 304 | // Based on the MCP SDK, the response is in result.content[0].text 305 | let parsedData; 306 | if ( 307 | result?.content && 308 | Array.isArray(result.content) && 309 | result.content[0]?.text 310 | ) { 311 | try { 312 | parsedData = JSON.parse(result.content[0].text); 313 | } catch (e) { 314 | this.logger.error('Failed to parse MCP response text:', e); 315 | } 316 | } 317 | 318 | // Extract tags data from the parsed response 319 | if (parsedData?.data) { 320 | response = parsedData.data; 321 | } else if (parsedData) { 322 | response = parsedData; 323 | } else if (result?.data) { 324 | response = result.data; 325 | } else { 326 | response = { tags: [], currentTag: 'master' }; 327 | } 328 | } catch (error) { 329 | this.logger.error('Failed to get tags:', error); 330 | response = { tags: [], currentTag: 'master' }; 331 | } 332 | } else { 333 | response = { tags: [], currentTag: 'master' }; 334 | } 335 | break; 336 | 337 | case 'switchTag': 338 | // Switch to a different tag 339 | if (this.mcpClient && data.tagName) { 340 | try { 341 | await this.mcpClient.callTool('use_tag', { 342 | name: data.tagName, 343 | projectRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath 344 | }); 345 | // Clear cache and fetch tasks for the new tag 346 | await this.repository.refresh(); 347 | const tasks = await this.repository.getAll({ tag: data.tagName }); 348 | this.broadcast('tasksUpdated', { tasks, source: 'tag-switch' }); 349 | response = { success: true }; 350 | } catch (error) { 351 | this.logger.error('Failed to switch tag:', error); 352 | throw error; 353 | } 354 | } else { 355 | throw new Error('Tag name not provided'); 356 | } 357 | break; 358 | 359 | case 'openExternal': 360 | // Open external URL 361 | if (message.url) { 362 | vscode.env.openExternal(vscode.Uri.parse(message.url)); 363 | } 364 | return; 365 | 366 | case 'openTerminal': 367 | // Delegate terminal execution to TerminalManager 368 | const { taskId, taskTitle } = data.data || data; // Handle both nested and direct data 369 | this.logger.log( 370 | `Webview openTerminal - taskId: ${taskId} (type: ${typeof taskId}), taskTitle: ${taskTitle}` 371 | ); 372 | 373 | // Get current tag to ensure we're working in the right context 374 | let currentTag = 'master'; // default fallback 375 | if (this.mcpClient) { 376 | try { 377 | const tagsResult = await this.mcpClient.callTool('list_tags', { 378 | projectRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, 379 | showMetadata: false 380 | }); 381 | 382 | let parsedData; 383 | if ( 384 | tagsResult?.content && 385 | Array.isArray(tagsResult.content) && 386 | tagsResult.content[0]?.text 387 | ) { 388 | try { 389 | parsedData = JSON.parse(tagsResult.content[0].text); 390 | if (parsedData?.data?.currentTag) { 391 | currentTag = parsedData.data.currentTag; 392 | } 393 | } catch (e) { 394 | this.logger.warn( 395 | 'Failed to parse tags response for terminal execution' 396 | ); 397 | } 398 | } 399 | } catch (error) { 400 | this.logger.warn( 401 | 'Failed to get current tag for terminal execution:', 402 | error 403 | ); 404 | } 405 | } 406 | 407 | const result = await this.terminalManager.executeTask({ 408 | taskId, 409 | taskTitle, 410 | tag: currentTag 411 | }); 412 | 413 | response = result; 414 | 415 | // Show user feedback AFTER sending the response (like the working "TaskMaster connected!" example) 416 | setImmediate(() => { 417 | if (result.success) { 418 | // Success: Show info message 419 | vscode.window.showInformationMessage( 420 | `✅ Started Claude session for Task ${taskId}: ${taskTitle}` 421 | ); 422 | } else { 423 | // Error: Show VS Code native error notification only 424 | const errorMsg = `Failed to start task: ${result.error}`; 425 | vscode.window.showErrorMessage(errorMsg); 426 | } 427 | }); 428 | break; 429 | 430 | default: 431 | throw new Error(`Unknown message type: ${type}`); 432 | } 433 | 434 | // Send response 435 | if (requestId) { 436 | panel.webview.postMessage({ 437 | type: 'response', 438 | requestId, 439 | success: true, 440 | data: response 441 | }); 442 | } 443 | } catch (error) { 444 | this.logger.error(`Error handling message ${type}`, error); 445 | 446 | if (requestId) { 447 | panel.webview.postMessage({ 448 | type: 'error', 449 | requestId, 450 | error: error instanceof Error ? error.message : 'Unknown error' 451 | }); 452 | } 453 | } 454 | } 455 | 456 | private getWebviewContent(webview: vscode.Webview): string { 457 | const scriptUri = webview.asWebviewUri( 458 | vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'index.js') 459 | ); 460 | const styleUri = webview.asWebviewUri( 461 | vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'index.css') 462 | ); 463 | const nonce = this.getNonce(); 464 | 465 | return `<!DOCTYPE html> 466 | <html lang="en"> 467 | <head> 468 | <meta charset="UTF-8"> 469 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> 470 | <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}'; style-src ${webview.cspSource} 'unsafe-inline';"> 471 | <link href="${styleUri}" rel="stylesheet"> 472 | <title>TaskMaster Kanban</title> 473 | </head> 474 | <body> 475 | <div id="root"></div> 476 | <script nonce="${nonce}" src="${scriptUri}"></script> 477 | </body> 478 | </html>`; 479 | } 480 | 481 | private getNonce(): string { 482 | let text = ''; 483 | const possible = 484 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 485 | for (let i = 0; i < 32; i++) { 486 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 487 | } 488 | return text; 489 | } 490 | } 491 | ``` -------------------------------------------------------------------------------- /tests/unit/initialize-project.test.js: -------------------------------------------------------------------------------- ```javascript 1 | import { jest } from '@jest/globals'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import os from 'os'; 5 | 6 | // Reduce noise in test output 7 | process.env.TASKMASTER_LOG_LEVEL = 'error'; 8 | 9 | // === Mock everything early === 10 | jest.mock('child_process', () => ({ execSync: jest.fn() })); 11 | jest.mock('fs', () => ({ 12 | ...jest.requireActual('fs'), 13 | mkdirSync: jest.fn(), 14 | writeFileSync: jest.fn(), 15 | readFileSync: jest.fn(), 16 | appendFileSync: jest.fn(), 17 | existsSync: jest.fn(), 18 | mkdtempSync: jest.requireActual('fs').mkdtempSync, 19 | rmSync: jest.requireActual('fs').rmSync 20 | })); 21 | 22 | // Mock console methods to suppress output 23 | const consoleMethods = ['log', 'info', 'warn', 'error', 'clear']; 24 | consoleMethods.forEach((method) => { 25 | global.console[method] = jest.fn(); 26 | }); 27 | 28 | // Mock ES modules using unstable_mockModule 29 | jest.unstable_mockModule('../../scripts/modules/utils.js', () => ({ 30 | isSilentMode: jest.fn(() => true), 31 | enableSilentMode: jest.fn(), 32 | log: jest.fn(), 33 | findProjectRoot: jest.fn(() => process.cwd()) 34 | })); 35 | 36 | // Mock git-utils module 37 | jest.unstable_mockModule('../../scripts/modules/utils/git-utils.js', () => ({ 38 | insideGitWorkTree: jest.fn(() => false) 39 | })); 40 | 41 | // Mock rule transformer 42 | jest.unstable_mockModule('../../src/utils/rule-transformer.js', () => ({ 43 | convertAllRulesToProfileRules: jest.fn(), 44 | getRulesProfile: jest.fn(() => ({ 45 | conversionConfig: {}, 46 | globalReplacements: [] 47 | })) 48 | })); 49 | 50 | // Mock any other modules that might output or do real operations 51 | jest.unstable_mockModule('../../scripts/modules/config-manager.js', () => ({ 52 | createDefaultConfig: jest.fn(() => ({ models: {}, project: {} })), 53 | saveConfig: jest.fn() 54 | })); 55 | 56 | // Mock display libraries 57 | jest.mock('figlet', () => ({ textSync: jest.fn(() => 'MOCKED BANNER') })); 58 | jest.mock('boxen', () => jest.fn(() => 'MOCKED BOX')); 59 | jest.mock('gradient-string', () => jest.fn(() => jest.fn((text) => text))); 60 | jest.mock('chalk', () => ({ 61 | blue: jest.fn((text) => text), 62 | green: jest.fn((text) => text), 63 | red: jest.fn((text) => text), 64 | yellow: jest.fn((text) => text), 65 | cyan: jest.fn((text) => text), 66 | white: jest.fn((text) => text), 67 | dim: jest.fn((text) => text), 68 | bold: jest.fn((text) => text), 69 | underline: jest.fn((text) => text) 70 | })); 71 | 72 | const { execSync } = jest.requireMock('child_process'); 73 | const mockFs = jest.requireMock('fs'); 74 | 75 | // Import the mocked modules 76 | const mockUtils = await import('../../scripts/modules/utils.js'); 77 | const mockGitUtils = await import('../../scripts/modules/utils/git-utils.js'); 78 | const mockRuleTransformer = await import('../../src/utils/rule-transformer.js'); 79 | 80 | // Import after mocks 81 | const { initializeProject } = await import('../../scripts/init.js'); 82 | 83 | describe('initializeProject – Git / Alias flag logic', () => { 84 | let tmpDir; 85 | const origCwd = process.cwd(); 86 | 87 | // Standard non-interactive options for all tests 88 | const baseOptions = { 89 | yes: true, 90 | skipInstall: true, 91 | name: 'test-project', 92 | description: 'Test project description', 93 | version: '1.0.0', 94 | author: 'Test Author' 95 | }; 96 | 97 | beforeEach(() => { 98 | jest.clearAllMocks(); 99 | 100 | // Set up basic fs mocks 101 | mockFs.mkdirSync.mockImplementation(() => {}); 102 | mockFs.writeFileSync.mockImplementation(() => {}); 103 | mockFs.readFileSync.mockImplementation((filePath) => { 104 | if (filePath.includes('assets') || filePath.includes('.cursor/rules')) { 105 | return 'mock template content'; 106 | } 107 | if (filePath.includes('.zshrc') || filePath.includes('.bashrc')) { 108 | return '# existing config'; 109 | } 110 | return ''; 111 | }); 112 | mockFs.appendFileSync.mockImplementation(() => {}); 113 | mockFs.existsSync.mockImplementation((filePath) => { 114 | // Template source files exist 115 | if (filePath.includes('assets') || filePath.includes('.cursor/rules')) { 116 | return true; 117 | } 118 | // Shell config files exist by default 119 | if (filePath.includes('.zshrc') || filePath.includes('.bashrc')) { 120 | return true; 121 | } 122 | return false; 123 | }); 124 | 125 | // Reset utils mocks 126 | mockUtils.isSilentMode.mockReturnValue(true); 127 | mockGitUtils.insideGitWorkTree.mockReturnValue(false); 128 | 129 | // Default execSync mock 130 | execSync.mockImplementation(() => ''); 131 | 132 | tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-init-')); 133 | process.chdir(tmpDir); 134 | }); 135 | 136 | afterEach(() => { 137 | process.chdir(origCwd); 138 | fs.rmSync(tmpDir, { recursive: true, force: true }); 139 | }); 140 | 141 | describe('Git Flag Behavior', () => { 142 | it('completes successfully with git:false in dry run', async () => { 143 | const result = await initializeProject({ 144 | ...baseOptions, 145 | git: false, 146 | aliases: false, 147 | dryRun: true 148 | }); 149 | 150 | expect(result.dryRun).toBe(true); 151 | }); 152 | 153 | it('completes successfully with git:true when not inside repo', async () => { 154 | mockGitUtils.insideGitWorkTree.mockReturnValue(false); 155 | 156 | await expect( 157 | initializeProject({ 158 | ...baseOptions, 159 | git: true, 160 | aliases: false, 161 | dryRun: false 162 | }) 163 | ).resolves.not.toThrow(); 164 | }); 165 | 166 | it('completes successfully when already inside repo', async () => { 167 | mockGitUtils.insideGitWorkTree.mockReturnValue(true); 168 | 169 | await expect( 170 | initializeProject({ 171 | ...baseOptions, 172 | git: true, 173 | aliases: false, 174 | dryRun: false 175 | }) 176 | ).resolves.not.toThrow(); 177 | }); 178 | 179 | it('uses default git behavior without errors', async () => { 180 | mockGitUtils.insideGitWorkTree.mockReturnValue(false); 181 | 182 | await expect( 183 | initializeProject({ 184 | ...baseOptions, 185 | aliases: false, 186 | dryRun: false 187 | }) 188 | ).resolves.not.toThrow(); 189 | }); 190 | 191 | it('handles git command failures gracefully', async () => { 192 | mockGitUtils.insideGitWorkTree.mockReturnValue(false); 193 | execSync.mockImplementation((cmd) => { 194 | if (cmd.includes('git init')) { 195 | throw new Error('git not found'); 196 | } 197 | return ''; 198 | }); 199 | 200 | await expect( 201 | initializeProject({ 202 | ...baseOptions, 203 | git: true, 204 | aliases: false, 205 | dryRun: false 206 | }) 207 | ).resolves.not.toThrow(); 208 | }); 209 | }); 210 | 211 | describe('Alias Flag Behavior', () => { 212 | it('completes successfully when aliases:true and environment is set up', async () => { 213 | const originalShell = process.env.SHELL; 214 | const originalHome = process.env.HOME; 215 | 216 | process.env.SHELL = '/bin/zsh'; 217 | process.env.HOME = '/mock/home'; 218 | 219 | await expect( 220 | initializeProject({ 221 | ...baseOptions, 222 | git: false, 223 | aliases: true, 224 | dryRun: false 225 | }) 226 | ).resolves.not.toThrow(); 227 | 228 | process.env.SHELL = originalShell; 229 | process.env.HOME = originalHome; 230 | }); 231 | 232 | it('completes successfully when aliases:false', async () => { 233 | await expect( 234 | initializeProject({ 235 | ...baseOptions, 236 | git: false, 237 | aliases: false, 238 | dryRun: false 239 | }) 240 | ).resolves.not.toThrow(); 241 | }); 242 | 243 | it('handles missing shell gracefully', async () => { 244 | const originalShell = process.env.SHELL; 245 | const originalHome = process.env.HOME; 246 | 247 | delete process.env.SHELL; // Remove shell env var 248 | process.env.HOME = '/mock/home'; 249 | 250 | await expect( 251 | initializeProject({ 252 | ...baseOptions, 253 | git: false, 254 | aliases: true, 255 | dryRun: false 256 | }) 257 | ).resolves.not.toThrow(); 258 | 259 | process.env.SHELL = originalShell; 260 | process.env.HOME = originalHome; 261 | }); 262 | 263 | it('handles missing shell config file gracefully', async () => { 264 | const originalShell = process.env.SHELL; 265 | const originalHome = process.env.HOME; 266 | 267 | process.env.SHELL = '/bin/zsh'; 268 | process.env.HOME = '/mock/home'; 269 | 270 | // Shell config doesn't exist 271 | mockFs.existsSync.mockImplementation((filePath) => { 272 | if (filePath.includes('.zshrc') || filePath.includes('.bashrc')) { 273 | return false; 274 | } 275 | if (filePath.includes('assets') || filePath.includes('.cursor/rules')) { 276 | return true; 277 | } 278 | return false; 279 | }); 280 | 281 | await expect( 282 | initializeProject({ 283 | ...baseOptions, 284 | git: false, 285 | aliases: true, 286 | dryRun: false 287 | }) 288 | ).resolves.not.toThrow(); 289 | 290 | process.env.SHELL = originalShell; 291 | process.env.HOME = originalHome; 292 | }); 293 | }); 294 | 295 | describe('Flag Combinations', () => { 296 | it.each` 297 | git | aliases | description 298 | ${true} | ${true} | ${'git & aliases enabled'} 299 | ${true} | ${false} | ${'git enabled, aliases disabled'} 300 | ${false} | ${true} | ${'git disabled, aliases enabled'} 301 | ${false} | ${false} | ${'git & aliases disabled'} 302 | `('handles $description without errors', async ({ git, aliases }) => { 303 | const originalShell = process.env.SHELL; 304 | const originalHome = process.env.HOME; 305 | 306 | if (aliases) { 307 | process.env.SHELL = '/bin/zsh'; 308 | process.env.HOME = '/mock/home'; 309 | } 310 | 311 | if (git) { 312 | mockGitUtils.insideGitWorkTree.mockReturnValue(false); 313 | } 314 | 315 | await expect( 316 | initializeProject({ 317 | ...baseOptions, 318 | git, 319 | aliases, 320 | dryRun: false 321 | }) 322 | ).resolves.not.toThrow(); 323 | 324 | process.env.SHELL = originalShell; 325 | process.env.HOME = originalHome; 326 | }); 327 | }); 328 | 329 | describe('Dry Run Mode', () => { 330 | it('returns dry run result and performs no operations', async () => { 331 | const result = await initializeProject({ 332 | ...baseOptions, 333 | git: true, 334 | aliases: true, 335 | dryRun: true 336 | }); 337 | 338 | expect(result.dryRun).toBe(true); 339 | }); 340 | 341 | it.each` 342 | git | aliases | description 343 | ${true} | ${false} | ${'git-specific behavior'} 344 | ${false} | ${false} | ${'no-git behavior'} 345 | ${false} | ${true} | ${'alias behavior'} 346 | `('shows $description in dry run', async ({ git, aliases }) => { 347 | const result = await initializeProject({ 348 | ...baseOptions, 349 | git, 350 | aliases, 351 | dryRun: true 352 | }); 353 | 354 | expect(result.dryRun).toBe(true); 355 | }); 356 | }); 357 | 358 | describe('Error Handling', () => { 359 | it('handles npm install failures gracefully', async () => { 360 | execSync.mockImplementation((cmd) => { 361 | if (cmd.includes('npm install')) { 362 | throw new Error('npm failed'); 363 | } 364 | return ''; 365 | }); 366 | 367 | await expect( 368 | initializeProject({ 369 | ...baseOptions, 370 | git: false, 371 | aliases: false, 372 | skipInstall: false, 373 | dryRun: false 374 | }) 375 | ).resolves.not.toThrow(); 376 | }); 377 | 378 | it('handles git failures gracefully', async () => { 379 | mockGitUtils.insideGitWorkTree.mockReturnValue(false); 380 | execSync.mockImplementation((cmd) => { 381 | if (cmd.includes('git init')) { 382 | throw new Error('git failed'); 383 | } 384 | return ''; 385 | }); 386 | 387 | await expect( 388 | initializeProject({ 389 | ...baseOptions, 390 | git: true, 391 | aliases: false, 392 | dryRun: false 393 | }) 394 | ).resolves.not.toThrow(); 395 | }); 396 | 397 | it('handles file system errors gracefully', async () => { 398 | mockFs.mkdirSync.mockImplementation(() => { 399 | throw new Error('Permission denied'); 400 | }); 401 | 402 | // Should handle file system errors gracefully 403 | await expect( 404 | initializeProject({ 405 | ...baseOptions, 406 | git: false, 407 | aliases: false, 408 | dryRun: false 409 | }) 410 | ).resolves.not.toThrow(); 411 | }); 412 | }); 413 | 414 | describe('Non-Interactive Mode', () => { 415 | it('bypasses prompts with yes:true', async () => { 416 | const result = await initializeProject({ 417 | ...baseOptions, 418 | git: true, 419 | aliases: true, 420 | dryRun: true 421 | }); 422 | 423 | expect(result).toEqual({ dryRun: true }); 424 | }); 425 | 426 | it('completes without hanging', async () => { 427 | await expect( 428 | initializeProject({ 429 | ...baseOptions, 430 | git: false, 431 | aliases: false, 432 | dryRun: false 433 | }) 434 | ).resolves.not.toThrow(); 435 | }); 436 | 437 | it('handles all flag combinations without hanging', async () => { 438 | const flagCombinations = [ 439 | { git: true, aliases: true }, 440 | { git: true, aliases: false }, 441 | { git: false, aliases: true }, 442 | { git: false, aliases: false }, 443 | {} // No flags (uses defaults) 444 | ]; 445 | 446 | for (const flags of flagCombinations) { 447 | await expect( 448 | initializeProject({ 449 | ...baseOptions, 450 | ...flags, 451 | dryRun: true // Use dry run for speed 452 | }) 453 | ).resolves.not.toThrow(); 454 | } 455 | }); 456 | 457 | it('accepts complete project details', async () => { 458 | await expect( 459 | initializeProject({ 460 | name: 'test-project', 461 | description: 'test description', 462 | version: '2.0.0', 463 | author: 'Test User', 464 | git: false, 465 | aliases: false, 466 | dryRun: true 467 | }) 468 | ).resolves.not.toThrow(); 469 | }); 470 | 471 | it('works with skipInstall option', async () => { 472 | await expect( 473 | initializeProject({ 474 | ...baseOptions, 475 | skipInstall: true, 476 | git: false, 477 | aliases: false, 478 | dryRun: false 479 | }) 480 | ).resolves.not.toThrow(); 481 | }); 482 | }); 483 | 484 | describe('Function Integration', () => { 485 | it('calls utility functions without errors', async () => { 486 | await initializeProject({ 487 | ...baseOptions, 488 | git: false, 489 | aliases: false, 490 | dryRun: false 491 | }); 492 | 493 | // Verify that utility functions were called 494 | expect(mockUtils.isSilentMode).toHaveBeenCalled(); 495 | expect( 496 | mockRuleTransformer.convertAllRulesToProfileRules 497 | ).toHaveBeenCalled(); 498 | }); 499 | 500 | it('handles template operations gracefully', async () => { 501 | // Make file operations throw errors 502 | mockFs.writeFileSync.mockImplementation(() => { 503 | throw new Error('Write failed'); 504 | }); 505 | 506 | // Should complete despite file operation failures 507 | await expect( 508 | initializeProject({ 509 | ...baseOptions, 510 | git: false, 511 | aliases: false, 512 | dryRun: false 513 | }) 514 | ).resolves.not.toThrow(); 515 | }); 516 | 517 | it('validates boolean flag conversion', async () => { 518 | // Test the boolean flag handling specifically 519 | await expect( 520 | initializeProject({ 521 | ...baseOptions, 522 | git: true, // Should convert to initGit: true 523 | aliases: false, // Should convert to addAliases: false 524 | dryRun: true 525 | }) 526 | ).resolves.not.toThrow(); 527 | 528 | await expect( 529 | initializeProject({ 530 | ...baseOptions, 531 | git: false, // Should convert to initGit: false 532 | aliases: true, // Should convert to addAliases: true 533 | dryRun: true 534 | }) 535 | ).resolves.not.toThrow(); 536 | }); 537 | }); 538 | }); 539 | ``` -------------------------------------------------------------------------------- /apps/cli/src/commands/start.command.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * @fileoverview StartCommand using Commander's native class pattern 3 | * Extends Commander.Command for better integration with the framework 4 | * This is a thin presentation layer over @tm/core's TaskExecutionService 5 | */ 6 | 7 | import { Command } from 'commander'; 8 | import chalk from 'chalk'; 9 | import boxen from 'boxen'; 10 | import ora, { type Ora } from 'ora'; 11 | import { spawn } from 'child_process'; 12 | import { 13 | createTaskMasterCore, 14 | type TaskMasterCore, 15 | type StartTaskResult as CoreStartTaskResult 16 | } from '@tm/core'; 17 | import { displayTaskDetails } from '../ui/components/task-detail.component.js'; 18 | import * as ui from '../utils/ui.js'; 19 | 20 | /** 21 | * CLI-specific options interface for the start command 22 | */ 23 | export interface StartCommandOptions { 24 | id?: string; 25 | format?: 'text' | 'json'; 26 | project?: string; 27 | dryRun?: boolean; 28 | force?: boolean; 29 | noStatusUpdate?: boolean; 30 | } 31 | 32 | /** 33 | * CLI-specific result type from start command 34 | * Extends the core result with CLI-specific display information 35 | */ 36 | export interface StartCommandResult extends CoreStartTaskResult { 37 | storageType?: string; 38 | } 39 | 40 | /** 41 | * StartCommand extending Commander's Command class 42 | * This is a thin presentation layer over @tm/core's TaskExecutionService 43 | */ 44 | export class StartCommand extends Command { 45 | private tmCore?: TaskMasterCore; 46 | private lastResult?: StartCommandResult; 47 | 48 | constructor(name?: string) { 49 | super(name || 'start'); 50 | 51 | // Configure the command 52 | this.description( 53 | 'Start working on a task by launching claude-code with context' 54 | ) 55 | .argument('[id]', 'Task ID to start working on') 56 | .option('-i, --id <id>', 'Task ID to start working on') 57 | .option('-f, --format <format>', 'Output format (text, json)', 'text') 58 | .option('-p, --project <path>', 'Project root directory', process.cwd()) 59 | .option( 60 | '--dry-run', 61 | 'Show what would be executed without launching claude-code' 62 | ) 63 | .option( 64 | '--force', 65 | 'Force start even if another task is already in-progress' 66 | ) 67 | .option( 68 | '--no-status-update', 69 | 'Do not automatically update task status to in-progress' 70 | ) 71 | .action( 72 | async (taskId: string | undefined, options: StartCommandOptions) => { 73 | await this.executeCommand(taskId, options); 74 | } 75 | ); 76 | } 77 | 78 | /** 79 | * Execute the start command 80 | */ 81 | private async executeCommand( 82 | taskId: string | undefined, 83 | options: StartCommandOptions 84 | ): Promise<void> { 85 | let spinner: Ora | null = null; 86 | 87 | try { 88 | // Validate options 89 | if (!this.validateOptions(options)) { 90 | process.exit(1); 91 | } 92 | 93 | // Initialize tm-core with spinner 94 | spinner = ora('Initializing Task Master...').start(); 95 | await this.initializeCore(options.project || process.cwd()); 96 | spinner.succeed('Task Master initialized'); 97 | 98 | // Get the task ID from argument or option, or find next available task 99 | const idArg = taskId || options.id || null; 100 | let targetTaskId = idArg; 101 | 102 | if (!targetTaskId) { 103 | spinner = ora('Finding next available task...').start(); 104 | targetTaskId = await this.performGetNextTask(); 105 | if (targetTaskId) { 106 | spinner.succeed(`Found next task: #${targetTaskId}`); 107 | } else { 108 | spinner.fail('No available tasks found'); 109 | } 110 | } 111 | 112 | if (!targetTaskId) { 113 | ui.displayError('No task ID provided and no available tasks found'); 114 | process.exit(1); 115 | } 116 | 117 | // Show pre-launch message (no spinner needed, it's just display) 118 | if (!options.dryRun) { 119 | await this.showPreLaunchMessage(targetTaskId); 120 | } 121 | 122 | // Use tm-core's startTask method with spinner 123 | spinner = ora('Preparing task execution...').start(); 124 | const coreResult = await this.performStartTask(targetTaskId, options); 125 | 126 | if (coreResult.started) { 127 | spinner.succeed( 128 | options.dryRun 129 | ? 'Dry run completed' 130 | : 'Task prepared - launching Claude...' 131 | ); 132 | } else { 133 | spinner.fail('Task execution failed'); 134 | } 135 | 136 | // Execute command if we have one and it's not a dry run 137 | if (!options.dryRun && coreResult.command) { 138 | // Stop any remaining spinners before launching Claude 139 | if (spinner && !spinner.isSpinning) { 140 | // Clear the line to make room for Claude 141 | console.log(); 142 | } 143 | await this.executeChildProcess(coreResult.command); 144 | } 145 | 146 | // Convert core result to CLI result with storage type 147 | const result: StartCommandResult = { 148 | ...coreResult, 149 | storageType: this.tmCore?.getStorageType() 150 | }; 151 | 152 | // Store result for programmatic access 153 | this.setLastResult(result); 154 | 155 | // Display results (only for dry run or if execution failed) 156 | if (options.dryRun || !coreResult.started) { 157 | this.displayResults(result, options); 158 | } 159 | } catch (error: any) { 160 | if (spinner) { 161 | spinner.fail('Operation failed'); 162 | } 163 | this.handleError(error); 164 | process.exit(1); 165 | } 166 | } 167 | 168 | /** 169 | * Validate command options 170 | */ 171 | private validateOptions(options: StartCommandOptions): boolean { 172 | // Validate format 173 | if (options.format && !['text', 'json'].includes(options.format)) { 174 | console.error(chalk.red(`Invalid format: ${options.format}`)); 175 | console.error(chalk.gray(`Valid formats: text, json`)); 176 | return false; 177 | } 178 | 179 | return true; 180 | } 181 | 182 | /** 183 | * Initialize TaskMasterCore 184 | */ 185 | private async initializeCore(projectRoot: string): Promise<void> { 186 | if (!this.tmCore) { 187 | this.tmCore = await createTaskMasterCore({ projectPath: projectRoot }); 188 | } 189 | } 190 | 191 | /** 192 | * Get the next available task using tm-core 193 | */ 194 | private async performGetNextTask(): Promise<string | null> { 195 | if (!this.tmCore) { 196 | throw new Error('TaskMasterCore not initialized'); 197 | } 198 | return this.tmCore.getNextAvailableTask(); 199 | } 200 | 201 | /** 202 | * Show pre-launch message using tm-core data 203 | */ 204 | private async showPreLaunchMessage(targetTaskId: string): Promise<void> { 205 | if (!this.tmCore) return; 206 | 207 | const { task, subtask, subtaskId } = 208 | await this.tmCore.getTaskWithSubtask(targetTaskId); 209 | if (task) { 210 | const workItemText = subtask 211 | ? `Subtask #${task.id}.${subtaskId} - ${subtask.title}` 212 | : `Task #${task.id} - ${task.title}`; 213 | 214 | console.log( 215 | chalk.green('🚀 Starting: ') + chalk.white.bold(workItemText) 216 | ); 217 | console.log(chalk.gray('Launching Claude Code...')); 218 | console.log(); // Empty line 219 | } 220 | } 221 | 222 | /** 223 | * Perform start task using tm-core business logic 224 | */ 225 | private async performStartTask( 226 | targetTaskId: string, 227 | options: StartCommandOptions 228 | ): Promise<CoreStartTaskResult> { 229 | if (!this.tmCore) { 230 | throw new Error('TaskMasterCore not initialized'); 231 | } 232 | 233 | // Show spinner for status update if enabled 234 | let statusSpinner: Ora | null = null; 235 | if (!options.noStatusUpdate && !options.dryRun) { 236 | statusSpinner = ora('Updating task status to in-progress...').start(); 237 | } 238 | 239 | // Get execution command from tm-core (instead of executing directly) 240 | const result = await this.tmCore.startTask(targetTaskId, { 241 | dryRun: options.dryRun, 242 | force: options.force, 243 | updateStatus: !options.noStatusUpdate 244 | }); 245 | 246 | if (statusSpinner) { 247 | if (result.started) { 248 | statusSpinner.succeed('Task status updated'); 249 | } else { 250 | statusSpinner.warn('Task status update skipped'); 251 | } 252 | } 253 | 254 | if (!result) { 255 | throw new Error('Failed to start task - core result is undefined'); 256 | } 257 | 258 | // Don't execute here - let the main executeCommand method handle it 259 | return result; 260 | } 261 | 262 | /** 263 | * Execute the child process directly in the main thread for better process control 264 | */ 265 | private async executeChildProcess(command: { 266 | executable: string; 267 | args: string[]; 268 | cwd: string; 269 | }): Promise<void> { 270 | return new Promise((resolve, reject) => { 271 | // Don't show the full command with args as it can be very long 272 | console.log(chalk.green('🚀 Launching Claude Code...')); 273 | console.log(); // Add space before Claude takes over 274 | 275 | const childProcess = spawn(command.executable, command.args, { 276 | cwd: command.cwd, 277 | stdio: 'inherit', // Inherit stdio from parent process 278 | shell: false 279 | }); 280 | 281 | childProcess.on('close', (code) => { 282 | if (code === 0) { 283 | resolve(); 284 | } else { 285 | reject(new Error(`Process exited with code ${code}`)); 286 | } 287 | }); 288 | 289 | childProcess.on('error', (error) => { 290 | reject(new Error(`Failed to spawn process: ${error.message}`)); 291 | }); 292 | 293 | // Handle process termination signals gracefully 294 | const cleanup = () => { 295 | if (childProcess && !childProcess.killed) { 296 | childProcess.kill('SIGTERM'); 297 | } 298 | }; 299 | 300 | process.on('SIGINT', cleanup); 301 | process.on('SIGTERM', cleanup); 302 | process.on('exit', cleanup); 303 | }); 304 | } 305 | 306 | /** 307 | * Display results based on format 308 | */ 309 | private displayResults( 310 | result: StartCommandResult, 311 | options: StartCommandOptions 312 | ): void { 313 | const format = options.format || 'text'; 314 | 315 | switch (format) { 316 | case 'json': 317 | this.displayJson(result); 318 | break; 319 | 320 | case 'text': 321 | default: 322 | this.displayTextResult(result, options); 323 | break; 324 | } 325 | } 326 | 327 | /** 328 | * Display in JSON format 329 | */ 330 | private displayJson(result: StartCommandResult): void { 331 | console.log(JSON.stringify(result, null, 2)); 332 | } 333 | 334 | /** 335 | * Display result in text format 336 | */ 337 | private displayTextResult( 338 | result: StartCommandResult, 339 | options: StartCommandOptions 340 | ): void { 341 | if (!result.found || !result.task) { 342 | console.log( 343 | boxen(chalk.yellow(`Task not found!`), { 344 | padding: { top: 0, bottom: 0, left: 1, right: 1 }, 345 | borderColor: 'yellow', 346 | borderStyle: 'round', 347 | margin: { top: 1 } 348 | }) 349 | ); 350 | return; 351 | } 352 | 353 | const task = result.task; 354 | 355 | if (options.dryRun) { 356 | // For dry run, show full details since Claude Code won't be launched 357 | let headerText = `Dry Run: Starting Task #${task.id} - ${task.title}`; 358 | 359 | // If working on a specific subtask, highlight it in the header 360 | if (result.subtask && result.subtaskId) { 361 | headerText = `Dry Run: Starting Subtask #${task.id}.${result.subtaskId} - ${result.subtask.title}`; 362 | } 363 | 364 | displayTaskDetails(task, { 365 | customHeader: headerText, 366 | headerColor: 'yellow' 367 | }); 368 | 369 | // Show claude-code prompt 370 | if (result.executionOutput) { 371 | console.log(); // Empty line for spacing 372 | console.log( 373 | boxen( 374 | chalk.white.bold('Claude-Code Prompt:') + 375 | '\n\n' + 376 | result.executionOutput, 377 | { 378 | padding: 1, 379 | borderStyle: 'round', 380 | borderColor: 'cyan', 381 | width: process.stdout.columns * 0.95 || 100 382 | } 383 | ) 384 | ); 385 | } 386 | 387 | console.log(); // Empty line for spacing 388 | console.log( 389 | boxen( 390 | chalk.yellow( 391 | '🔍 Dry run - claude-code would be launched with the above prompt' 392 | ), 393 | { 394 | padding: { top: 0, bottom: 0, left: 1, right: 1 }, 395 | borderColor: 'yellow', 396 | borderStyle: 'round' 397 | } 398 | ) 399 | ); 400 | } else { 401 | // For actual execution, show minimal info since Claude Code will clear the terminal 402 | if (result.started) { 403 | // Determine what was worked on - task or subtask 404 | let workItemText = `Task: #${task.id} - ${task.title}`; 405 | let statusTarget = task.id; 406 | 407 | if (result.subtask && result.subtaskId) { 408 | workItemText = `Subtask: #${task.id}.${result.subtaskId} - ${result.subtask.title}`; 409 | statusTarget = `${task.id}.${result.subtaskId}`; 410 | } 411 | 412 | // Post-execution message (shown after Claude Code exits) 413 | console.log( 414 | boxen( 415 | chalk.green.bold('🎉 Task Session Complete!') + 416 | '\n\n' + 417 | chalk.white(workItemText) + 418 | '\n\n' + 419 | chalk.cyan('Next steps:') + 420 | '\n' + 421 | `• Run ${chalk.yellow('tm show ' + task.id)} to review task details\n` + 422 | `• Run ${chalk.yellow('tm set-status --id=' + statusTarget + ' --status=done')} when complete\n` + 423 | `• Run ${chalk.yellow('tm next')} to find the next available task\n` + 424 | `• Run ${chalk.yellow('tm start')} to begin the next task`, 425 | { 426 | padding: 1, 427 | borderStyle: 'round', 428 | borderColor: 'green', 429 | width: process.stdout.columns * 0.95 || 100, 430 | margin: { top: 1 } 431 | } 432 | ) 433 | ); 434 | } else { 435 | // Error case 436 | console.log( 437 | boxen( 438 | chalk.red( 439 | '❌ Failed to launch claude-code' + 440 | (result.error ? `\nError: ${result.error}` : '') 441 | ), 442 | { 443 | padding: { top: 0, bottom: 0, left: 1, right: 1 }, 444 | borderColor: 'red', 445 | borderStyle: 'round' 446 | } 447 | ) 448 | ); 449 | } 450 | } 451 | 452 | console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`); 453 | } 454 | 455 | /** 456 | * Handle general errors 457 | */ 458 | private handleError(error: any): void { 459 | const msg = error?.getSanitizedDetails?.() ?? { 460 | message: error?.message ?? String(error) 461 | }; 462 | console.error(chalk.red(`Error: ${msg.message || 'Unexpected error'}`)); 463 | 464 | // Show stack trace in development mode or when DEBUG is set 465 | const isDevelopment = process.env.NODE_ENV !== 'production'; 466 | if ((isDevelopment || process.env.DEBUG) && error.stack) { 467 | console.error(chalk.gray(error.stack)); 468 | } 469 | } 470 | 471 | /** 472 | * Set the last result for programmatic access 473 | */ 474 | private setLastResult(result: StartCommandResult): void { 475 | this.lastResult = result; 476 | } 477 | 478 | /** 479 | * Get the last result (for programmatic usage) 480 | */ 481 | getLastResult(): StartCommandResult | undefined { 482 | return this.lastResult; 483 | } 484 | 485 | /** 486 | * Clean up resources 487 | */ 488 | async cleanup(): Promise<void> { 489 | if (this.tmCore) { 490 | await this.tmCore.close(); 491 | this.tmCore = undefined; 492 | } 493 | } 494 | 495 | /** 496 | * Static method to register this command on an existing program 497 | */ 498 | static registerOn(program: Command): Command { 499 | const startCommand = new StartCommand(); 500 | program.addCommand(startCommand); 501 | return startCommand; 502 | } 503 | 504 | /** 505 | * Alternative registration that returns the command for chaining 506 | */ 507 | static register(program: Command, name?: string): StartCommand { 508 | const startCommand = new StartCommand(name); 509 | program.addCommand(startCommand); 510 | return startCommand; 511 | } 512 | } 513 | ``` -------------------------------------------------------------------------------- /packages/tm-core/src/types/database.types.ts: -------------------------------------------------------------------------------- ```typescript 1 | export type Json = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | { [key: string]: Json | undefined } 7 | | Json[]; 8 | 9 | export type Database = { 10 | public: { 11 | Tables: { 12 | accounts: { 13 | Row: { 14 | created_at: string | null; 15 | created_by: string | null; 16 | email: string | null; 17 | id: string; 18 | is_personal_account: boolean; 19 | name: string; 20 | picture_url: string | null; 21 | primary_owner_user_id: string; 22 | public_data: Json; 23 | slug: string | null; 24 | updated_at: string | null; 25 | updated_by: string | null; 26 | }; 27 | Insert: { 28 | created_at?: string | null; 29 | created_by?: string | null; 30 | email?: string | null; 31 | id?: string; 32 | is_personal_account?: boolean; 33 | name: string; 34 | picture_url?: string | null; 35 | primary_owner_user_id?: string; 36 | public_data?: Json; 37 | slug?: string | null; 38 | updated_at?: string | null; 39 | updated_by?: string | null; 40 | }; 41 | Update: { 42 | created_at?: string | null; 43 | created_by?: string | null; 44 | email?: string | null; 45 | id?: string; 46 | is_personal_account?: boolean; 47 | name?: string; 48 | picture_url?: string | null; 49 | primary_owner_user_id?: string; 50 | public_data?: Json; 51 | slug?: string | null; 52 | updated_at?: string | null; 53 | updated_by?: string | null; 54 | }; 55 | Relationships: []; 56 | }; 57 | brief: { 58 | Row: { 59 | account_id: string; 60 | created_at: string; 61 | created_by: string; 62 | document_id: string; 63 | id: string; 64 | plan_generation_completed_at: string | null; 65 | plan_generation_error: string | null; 66 | plan_generation_started_at: string | null; 67 | plan_generation_status: Database['public']['Enums']['plan_generation_status']; 68 | status: Database['public']['Enums']['brief_status']; 69 | updated_at: string; 70 | }; 71 | Insert: { 72 | account_id: string; 73 | created_at?: string; 74 | created_by: string; 75 | document_id: string; 76 | id?: string; 77 | plan_generation_completed_at?: string | null; 78 | plan_generation_error?: string | null; 79 | plan_generation_started_at?: string | null; 80 | plan_generation_status?: Database['public']['Enums']['plan_generation_status']; 81 | status?: Database['public']['Enums']['brief_status']; 82 | updated_at?: string; 83 | }; 84 | Update: { 85 | account_id?: string; 86 | created_at?: string; 87 | created_by?: string; 88 | document_id?: string; 89 | id?: string; 90 | plan_generation_completed_at?: string | null; 91 | plan_generation_error?: string | null; 92 | plan_generation_started_at?: string | null; 93 | plan_generation_status?: Database['public']['Enums']['plan_generation_status']; 94 | status?: Database['public']['Enums']['brief_status']; 95 | updated_at?: string; 96 | }; 97 | Relationships: [ 98 | { 99 | foreignKeyName: 'brief_account_id_fkey'; 100 | columns: ['account_id']; 101 | isOneToOne: false; 102 | referencedRelation: 'accounts'; 103 | referencedColumns: ['id']; 104 | }, 105 | { 106 | foreignKeyName: 'brief_document_id_fkey'; 107 | columns: ['document_id']; 108 | isOneToOne: false; 109 | referencedRelation: 'document'; 110 | referencedColumns: ['id']; 111 | } 112 | ]; 113 | }; 114 | document: { 115 | Row: { 116 | account_id: string; 117 | created_at: string; 118 | created_by: string; 119 | description: string | null; 120 | document_name: string; 121 | document_type: Database['public']['Enums']['document_type']; 122 | file_path: string | null; 123 | file_size: number | null; 124 | id: string; 125 | metadata: Json | null; 126 | mime_type: string | null; 127 | processed_at: string | null; 128 | processing_error: string | null; 129 | processing_status: 130 | | Database['public']['Enums']['document_processing_status'] 131 | | null; 132 | source_id: string | null; 133 | source_type: string | null; 134 | title: string; 135 | updated_at: string; 136 | }; 137 | Insert: { 138 | account_id: string; 139 | created_at?: string; 140 | created_by: string; 141 | description?: string | null; 142 | document_name: string; 143 | document_type?: Database['public']['Enums']['document_type']; 144 | file_path?: string | null; 145 | file_size?: number | null; 146 | id?: string; 147 | metadata?: Json | null; 148 | mime_type?: string | null; 149 | processed_at?: string | null; 150 | processing_error?: string | null; 151 | processing_status?: 152 | | Database['public']['Enums']['document_processing_status'] 153 | | null; 154 | source_id?: string | null; 155 | source_type?: string | null; 156 | title: string; 157 | updated_at?: string; 158 | }; 159 | Update: { 160 | account_id?: string; 161 | created_at?: string; 162 | created_by?: string; 163 | description?: string | null; 164 | document_name?: string; 165 | document_type?: Database['public']['Enums']['document_type']; 166 | file_path?: string | null; 167 | file_size?: number | null; 168 | id?: string; 169 | metadata?: Json | null; 170 | mime_type?: string | null; 171 | processed_at?: string | null; 172 | processing_error?: string | null; 173 | processing_status?: 174 | | Database['public']['Enums']['document_processing_status'] 175 | | null; 176 | source_id?: string | null; 177 | source_type?: string | null; 178 | title?: string; 179 | updated_at?: string; 180 | }; 181 | Relationships: [ 182 | { 183 | foreignKeyName: 'document_account_id_fkey'; 184 | columns: ['account_id']; 185 | isOneToOne: false; 186 | referencedRelation: 'accounts'; 187 | referencedColumns: ['id']; 188 | } 189 | ]; 190 | }; 191 | tasks: { 192 | Row: { 193 | account_id: string; 194 | actual_hours: number; 195 | assignee_id: string | null; 196 | brief_id: string | null; 197 | completed_subtasks: number; 198 | complexity: number | null; 199 | created_at: string; 200 | created_by: string; 201 | description: string | null; 202 | display_id: string | null; 203 | document_id: string | null; 204 | due_date: string | null; 205 | estimated_hours: number | null; 206 | id: string; 207 | metadata: Json; 208 | parent_task_id: string | null; 209 | position: number; 210 | priority: Database['public']['Enums']['task_priority']; 211 | status: Database['public']['Enums']['task_status']; 212 | subtask_position: number; 213 | title: string; 214 | total_subtasks: number; 215 | updated_at: string; 216 | updated_by: string; 217 | }; 218 | Insert: { 219 | account_id: string; 220 | actual_hours?: number; 221 | assignee_id?: string | null; 222 | brief_id?: string | null; 223 | completed_subtasks?: number; 224 | complexity?: number | null; 225 | created_at?: string; 226 | created_by: string; 227 | description?: string | null; 228 | display_id?: string | null; 229 | document_id?: string | null; 230 | due_date?: string | null; 231 | estimated_hours?: number | null; 232 | id?: string; 233 | metadata?: Json; 234 | parent_task_id?: string | null; 235 | position?: number; 236 | priority?: Database['public']['Enums']['task_priority']; 237 | status?: Database['public']['Enums']['task_status']; 238 | subtask_position?: number; 239 | title: string; 240 | total_subtasks?: number; 241 | updated_at?: string; 242 | updated_by: string; 243 | }; 244 | Update: { 245 | account_id?: string; 246 | actual_hours?: number; 247 | assignee_id?: string | null; 248 | brief_id?: string | null; 249 | completed_subtasks?: number; 250 | complexity?: number | null; 251 | created_at?: string; 252 | created_by?: string; 253 | description?: string | null; 254 | display_id?: string | null; 255 | document_id?: string | null; 256 | due_date?: string | null; 257 | estimated_hours?: number | null; 258 | id?: string; 259 | metadata?: Json; 260 | parent_task_id?: string | null; 261 | position?: number; 262 | priority?: Database['public']['Enums']['task_priority']; 263 | status?: Database['public']['Enums']['task_status']; 264 | subtask_position?: number; 265 | title?: string; 266 | total_subtasks?: number; 267 | updated_at?: string; 268 | updated_by?: string; 269 | }; 270 | Relationships: [ 271 | { 272 | foreignKeyName: 'tasks_account_id_fkey'; 273 | columns: ['account_id']; 274 | isOneToOne: false; 275 | referencedRelation: 'accounts'; 276 | referencedColumns: ['id']; 277 | }, 278 | { 279 | foreignKeyName: 'tasks_brief_id_fkey'; 280 | columns: ['brief_id']; 281 | isOneToOne: false; 282 | referencedRelation: 'brief'; 283 | referencedColumns: ['id']; 284 | }, 285 | { 286 | foreignKeyName: 'tasks_document_id_fkey'; 287 | columns: ['document_id']; 288 | isOneToOne: false; 289 | referencedRelation: 'document'; 290 | referencedColumns: ['id']; 291 | }, 292 | { 293 | foreignKeyName: 'tasks_parent_task_id_fkey'; 294 | columns: ['parent_task_id']; 295 | isOneToOne: false; 296 | referencedRelation: 'tasks'; 297 | referencedColumns: ['id']; 298 | } 299 | ]; 300 | }; 301 | task_dependencies: { 302 | Row: { 303 | account_id: string; 304 | created_at: string; 305 | depends_on_task_id: string; 306 | id: string; 307 | task_id: string; 308 | }; 309 | Insert: { 310 | account_id: string; 311 | created_at?: string; 312 | depends_on_task_id: string; 313 | id?: string; 314 | task_id: string; 315 | }; 316 | Update: { 317 | account_id?: string; 318 | created_at?: string; 319 | depends_on_task_id?: string; 320 | id?: string; 321 | task_id?: string; 322 | }; 323 | Relationships: [ 324 | { 325 | foreignKeyName: 'task_dependencies_account_id_fkey'; 326 | columns: ['account_id']; 327 | isOneToOne: false; 328 | referencedRelation: 'accounts'; 329 | referencedColumns: ['id']; 330 | }, 331 | { 332 | foreignKeyName: 'task_dependencies_depends_on_task_id_fkey'; 333 | columns: ['depends_on_task_id']; 334 | isOneToOne: false; 335 | referencedRelation: 'tasks'; 336 | referencedColumns: ['id']; 337 | }, 338 | { 339 | foreignKeyName: 'task_dependencies_task_id_fkey'; 340 | columns: ['task_id']; 341 | isOneToOne: false; 342 | referencedRelation: 'tasks'; 343 | referencedColumns: ['id']; 344 | } 345 | ]; 346 | }; 347 | user_accounts: { 348 | Row: { 349 | id: string | null; 350 | name: string | null; 351 | picture_url: string | null; 352 | role: string | null; 353 | slug: string | null; 354 | }; 355 | Insert: { 356 | id?: string | null; 357 | name?: string | null; 358 | picture_url?: string | null; 359 | role?: string | null; 360 | slug?: string | null; 361 | }; 362 | Update: { 363 | id?: string | null; 364 | name?: string | null; 365 | picture_url?: string | null; 366 | role?: string | null; 367 | slug?: string | null; 368 | }; 369 | Relationships: []; 370 | }; 371 | }; 372 | Views: { 373 | [_ in never]: never; 374 | }; 375 | Functions: { 376 | [_ in never]: never; 377 | }; 378 | Enums: { 379 | brief_status: 380 | | 'draft' 381 | | 'refining' 382 | | 'aligned' 383 | | 'delivering' 384 | | 'delivered' 385 | | 'done' 386 | | 'archived'; 387 | document_processing_status: 'pending' | 'processing' | 'ready' | 'failed'; 388 | document_type: 389 | | 'brief' 390 | | 'blueprint' 391 | | 'file' 392 | | 'note' 393 | | 'transcript' 394 | | 'generated_plan' 395 | | 'generated_task' 396 | | 'generated_summary' 397 | | 'method' 398 | | 'task'; 399 | plan_generation_status: 400 | | 'not_started' 401 | | 'generating' 402 | | 'completed' 403 | | 'failed'; 404 | task_priority: 'low' | 'medium' | 'high' | 'urgent'; 405 | task_status: 'todo' | 'in_progress' | 'done'; 406 | }; 407 | CompositeTypes: { 408 | [_ in never]: never; 409 | }; 410 | }; 411 | }; 412 | 413 | export type Tables< 414 | PublicTableNameOrOptions extends 415 | | keyof (Database['public']['Tables'] & Database['public']['Views']) 416 | | { schema: keyof Database }, 417 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 418 | ? keyof (Database[PublicTableNameOrOptions['schema']]['Tables'] & 419 | Database[PublicTableNameOrOptions['schema']]['Views']) 420 | : never = never 421 | > = PublicTableNameOrOptions extends { schema: keyof Database } 422 | ? (Database[PublicTableNameOrOptions['schema']]['Tables'] & 423 | Database[PublicTableNameOrOptions['schema']]['Views'])[TableName] extends { 424 | Row: infer R; 425 | } 426 | ? R 427 | : never 428 | : PublicTableNameOrOptions extends keyof (Database['public']['Tables'] & 429 | Database['public']['Views']) 430 | ? (Database['public']['Tables'] & 431 | Database['public']['Views'])[PublicTableNameOrOptions] extends { 432 | Row: infer R; 433 | } 434 | ? R 435 | : never 436 | : never; 437 | 438 | export type TablesInsert< 439 | PublicTableNameOrOptions extends 440 | | keyof Database['public']['Tables'] 441 | | { schema: keyof Database }, 442 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 443 | ? keyof Database[PublicTableNameOrOptions['schema']]['Tables'] 444 | : never = never 445 | > = PublicTableNameOrOptions extends { schema: keyof Database } 446 | ? Database[PublicTableNameOrOptions['schema']]['Tables'][TableName] extends { 447 | Insert: infer I; 448 | } 449 | ? I 450 | : never 451 | : PublicTableNameOrOptions extends keyof Database['public']['Tables'] 452 | ? Database['public']['Tables'][PublicTableNameOrOptions] extends { 453 | Insert: infer I; 454 | } 455 | ? I 456 | : never 457 | : never; 458 | 459 | export type TablesUpdate< 460 | PublicTableNameOrOptions extends 461 | | keyof Database['public']['Tables'] 462 | | { schema: keyof Database }, 463 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 464 | ? keyof Database[PublicTableNameOrOptions['schema']]['Tables'] 465 | : never = never 466 | > = PublicTableNameOrOptions extends { schema: keyof Database } 467 | ? Database[PublicTableNameOrOptions['schema']]['Tables'][TableName] extends { 468 | Update: infer U; 469 | } 470 | ? U 471 | : never 472 | : PublicTableNameOrOptions extends keyof Database['public']['Tables'] 473 | ? Database['public']['Tables'][PublicTableNameOrOptions] extends { 474 | Update: infer U; 475 | } 476 | ? U 477 | : never 478 | : never; 479 | 480 | export type Enums< 481 | PublicEnumNameOrOptions extends 482 | | keyof Database['public']['Enums'] 483 | | { schema: keyof Database }, 484 | EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } 485 | ? keyof Database[PublicEnumNameOrOptions['schema']]['Enums'] 486 | : never = never 487 | > = PublicEnumNameOrOptions extends { schema: keyof Database } 488 | ? Database[PublicEnumNameOrOptions['schema']]['Enums'][EnumName] 489 | : PublicEnumNameOrOptions extends keyof Database['public']['Enums'] 490 | ? Database['public']['Enums'][PublicEnumNameOrOptions] 491 | : never; 492 | ``` -------------------------------------------------------------------------------- /tests/unit/commands.test.js: -------------------------------------------------------------------------------- ```javascript 1 | /** 2 | * Commands module tests - Focus on CLI setup and integration 3 | */ 4 | 5 | import { jest } from '@jest/globals'; 6 | 7 | // Mock modules first 8 | jest.mock('fs', () => ({ 9 | existsSync: jest.fn(), 10 | readFileSync: jest.fn() 11 | })); 12 | 13 | jest.mock('path', () => ({ 14 | join: jest.fn((dir, file) => `${dir}/${file}`) 15 | })); 16 | 17 | jest.mock('chalk', () => ({ 18 | red: jest.fn((text) => text), 19 | blue: jest.fn((text) => text), 20 | green: jest.fn((text) => text), 21 | yellow: jest.fn((text) => text), 22 | white: jest.fn((text) => ({ 23 | bold: jest.fn((text) => text) 24 | })), 25 | reset: jest.fn((text) => text) 26 | })); 27 | 28 | // Mock config-manager to prevent file system discovery issues 29 | jest.mock('../../scripts/modules/config-manager.js', () => ({ 30 | getLogLevel: jest.fn(() => 'info'), 31 | getDebugFlag: jest.fn(() => false), 32 | getConfig: jest.fn(() => ({})), // Return empty config to prevent real loading 33 | getGlobalConfig: jest.fn(() => ({})) 34 | })); 35 | 36 | // Mock path-utils to prevent file system discovery issues 37 | jest.mock('../../src/utils/path-utils.js', () => ({ 38 | __esModule: true, 39 | findProjectRoot: jest.fn(() => '/mock/project'), 40 | findConfigPath: jest.fn(() => null), 41 | findTasksPath: jest.fn(() => '/mock/tasks.json'), 42 | findComplexityReportPath: jest.fn(() => null), 43 | resolveTasksOutputPath: jest.fn(() => '/mock/tasks.json'), 44 | resolveComplexityReportOutputPath: jest.fn(() => '/mock/report.json') 45 | })); 46 | 47 | jest.mock('../../scripts/modules/ui.js', () => ({ 48 | displayBanner: jest.fn(), 49 | displayHelp: jest.fn() 50 | })); 51 | 52 | // Add utility functions for testing 53 | const toKebabCase = (str) => { 54 | return str 55 | .replace(/([a-z0-9])([A-Z])/g, '$1-$2') 56 | .toLowerCase() 57 | .replace(/^-/, ''); 58 | }; 59 | 60 | function detectCamelCaseFlags(args) { 61 | const camelCaseFlags = []; 62 | for (const arg of args) { 63 | if (arg.startsWith('--')) { 64 | const flagName = arg.split('=')[0].slice(2); 65 | 66 | if (!flagName.includes('-')) { 67 | if (/[a-z][A-Z]/.test(flagName)) { 68 | const kebabVersion = toKebabCase(flagName); 69 | if (kebabVersion !== flagName) { 70 | camelCaseFlags.push({ 71 | original: flagName, 72 | kebabCase: kebabVersion 73 | }); 74 | } 75 | } 76 | } 77 | } 78 | } 79 | return camelCaseFlags; 80 | } 81 | 82 | jest.mock('../../scripts/modules/utils.js', () => ({ 83 | CONFIG: { 84 | projectVersion: '1.5.0' 85 | }, 86 | log: jest.fn(() => {}), // Prevent any real logging that could trigger config discovery 87 | toKebabCase: toKebabCase, 88 | detectCamelCaseFlags: detectCamelCaseFlags 89 | })); 90 | 91 | // Import all modules after mocking 92 | import fs from 'fs'; 93 | import path from 'path'; 94 | import { setupCLI } from '../../scripts/modules/commands.js'; 95 | import { 96 | RULES_SETUP_ACTION, 97 | RULES_ACTIONS 98 | } from '../../src/constants/rules-actions.js'; 99 | import { compareVersions } from '@tm/cli'; 100 | 101 | describe('Commands Module - CLI Setup and Integration', () => { 102 | const mockExistsSync = jest.spyOn(fs, 'existsSync'); 103 | 104 | beforeEach(() => { 105 | jest.clearAllMocks(); 106 | mockExistsSync.mockReturnValue(true); 107 | }); 108 | 109 | afterAll(() => { 110 | jest.restoreAllMocks(); 111 | }); 112 | 113 | describe('setupCLI function', () => { 114 | test('should return Commander program instance', () => { 115 | const program = setupCLI(); 116 | expect(program).toBeDefined(); 117 | expect(program.name()).toBe('task-master'); 118 | }); 119 | 120 | test('should return version that matches package.json when TM_PUBLIC_VERSION is set', () => { 121 | // Read actual version from package.json 122 | const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); 123 | const expectedVersion = packageJson.version; 124 | 125 | // Set environment variable to match package.json 126 | const originalEnv = process.env.TM_PUBLIC_VERSION; 127 | process.env.TM_PUBLIC_VERSION = expectedVersion; 128 | 129 | const program = setupCLI(); 130 | const version = program.version(); 131 | expect(version).toBe(expectedVersion); 132 | 133 | // Restore original environment 134 | if (originalEnv !== undefined) { 135 | process.env.TM_PUBLIC_VERSION = originalEnv; 136 | } else { 137 | delete process.env.TM_PUBLIC_VERSION; 138 | } 139 | }); 140 | 141 | test('should use default version when TM_PUBLIC_VERSION is not available', () => { 142 | const originalEnv = process.env.TM_PUBLIC_VERSION; 143 | delete process.env.TM_PUBLIC_VERSION; 144 | 145 | const program = setupCLI(); 146 | const version = program.version(); 147 | expect(version).toBe('unknown'); 148 | 149 | // Restore original environment 150 | if (originalEnv !== undefined) { 151 | process.env.TM_PUBLIC_VERSION = originalEnv; 152 | } 153 | }); 154 | }); 155 | 156 | describe('CLI Flag Format Validation', () => { 157 | test('should detect camelCase flags correctly', () => { 158 | const args = ['node', 'task-master', '--camelCase', '--kebab-case']; 159 | const camelCaseFlags = args.filter( 160 | (arg) => 161 | arg.startsWith('--') && /[A-Z]/.test(arg) && !arg.includes('-[A-Z]') 162 | ); 163 | expect(camelCaseFlags).toContain('--camelCase'); 164 | expect(camelCaseFlags).not.toContain('--kebab-case'); 165 | }); 166 | 167 | test('should accept kebab-case flags correctly', () => { 168 | const args = ['node', 'task-master', '--kebab-case']; 169 | const camelCaseFlags = args.filter( 170 | (arg) => 171 | arg.startsWith('--') && /[A-Z]/.test(arg) && !arg.includes('-[A-Z]') 172 | ); 173 | expect(camelCaseFlags).toHaveLength(0); 174 | }); 175 | 176 | test('toKebabCase should convert camelCase to kebab-case', () => { 177 | expect(toKebabCase('promptText')).toBe('prompt-text'); 178 | expect(toKebabCase('userID')).toBe('user-id'); 179 | expect(toKebabCase('numTasks')).toBe('num-tasks'); 180 | expect(toKebabCase('alreadyKebabCase')).toBe('already-kebab-case'); 181 | }); 182 | 183 | test('detectCamelCaseFlags should identify camelCase flags', () => { 184 | const args = [ 185 | 'node', 186 | 'task-master', 187 | 'add-task', 188 | '--promptText=test', 189 | '--userID=123' 190 | ]; 191 | const flags = detectCamelCaseFlags(args); 192 | 193 | expect(flags).toHaveLength(2); 194 | expect(flags).toContainEqual({ 195 | original: 'promptText', 196 | kebabCase: 'prompt-text' 197 | }); 198 | expect(flags).toContainEqual({ 199 | original: 'userID', 200 | kebabCase: 'user-id' 201 | }); 202 | }); 203 | 204 | test('detectCamelCaseFlags should not flag kebab-case flags', () => { 205 | const args = [ 206 | 'node', 207 | 'task-master', 208 | 'add-task', 209 | '--prompt-text=test', 210 | '--user-id=123' 211 | ]; 212 | const flags = detectCamelCaseFlags(args); 213 | 214 | expect(flags).toHaveLength(0); 215 | }); 216 | 217 | test('detectCamelCaseFlags should respect single-word flags', () => { 218 | const args = [ 219 | 'node', 220 | 'task-master', 221 | 'add-task', 222 | '--prompt=test', 223 | '--file=test.json', 224 | '--priority=high', 225 | '--promptText=test' 226 | ]; 227 | const flags = detectCamelCaseFlags(args); 228 | 229 | expect(flags).toHaveLength(1); 230 | expect(flags).toContainEqual({ 231 | original: 'promptText', 232 | kebabCase: 'prompt-text' 233 | }); 234 | }); 235 | }); 236 | 237 | describe('Command Validation Logic', () => { 238 | test('should validate task ID parameter correctly', () => { 239 | // Test valid task IDs 240 | const validId = '5'; 241 | const taskId = parseInt(validId, 10); 242 | expect(Number.isNaN(taskId) || taskId <= 0).toBe(false); 243 | 244 | // Test invalid task IDs 245 | const invalidId = 'not-a-number'; 246 | const invalidTaskId = parseInt(invalidId, 10); 247 | expect(Number.isNaN(invalidTaskId) || invalidTaskId <= 0).toBe(true); 248 | 249 | // Test zero or negative IDs 250 | const zeroId = '0'; 251 | const zeroTaskId = parseInt(zeroId, 10); 252 | expect(Number.isNaN(zeroTaskId) || zeroTaskId <= 0).toBe(true); 253 | }); 254 | 255 | test('should handle environment variable cleanup correctly', () => { 256 | // Instead of using delete operator, test setting to undefined 257 | const testEnv = { PERPLEXITY_API_KEY: 'test-key' }; 258 | testEnv.PERPLEXITY_API_KEY = undefined; 259 | expect(testEnv.PERPLEXITY_API_KEY).toBeUndefined(); 260 | }); 261 | }); 262 | }); 263 | 264 | // Test utility functions that commands rely on 265 | describe('Version comparison utility', () => { 266 | test('compareVersions correctly compares semantic versions', () => { 267 | expect(compareVersions('1.0.0', '1.0.0')).toBe(0); 268 | expect(compareVersions('1.0.0', '1.0.1')).toBe(-1); 269 | expect(compareVersions('1.0.1', '1.0.0')).toBe(1); 270 | expect(compareVersions('1.0.0', '1.1.0')).toBe(-1); 271 | expect(compareVersions('1.1.0', '1.0.0')).toBe(1); 272 | expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); 273 | expect(compareVersions('2.0.0', '1.0.0')).toBe(1); 274 | expect(compareVersions('1.0', '1.0.0')).toBe(0); 275 | expect(compareVersions('1.0.0.0', '1.0.0')).toBe(0); 276 | expect(compareVersions('1.0.0', '1.0.0.1')).toBe(-1); 277 | }); 278 | }); 279 | 280 | describe('Update check functionality', () => { 281 | let displayUpgradeNotification; 282 | let consoleLogSpy; 283 | 284 | beforeAll(async () => { 285 | // Import from @tm/cli instead of commands.js 286 | const cliModule = await import('../../apps/cli/src/utils/auto-update.js'); 287 | displayUpgradeNotification = cliModule.displayUpgradeNotification; 288 | }); 289 | 290 | beforeEach(() => { 291 | consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); 292 | }); 293 | 294 | afterEach(() => { 295 | consoleLogSpy.mockRestore(); 296 | }); 297 | 298 | test('displays upgrade notification when newer version is available', () => { 299 | displayUpgradeNotification('1.0.0', '1.1.0'); 300 | expect(consoleLogSpy).toHaveBeenCalled(); 301 | expect(consoleLogSpy.mock.calls[0][0]).toContain('Update Available!'); 302 | expect(consoleLogSpy.mock.calls[0][0]).toContain('1.0.0'); 303 | expect(consoleLogSpy.mock.calls[0][0]).toContain('1.1.0'); 304 | }); 305 | }); 306 | 307 | // ----------------------------------------------------------------------------- 308 | // Rules command tests (add/remove) 309 | // ----------------------------------------------------------------------------- 310 | describe('rules command', () => { 311 | let program; 312 | let mockConsoleLog; 313 | let mockConsoleError; 314 | let mockExit; 315 | 316 | beforeEach(() => { 317 | jest.clearAllMocks(); 318 | program = setupCLI(); 319 | mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); 320 | mockConsoleError = jest 321 | .spyOn(console, 'error') 322 | .mockImplementation(() => {}); 323 | mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}); 324 | }); 325 | 326 | test('should handle rules add <profile> command', async () => { 327 | // Simulate: task-master rules add roo 328 | await program.parseAsync(['rules', RULES_ACTIONS.ADD, 'roo'], { 329 | from: 'user' 330 | }); 331 | // Expect some log output indicating success 332 | expect(mockConsoleLog).toHaveBeenCalledWith( 333 | expect.stringMatching(/adding rules for profile: roo/i) 334 | ); 335 | expect(mockConsoleLog).toHaveBeenCalledWith( 336 | expect.stringMatching(/completed adding rules for profile: roo/i) 337 | ); 338 | // Should not exit with error 339 | expect(mockExit).not.toHaveBeenCalledWith(1); 340 | }); 341 | 342 | test('should handle rules remove <profile> command', async () => { 343 | // Simulate: task-master rules remove roo --force 344 | await program.parseAsync( 345 | ['rules', RULES_ACTIONS.REMOVE, 'roo', '--force'], 346 | { 347 | from: 'user' 348 | } 349 | ); 350 | // Expect some log output indicating removal 351 | expect(mockConsoleLog).toHaveBeenCalledWith( 352 | expect.stringMatching(/removing rules for profile: roo/i) 353 | ); 354 | expect(mockConsoleLog).toHaveBeenCalledWith( 355 | expect.stringMatching(/Summary for roo: Rule profile removed/i) 356 | ); 357 | // Should not exit with error 358 | expect(mockExit).not.toHaveBeenCalledWith(1); 359 | }); 360 | 361 | test(`should handle rules --${RULES_SETUP_ACTION} command`, async () => { 362 | // For this test, we'll verify that the command doesn't crash and exits gracefully 363 | // Since mocking ES modules is complex, we'll test the command structure instead 364 | 365 | // Create a spy on console.log to capture any output 366 | const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); 367 | 368 | // Mock process.exit to prevent actual exit and capture the call 369 | const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {}); 370 | 371 | try { 372 | // The command should be recognized and not throw an error about invalid action 373 | // We expect it to attempt to run the interactive setup, but since we can't easily 374 | // mock the ES module, we'll just verify the command structure is correct 375 | 376 | // This test verifies that: 377 | // 1. The --setup flag is recognized as a valid option 378 | // 2. The command doesn't exit with error code 1 due to invalid action 379 | // 3. The command structure is properly set up 380 | 381 | // Note: In a real scenario, this would call runInteractiveProfilesSetup() 382 | // but for testing purposes, we're focusing on command structure validation 383 | 384 | expect(() => { 385 | // Test that the command option is properly configured 386 | const command = program.commands.find((cmd) => cmd.name() === 'rules'); 387 | expect(command).toBeDefined(); 388 | 389 | // Check that the --setup option exists 390 | const setupOption = command.options.find( 391 | (opt) => opt.long === `--${RULES_SETUP_ACTION}` 392 | ); 393 | expect(setupOption).toBeDefined(); 394 | expect(setupOption.description).toContain('interactive setup'); 395 | }).not.toThrow(); 396 | 397 | // Verify the command structure is valid 398 | expect(mockExit).not.toHaveBeenCalledWith(1); 399 | } finally { 400 | consoleSpy.mockRestore(); 401 | exitSpy.mockRestore(); 402 | } 403 | }); 404 | 405 | test('should show error for invalid action', async () => { 406 | // Simulate: task-master rules invalid-action 407 | await program.parseAsync(['rules', 'invalid-action'], { from: 'user' }); 408 | 409 | // Should show error for invalid action 410 | expect(mockConsoleError).toHaveBeenCalledWith( 411 | expect.stringMatching(/Error: Invalid or missing action/i) 412 | ); 413 | expect(mockConsoleError).toHaveBeenCalledWith( 414 | expect.stringMatching( 415 | new RegExp( 416 | `For interactive setup, use: task-master rules --${RULES_SETUP_ACTION}`, 417 | 'i' 418 | ) 419 | ) 420 | ); 421 | expect(mockExit).toHaveBeenCalledWith(1); 422 | }); 423 | 424 | test('should show error when no action provided', async () => { 425 | // Simulate: task-master rules (no action) 426 | await program.parseAsync(['rules'], { from: 'user' }); 427 | 428 | // Should show error for missing action 429 | expect(mockConsoleError).toHaveBeenCalledWith( 430 | expect.stringMatching(/Error: Invalid or missing action 'none'/i) 431 | ); 432 | expect(mockConsoleError).toHaveBeenCalledWith( 433 | expect.stringMatching( 434 | new RegExp( 435 | `For interactive setup, use: task-master rules --${RULES_SETUP_ACTION}`, 436 | 'i' 437 | ) 438 | ) 439 | ); 440 | expect(mockExit).toHaveBeenCalledWith(1); 441 | }); 442 | }); 443 | ```