This is page 33 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 -------------------------------------------------------------------------------- /.taskmaster/docs/task-template-importing-prd.txt: -------------------------------------------------------------------------------- ``` 1 | # Task Template Importing System - Product Requirements Document 2 | 3 | <context> 4 | # Overview 5 | The Task Template Importing system enables seamless integration of external task templates into the Task Master CLI through automatic file discovery. This system allows users to drop task template files into the tasks directory and immediately access them as new tag contexts without manual import commands or configuration. The solution addresses the need for multi-project task management, team collaboration through shared templates, and clean separation between permanent tasks and temporary project contexts. 6 | 7 | # Core Features 8 | ## Silent Task Template Discovery 9 | - **What it does**: Automatically scans for `tasks_*.json` files in the tasks directory during tag operations 10 | - **Why it's important**: Eliminates friction in adding new task contexts and enables zero-configuration workflow 11 | - **How it works**: File pattern matching extracts tag names from filenames and validates against internal tag keys 12 | 13 | ## External Tag Resolution System 14 | - **What it does**: Provides fallback mechanism to external files when tags are not found in main tasks.json 15 | - **Why it's important**: Maintains clean separation between core tasks and project-specific templates 16 | - **How it works**: Tag resolution logic checks external files as secondary source while preserving main file precedence 17 | 18 | ## Read-Only External Tag Access 19 | - **What it does**: Allows viewing and switching to external tags while preventing modifications 20 | - **Why it's important**: Protects template integrity and prevents accidental changes to shared templates 21 | - **How it works**: All task modifications route to main tasks.json regardless of current tag context 22 | 23 | ## Tag Precedence Management 24 | - **What it does**: Ensures main tasks.json tags override external files with same tag names 25 | - **Why it's important**: Prevents conflicts and maintains data integrity 26 | - **How it works**: Priority system where main file tags take precedence over external file tags 27 | 28 | # User Experience 29 | ## User Personas 30 | - **Solo Developer**: Manages multiple projects with different task contexts 31 | - **Team Lead**: Shares standardized task templates across team members 32 | - **Project Manager**: Organizes tasks by project phases or feature branches 33 | 34 | ## Key User Flows 35 | ### Template Addition Flow 36 | 1. User receives or creates a `tasks_projectname.json` file 37 | 2. User drops file into `.taskmaster/tasks/` directory 38 | 3. Tag becomes immediately available via `task-master use-tag projectname` 39 | 4. User can list, view, and switch to external tag without configuration 40 | 41 | ### Template Usage Flow 42 | 1. User runs `task-master tags` to see available tags including external ones 43 | 2. External tags display with `(imported)` indicator 44 | 3. User switches to external tag with `task-master use-tag projectname` 45 | 4. User can view tasks but modifications are routed to main tasks.json 46 | 47 | ## UI/UX Considerations 48 | - External tags clearly marked with `(imported)` suffix in listings 49 | - Visual indicators distinguish between main and external tags 50 | - Error messages guide users when external files are malformed 51 | - Read-only warnings when attempting to modify external tag contexts 52 | </context> 53 | 54 | <PRD> 55 | # Technical Architecture 56 | ## System Components 57 | 1. **External File Discovery Engine** 58 | - File pattern scanner for `tasks_*.json` files 59 | - Tag name extraction from filenames using regex 60 | - Dynamic tag registry combining main and external sources 61 | - Error handling for malformed external files 62 | 63 | 2. **Enhanced Tag Resolution System** 64 | - Fallback mechanism to external files when tags not found in main tasks.json 65 | - Precedence management ensuring main file tags override external files 66 | - Read-only access enforcement for external tags 67 | - Tag metadata preservation during discovery operations 68 | 69 | 3. **Silent Discovery Integration** 70 | - Automatic scanning during tag-related operations 71 | - Seamless integration with existing tag management functions 72 | - Zero-configuration workflow requiring no manual import commands 73 | - Dynamic tag availability without restart requirements 74 | 75 | ## Data Models 76 | 77 | ### External Task File Structure 78 | ```json 79 | { 80 | "meta": { 81 | "projectName": "External Project Name", 82 | "version": "1.0.0", 83 | "templateSource": "external", 84 | "createdAt": "ISO-8601 timestamp" 85 | }, 86 | "tags": { 87 | "projectname": { 88 | "meta": { 89 | "name": "Project Name", 90 | "description": "Project description", 91 | "createdAt": "ISO-8601 timestamp" 92 | }, 93 | "tasks": [ 94 | // Array of task objects 95 | ] 96 | }, 97 | "master": { 98 | // This section is ignored to prevent conflicts 99 | } 100 | } 101 | } 102 | ``` 103 | 104 | ### Enhanced Tag Registry Model 105 | ```json 106 | { 107 | "mainTags": [ 108 | { 109 | "name": "master", 110 | "source": "main", 111 | "taskCount": 150, 112 | "isActive": true 113 | } 114 | ], 115 | "externalTags": [ 116 | { 117 | "name": "projectname", 118 | "source": "external", 119 | "filename": "tasks_projectname.json", 120 | "taskCount": 25, 121 | "isReadOnly": true 122 | } 123 | ] 124 | } 125 | ``` 126 | 127 | ## APIs and Integrations 128 | 1. **File System Discovery API** 129 | - Directory scanning with pattern matching 130 | - JSON file validation and parsing 131 | - Error handling for corrupted or malformed files 132 | - File modification time tracking for cache invalidation 133 | 134 | 2. **Enhanced Tag Management API** 135 | - `scanForExternalTaskFiles(projectRoot)` - Discover external template files 136 | - `getExternalTagsFromFiles(projectRoot)` - Extract tag names from external files 137 | - `readExternalTagData(projectRoot, tagName)` - Read specific external tag data 138 | - `getAvailableTags(projectRoot)` - Combined main and external tag listing 139 | 140 | 3. **Tag Resolution Enhancement** 141 | - Modified `readJSON()` with external file fallback 142 | - Enhanced `tags()` function with external tag display 143 | - Updated `useTag()` function supporting external tag switching 144 | - Read-only enforcement for external tag operations 145 | 146 | ## Infrastructure Requirements 147 | 1. **File System Access** 148 | - Read permissions for tasks directory 149 | - JSON parsing capabilities 150 | - Pattern matching and regex support 151 | - Error handling for file system operations 152 | 153 | 2. **Backward Compatibility** 154 | - Existing tag operations continue unchanged 155 | - Main tasks.json structure preserved 156 | - No breaking changes to current workflows 157 | - Graceful degradation when external files unavailable 158 | 159 | # Development Roadmap 160 | ## Phase 1: Core External File Discovery (Foundation) 161 | 1. **External File Scanner Implementation** 162 | - Create `scanForExternalTaskFiles()` function in utils.js 163 | - Implement file pattern matching for `tasks_*.json` files 164 | - Add error handling for file system access issues 165 | - Test with various filename patterns and edge cases 166 | 167 | 2. **Tag Name Extraction System** 168 | - Implement `getExternalTagsFromFiles()` function 169 | - Create regex pattern for extracting tag names from filenames 170 | - Add validation to ensure tag names match internal tag key format 171 | - Handle special characters and invalid filename patterns 172 | 173 | 3. **External Tag Data Reader** 174 | - Create `readExternalTagData()` function 175 | - Implement JSON parsing with error handling 176 | - Add validation for required tag structure 177 | - Ignore 'master' key in external files to prevent conflicts 178 | 179 | ## Phase 2: Tag Resolution Enhancement (Core Integration) 180 | 1. **Enhanced Tag Registry** 181 | - Implement `getAvailableTags()` function combining main and external sources 182 | - Create tag metadata structure including source information 183 | - Add deduplication logic prioritizing main tags over external 184 | - Implement caching mechanism for performance optimization 185 | 186 | 2. **Modified readJSON Function** 187 | - Add external file fallback when tag not found in main tasks.json 188 | - Maintain precedence rule: main tasks.json overrides external files 189 | - Preserve existing error handling and validation patterns 190 | - Ensure read-only access for external tags 191 | 192 | 3. **Tag Listing Enhancement** 193 | - Update `tags()` function to display external tags with `(imported)` indicator 194 | - Show external tag metadata and task counts 195 | - Maintain current tag highlighting and sorting functionality 196 | - Add visual distinction between main and external tags 197 | 198 | ## Phase 3: User Interface Integration (User Experience) 199 | 1. **Tag Switching Enhancement** 200 | - Update `useTag()` function to support external tag switching 201 | - Add read-only warnings when switching to external tags 202 | - Update state.json with external tag context information 203 | - Maintain current tag switching behavior for main tags 204 | 205 | 2. **Error Handling and User Feedback** 206 | - Implement comprehensive error messages for malformed external files 207 | - Add user guidance for proper external file structure 208 | - Create warnings for read-only operations on external tags 209 | - Ensure graceful degradation when external files are corrupted 210 | 211 | 3. **Documentation and Help Integration** 212 | - Update command help text to include external tag information 213 | - Add examples of external file structure and usage 214 | - Create troubleshooting guide for common external file issues 215 | - Document file naming conventions and best practices 216 | 217 | ## Phase 4: Advanced Features and Optimization (Enhancement) 218 | 1. **Performance Optimization** 219 | - Implement file modification time caching 220 | - Add lazy loading for external tag data 221 | - Optimize file scanning for directories with many files 222 | - Create efficient tag resolution caching mechanism 223 | 224 | 2. **Advanced External File Features** 225 | - Support for nested external file directories 226 | - Batch external file validation and reporting 227 | - External file metadata display and management 228 | - Integration with version control ignore patterns 229 | 230 | 3. **Team Collaboration Features** 231 | - Shared external file validation 232 | - External file conflict detection and resolution 233 | - Team template sharing guidelines and documentation 234 | - Integration with git workflows for template management 235 | 236 | # Logical Dependency Chain 237 | ## Foundation Layer (Must Be Built First) 238 | 1. **External File Scanner** 239 | - Core requirement for all other functionality 240 | - Provides the discovery mechanism for external template files 241 | - Must handle file system access and pattern matching reliably 242 | 243 | 2. **Tag Name Extraction** 244 | - Depends on file scanner functionality 245 | - Required for identifying available external tags 246 | - Must validate tag names against internal format requirements 247 | 248 | 3. **External Tag Data Reader** 249 | - Depends on tag name extraction 250 | - Provides access to external tag content 251 | - Must handle JSON parsing and validation safely 252 | 253 | ## Integration Layer (Builds on Foundation) 254 | 4. **Enhanced Tag Registry** 255 | - Depends on all foundation components 256 | - Combines main and external tag sources 257 | - Required for unified tag management across the system 258 | 259 | 5. **Modified readJSON Function** 260 | - Depends on enhanced tag registry 261 | - Provides fallback mechanism for tag resolution 262 | - Critical for maintaining backward compatibility 263 | 264 | 6. **Tag Listing Enhancement** 265 | - Depends on enhanced tag registry 266 | - Provides user visibility into external tags 267 | - Required for user discovery of available templates 268 | 269 | ## User Experience Layer (Completes the Feature) 270 | 7. **Tag Switching Enhancement** 271 | - Depends on modified readJSON and tag listing 272 | - Enables user interaction with external tags 273 | - Must enforce read-only access properly 274 | 275 | 8. **Error Handling and User Feedback** 276 | - Can be developed in parallel with other UX components 277 | - Enhances reliability and user experience 278 | - Should be integrated throughout development process 279 | 280 | 9. **Documentation and Help Integration** 281 | - Should be developed alongside implementation 282 | - Required for user adoption and proper usage 283 | - Can be completed in parallel with advanced features 284 | 285 | ## Optimization Layer (Performance and Advanced Features) 286 | 10. **Performance Optimization** 287 | - Can be developed after core functionality is stable 288 | - Improves user experience with large numbers of external files 289 | - Not blocking for initial release 290 | 291 | 11. **Advanced External File Features** 292 | - Can be developed independently after core features 293 | - Enhances power user workflows 294 | - Optional for initial release 295 | 296 | 12. **Team Collaboration Features** 297 | - Depends on stable core functionality 298 | - Enhances team workflows and template sharing 299 | - Can be prioritized based on user feedback 300 | 301 | # Risks and Mitigations 302 | ## Technical Challenges 303 | 304 | ### File System Performance 305 | **Risk**: Scanning for external files on every tag operation could impact performance with large directories. 306 | **Mitigation**: 307 | - Implement file modification time caching to avoid unnecessary rescans 308 | - Use lazy loading for external tag data - only read when accessed 309 | - Add configurable limits on number of external files to scan 310 | - Optimize file pattern matching with efficient regex patterns 311 | 312 | ### External File Corruption 313 | **Risk**: Malformed or corrupted external JSON files could break tag operations. 314 | **Mitigation**: 315 | - Implement robust JSON parsing with comprehensive error handling 316 | - Add file validation before attempting to parse external files 317 | - Gracefully skip corrupted files and continue with valid ones 318 | - Provide clear error messages guiding users to fix malformed files 319 | 320 | ### Tag Name Conflicts 321 | **Risk**: External files might contain tag names that conflict with main tasks.json tags. 322 | **Mitigation**: 323 | - Implement strict precedence rule: main tasks.json always overrides external files 324 | - Add warnings when external tags are ignored due to conflicts 325 | - Document naming conventions to avoid common conflicts 326 | - Provide validation tools to check for potential conflicts 327 | 328 | ## MVP Definition 329 | 330 | ### Core Feature Scope 331 | **Risk**: Including too many advanced features could delay the core functionality. 332 | **Mitigation**: 333 | - Define MVP as basic external file discovery + tag switching 334 | - Focus on the silent discovery mechanism as the primary value proposition 335 | - Defer advanced features like nested directories and batch operations 336 | - Ensure each phase delivers complete, usable functionality 337 | 338 | ### User Experience Complexity 339 | **Risk**: The read-only nature of external tags might confuse users. 340 | **Mitigation**: 341 | - Provide clear visual indicators for external tags in all interfaces 342 | - Add explicit warnings when users attempt to modify external tag contexts 343 | - Document the read-only behavior and its rationale clearly 344 | - Consider future enhancement for external tag modification workflows 345 | 346 | ### Backward Compatibility 347 | **Risk**: Changes to tag resolution logic might break existing workflows. 348 | **Mitigation**: 349 | - Maintain existing tag operations unchanged for main tasks.json 350 | - Add external file support as enhancement, not replacement 351 | - Test thoroughly with existing task structures and workflows 352 | - Provide migration path if any breaking changes are necessary 353 | 354 | ## Resource Constraints 355 | 356 | ### Development Complexity 357 | **Risk**: Integration with existing tag management system could be complex. 358 | **Mitigation**: 359 | - Phase implementation to minimize risk of breaking existing functionality 360 | - Create comprehensive test suite covering both main and external tag scenarios 361 | - Use feature flags to enable/disable external file support during development 362 | - Implement thorough error handling to prevent system failures 363 | 364 | ### File System Dependencies 365 | **Risk**: Different operating systems might handle file operations differently. 366 | **Mitigation**: 367 | - Use Node.js built-in file system APIs for cross-platform compatibility 368 | - Test on multiple operating systems (Windows, macOS, Linux) 369 | - Handle file path separators and naming conventions properly 370 | - Add fallback mechanisms for file system access issues 371 | 372 | ### User Adoption 373 | **Risk**: Users might not understand or adopt the external file template system. 374 | **Mitigation**: 375 | - Create clear documentation with practical examples 376 | - Provide sample external template files for common use cases 377 | - Integrate help and guidance directly into the CLI interface 378 | - Gather user feedback early and iterate on the user experience 379 | 380 | # Appendix 381 | ## External File Naming Convention 382 | 383 | ### Filename Pattern 384 | - **Format**: `tasks_[tagname].json` 385 | - **Examples**: `tasks_feature-auth.json`, `tasks_v2-migration.json`, `tasks_project-alpha.json` 386 | - **Validation**: Tag name must match internal tag key format (alphanumeric, hyphens, underscores) 387 | 388 | ### File Structure Requirements 389 | ```json 390 | { 391 | "meta": { 392 | "projectName": "Required: Human-readable project name", 393 | "version": "Optional: Template version", 394 | "templateSource": "Optional: Source identifier", 395 | "createdAt": "Optional: ISO-8601 timestamp" 396 | }, 397 | "tags": { 398 | "[tagname]": { 399 | "meta": { 400 | "name": "Required: Tag display name", 401 | "description": "Optional: Tag description", 402 | "createdAt": "Optional: ISO-8601 timestamp" 403 | }, 404 | "tasks": [ 405 | // Required: Array of task objects following standard task structure 406 | ] 407 | } 408 | } 409 | } 410 | ``` 411 | 412 | ## Implementation Functions Specification 413 | 414 | ### Core Discovery Functions 415 | ```javascript 416 | // Scan tasks directory for external template files 417 | function scanForExternalTaskFiles(projectRoot) { 418 | // Returns: Array of external file paths 419 | } 420 | 421 | // Extract tag names from external filenames 422 | function getExternalTagsFromFiles(projectRoot) { 423 | // Returns: Array of external tag names 424 | } 425 | 426 | // Read specific external tag data 427 | function readExternalTagData(projectRoot, tagName) { 428 | // Returns: Tag data object or null if not found 429 | } 430 | 431 | // Get combined main and external tags 432 | function getAvailableTags(projectRoot) { 433 | // Returns: Combined tag registry with metadata 434 | } 435 | ``` 436 | 437 | ### Integration Points 438 | ```javascript 439 | // Enhanced readJSON with external fallback 440 | function readJSON(projectRoot, tag = null) { 441 | // Modified to check external files when tag not found in main 442 | } 443 | 444 | // Enhanced tags listing with external indicators 445 | function tags(projectRoot, options = {}) { 446 | // Modified to display external tags with (imported) suffix 447 | } 448 | 449 | // Enhanced tag switching with external support 450 | function useTag(projectRoot, tagName) { 451 | // Modified to support switching to external tags (read-only) 452 | } 453 | ``` 454 | 455 | ## Error Handling Specifications 456 | 457 | ### File System Errors 458 | - **ENOENT**: External file not found - gracefully skip and continue 459 | - **EACCES**: Permission denied - warn user and continue with available files 460 | - **EISDIR**: Directory instead of file - skip and continue scanning 461 | 462 | ### JSON Parsing Errors 463 | - **SyntaxError**: Malformed JSON - skip file and log warning with filename 464 | - **Missing required fields**: Skip file and provide specific error message 465 | - **Invalid tag structure**: Skip file and guide user to correct format 466 | 467 | ### Tag Conflict Resolution 468 | - **Duplicate tag names**: Main tasks.json takes precedence, log warning 469 | - **Invalid tag names**: Skip external file and provide naming guidance 470 | - **Master key in external**: Ignore master key, process other tags normally 471 | </PRD> ``` -------------------------------------------------------------------------------- /packages/tm-core/src/storage/api-storage.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * @fileoverview API-based storage implementation using repository pattern 3 | * This provides storage via repository abstraction for flexibility 4 | */ 5 | 6 | import type { 7 | IStorage, 8 | StorageStats, 9 | UpdateStatusResult 10 | } from '../interfaces/storage.interface.js'; 11 | import type { 12 | Task, 13 | TaskMetadata, 14 | TaskTag, 15 | TaskStatus 16 | } from '../types/index.js'; 17 | import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js'; 18 | import { TaskRepository } from '../repositories/task-repository.interface.js'; 19 | import { SupabaseTaskRepository } from '../repositories/supabase-task-repository.js'; 20 | import { SupabaseClient } from '@supabase/supabase-js'; 21 | import { AuthManager } from '../auth/auth-manager.js'; 22 | 23 | /** 24 | * API storage configuration 25 | */ 26 | export interface ApiStorageConfig { 27 | /** Supabase client instance */ 28 | supabaseClient?: SupabaseClient; 29 | /** Custom repository implementation */ 30 | repository?: TaskRepository; 31 | /** Project ID for scoping */ 32 | projectId: string; 33 | /** Enable request retries */ 34 | enableRetry?: boolean; 35 | /** Maximum retry attempts */ 36 | maxRetries?: number; 37 | } 38 | 39 | /** 40 | * ApiStorage implementation using repository pattern 41 | * Provides flexibility to swap between different backend implementations 42 | */ 43 | export class ApiStorage implements IStorage { 44 | private readonly repository: TaskRepository; 45 | private readonly projectId: string; 46 | private readonly enableRetry: boolean; 47 | private readonly maxRetries: number; 48 | private initialized = false; 49 | private tagsCache: Map<string, TaskTag> = new Map(); 50 | 51 | constructor(config: ApiStorageConfig) { 52 | this.validateConfig(config); 53 | 54 | // Use provided repository or create Supabase repository 55 | if (config.repository) { 56 | this.repository = config.repository; 57 | } else if (config.supabaseClient) { 58 | // TODO: SupabaseTaskRepository doesn't implement all TaskRepository methods yet 59 | // Cast for now until full implementation is complete 60 | this.repository = new SupabaseTaskRepository( 61 | config.supabaseClient 62 | ) as unknown as TaskRepository; 63 | } else { 64 | throw new TaskMasterError( 65 | 'Either repository or supabaseClient must be provided', 66 | ERROR_CODES.MISSING_CONFIGURATION 67 | ); 68 | } 69 | 70 | this.projectId = config.projectId; 71 | this.enableRetry = config.enableRetry ?? true; 72 | this.maxRetries = config.maxRetries ?? 3; 73 | } 74 | 75 | /** 76 | * Validate API storage configuration 77 | */ 78 | private validateConfig(config: ApiStorageConfig): void { 79 | if (!config.projectId) { 80 | throw new TaskMasterError( 81 | 'Project ID is required for API storage', 82 | ERROR_CODES.MISSING_CONFIGURATION 83 | ); 84 | } 85 | 86 | if (!config.repository && !config.supabaseClient) { 87 | throw new TaskMasterError( 88 | 'Either repository or supabaseClient must be provided', 89 | ERROR_CODES.MISSING_CONFIGURATION 90 | ); 91 | } 92 | } 93 | 94 | /** 95 | * Initialize the API storage 96 | */ 97 | async initialize(): Promise<void> { 98 | if (this.initialized) return; 99 | 100 | try { 101 | // Load initial tags 102 | await this.loadTagsIntoCache(); 103 | this.initialized = true; 104 | } catch (error) { 105 | throw new TaskMasterError( 106 | 'Failed to initialize API storage', 107 | ERROR_CODES.STORAGE_ERROR, 108 | { operation: 'initialize' }, 109 | error as Error 110 | ); 111 | } 112 | } 113 | 114 | /** 115 | * Load tags into cache 116 | * In our API-based system, "tags" represent briefs 117 | */ 118 | private async loadTagsIntoCache(): Promise<void> { 119 | try { 120 | const authManager = AuthManager.getInstance(); 121 | const context = authManager.getContext(); 122 | 123 | // If we have a selected brief, create a virtual "tag" for it 124 | if (context?.briefId) { 125 | // Create a virtual tag representing the current brief 126 | const briefTag: TaskTag = { 127 | name: context.briefId, 128 | tasks: [], // Will be populated when tasks are loaded 129 | metadata: { 130 | briefId: context.briefId, 131 | briefName: context.briefName, 132 | organizationId: context.orgId 133 | } 134 | }; 135 | 136 | this.tagsCache.clear(); 137 | this.tagsCache.set(context.briefId, briefTag); 138 | } 139 | } catch (error) { 140 | // If no brief is selected, that's okay - user needs to select one first 141 | console.debug('No brief selected, starting with empty cache'); 142 | } 143 | } 144 | 145 | /** 146 | * Load tasks from API 147 | * In our system, the tag parameter represents a brief ID 148 | */ 149 | async loadTasks(tag?: string): Promise<Task[]> { 150 | await this.ensureInitialized(); 151 | 152 | try { 153 | const authManager = AuthManager.getInstance(); 154 | const context = authManager.getContext(); 155 | 156 | // If no brief is selected in context, throw an error 157 | if (!context?.briefId) { 158 | throw new Error( 159 | 'No brief selected. Please select a brief first using: tm context brief <brief-id>' 160 | ); 161 | } 162 | 163 | // Load tasks from the current brief context 164 | const tasks = await this.retryOperation(() => 165 | this.repository.getTasks(this.projectId) 166 | ); 167 | 168 | // Update the tag cache with the loaded task IDs 169 | const briefTag = this.tagsCache.get(context.briefId); 170 | if (briefTag) { 171 | briefTag.tasks = tasks.map((task) => task.id); 172 | } 173 | 174 | return tasks; 175 | } catch (error) { 176 | throw new TaskMasterError( 177 | 'Failed to load tasks from API', 178 | ERROR_CODES.STORAGE_ERROR, 179 | { operation: 'loadTasks', tag, context: 'brief-based loading' }, 180 | error as Error 181 | ); 182 | } 183 | } 184 | 185 | /** 186 | * Save tasks to API 187 | */ 188 | async saveTasks(tasks: Task[], tag?: string): Promise<void> { 189 | await this.ensureInitialized(); 190 | 191 | try { 192 | if (tag) { 193 | // Update tag with task IDs 194 | const tagData = this.tagsCache.get(tag) || { 195 | name: tag, 196 | tasks: [], 197 | metadata: {} 198 | }; 199 | tagData.tasks = tasks.map((t) => t.id); 200 | 201 | // Save or update tag 202 | if (this.tagsCache.has(tag)) { 203 | await this.repository.updateTag(this.projectId, tag, tagData); 204 | } else { 205 | await this.repository.createTag(this.projectId, tagData); 206 | } 207 | 208 | this.tagsCache.set(tag, tagData); 209 | } 210 | 211 | // Save tasks using bulk operation 212 | await this.retryOperation(() => 213 | this.repository.bulkCreateTasks(this.projectId, tasks) 214 | ); 215 | } catch (error) { 216 | throw new TaskMasterError( 217 | 'Failed to save tasks to API', 218 | ERROR_CODES.STORAGE_ERROR, 219 | { operation: 'saveTasks', tag, taskCount: tasks.length }, 220 | error as Error 221 | ); 222 | } 223 | } 224 | 225 | /** 226 | * Load a single task by ID 227 | */ 228 | async loadTask(taskId: string, tag?: string): Promise<Task | null> { 229 | await this.ensureInitialized(); 230 | 231 | try { 232 | return await this.retryOperation(() => 233 | this.repository.getTask(this.projectId, taskId) 234 | ); 235 | } catch (error) { 236 | throw new TaskMasterError( 237 | 'Failed to load task from API', 238 | ERROR_CODES.STORAGE_ERROR, 239 | { operation: 'loadTask', taskId, tag }, 240 | error as Error 241 | ); 242 | } 243 | } 244 | 245 | /** 246 | * Save a single task 247 | */ 248 | async saveTask(task: Task, tag?: string): Promise<void> { 249 | await this.ensureInitialized(); 250 | 251 | try { 252 | // Check if task exists 253 | const existing = await this.repository.getTask(this.projectId, task.id); 254 | 255 | if (existing) { 256 | await this.retryOperation(() => 257 | this.repository.updateTask(this.projectId, task.id, task) 258 | ); 259 | } else { 260 | await this.retryOperation(() => 261 | this.repository.createTask(this.projectId, task) 262 | ); 263 | } 264 | 265 | // Update tag if specified 266 | if (tag) { 267 | const tagData = this.tagsCache.get(tag); 268 | if (tagData && !tagData.tasks.includes(task.id)) { 269 | tagData.tasks.push(task.id); 270 | await this.repository.updateTag(this.projectId, tag, tagData); 271 | } 272 | } 273 | } catch (error) { 274 | throw new TaskMasterError( 275 | 'Failed to save task to API', 276 | ERROR_CODES.STORAGE_ERROR, 277 | { operation: 'saveTask', taskId: task.id, tag }, 278 | error as Error 279 | ); 280 | } 281 | } 282 | 283 | /** 284 | * Delete a task 285 | */ 286 | async deleteTask(taskId: string, tag?: string): Promise<void> { 287 | await this.ensureInitialized(); 288 | 289 | try { 290 | await this.retryOperation(() => 291 | this.repository.deleteTask(this.projectId, taskId) 292 | ); 293 | 294 | // Remove from tag if specified 295 | if (tag) { 296 | const tagData = this.tagsCache.get(tag); 297 | if (tagData) { 298 | tagData.tasks = tagData.tasks.filter((id) => id !== taskId); 299 | await this.repository.updateTag(this.projectId, tag, tagData); 300 | } 301 | } 302 | } catch (error) { 303 | throw new TaskMasterError( 304 | 'Failed to delete task from API', 305 | ERROR_CODES.STORAGE_ERROR, 306 | { operation: 'deleteTask', taskId, tag }, 307 | error as Error 308 | ); 309 | } 310 | } 311 | 312 | /** 313 | * List available tags (briefs in our system) 314 | */ 315 | async listTags(): Promise<string[]> { 316 | await this.ensureInitialized(); 317 | 318 | try { 319 | const authManager = AuthManager.getInstance(); 320 | const context = authManager.getContext(); 321 | 322 | // In our API-based system, we only have one "tag" at a time - the current brief 323 | if (context?.briefId) { 324 | // Ensure the current brief is in our cache 325 | await this.loadTagsIntoCache(); 326 | return [context.briefId]; 327 | } 328 | 329 | // No brief selected, return empty array 330 | return []; 331 | } catch (error) { 332 | throw new TaskMasterError( 333 | 'Failed to list tags from API', 334 | ERROR_CODES.STORAGE_ERROR, 335 | { operation: 'listTags' }, 336 | error as Error 337 | ); 338 | } 339 | } 340 | 341 | /** 342 | * Load metadata 343 | */ 344 | async loadMetadata(tag?: string): Promise<TaskMetadata | null> { 345 | await this.ensureInitialized(); 346 | 347 | try { 348 | if (tag) { 349 | const tagData = this.tagsCache.get(tag); 350 | return (tagData?.metadata as TaskMetadata) || null; 351 | } 352 | 353 | // Return global metadata if no tag specified 354 | // This could be stored in a special system tag 355 | const systemTag = await this.repository.getTag(this.projectId, '_system'); 356 | return (systemTag?.metadata as TaskMetadata) || null; 357 | } catch (error) { 358 | throw new TaskMasterError( 359 | 'Failed to load metadata from API', 360 | ERROR_CODES.STORAGE_ERROR, 361 | { operation: 'loadMetadata', tag }, 362 | error as Error 363 | ); 364 | } 365 | } 366 | 367 | /** 368 | * Save metadata 369 | */ 370 | async saveMetadata(metadata: TaskMetadata, tag?: string): Promise<void> { 371 | await this.ensureInitialized(); 372 | 373 | try { 374 | if (tag) { 375 | const tagData = this.tagsCache.get(tag) || { 376 | name: tag, 377 | tasks: [], 378 | metadata: {} 379 | }; 380 | tagData.metadata = metadata as any; 381 | 382 | if (this.tagsCache.has(tag)) { 383 | await this.repository.updateTag(this.projectId, tag, tagData); 384 | } else { 385 | await this.repository.createTag(this.projectId, tagData); 386 | } 387 | 388 | this.tagsCache.set(tag, tagData); 389 | } else { 390 | // Save to system tag 391 | const systemTag: TaskTag = { 392 | name: '_system', 393 | tasks: [], 394 | metadata: metadata as any 395 | }; 396 | 397 | const existing = await this.repository.getTag( 398 | this.projectId, 399 | '_system' 400 | ); 401 | if (existing) { 402 | await this.repository.updateTag(this.projectId, '_system', systemTag); 403 | } else { 404 | await this.repository.createTag(this.projectId, systemTag); 405 | } 406 | } 407 | } catch (error) { 408 | throw new TaskMasterError( 409 | 'Failed to save metadata to API', 410 | ERROR_CODES.STORAGE_ERROR, 411 | { operation: 'saveMetadata', tag }, 412 | error as Error 413 | ); 414 | } 415 | } 416 | 417 | /** 418 | * Check if storage exists 419 | */ 420 | async exists(): Promise<boolean> { 421 | try { 422 | await this.initialize(); 423 | return true; 424 | } catch { 425 | return false; 426 | } 427 | } 428 | 429 | /** 430 | * Append tasks to existing storage 431 | */ 432 | async appendTasks(tasks: Task[], tag?: string): Promise<void> { 433 | await this.ensureInitialized(); 434 | 435 | try { 436 | // Use bulk create - repository should handle duplicates 437 | await this.retryOperation(() => 438 | this.repository.bulkCreateTasks(this.projectId, tasks) 439 | ); 440 | 441 | // Update tag if specified 442 | if (tag) { 443 | const tagData = this.tagsCache.get(tag) || { 444 | name: tag, 445 | tasks: [], 446 | metadata: {} 447 | }; 448 | 449 | const newTaskIds = tasks.map((t) => t.id); 450 | tagData.tasks = [...new Set([...tagData.tasks, ...newTaskIds])]; 451 | 452 | if (this.tagsCache.has(tag)) { 453 | await this.repository.updateTag(this.projectId, tag, tagData); 454 | } else { 455 | await this.repository.createTag(this.projectId, tagData); 456 | } 457 | 458 | this.tagsCache.set(tag, tagData); 459 | } 460 | } catch (error) { 461 | throw new TaskMasterError( 462 | 'Failed to append tasks to API', 463 | ERROR_CODES.STORAGE_ERROR, 464 | { operation: 'appendTasks', tag, taskCount: tasks.length }, 465 | error as Error 466 | ); 467 | } 468 | } 469 | 470 | /** 471 | * Update a specific task 472 | */ 473 | async updateTask( 474 | taskId: string, 475 | updates: Partial<Task>, 476 | tag?: string 477 | ): Promise<void> { 478 | await this.ensureInitialized(); 479 | 480 | try { 481 | await this.retryOperation(() => 482 | this.repository.updateTask(this.projectId, taskId, updates) 483 | ); 484 | } catch (error) { 485 | throw new TaskMasterError( 486 | 'Failed to update task via API', 487 | ERROR_CODES.STORAGE_ERROR, 488 | { operation: 'updateTask', taskId, tag }, 489 | error as Error 490 | ); 491 | } 492 | } 493 | 494 | /** 495 | * Update task or subtask status by ID - for API storage 496 | */ 497 | async updateTaskStatus( 498 | taskId: string, 499 | newStatus: TaskStatus, 500 | tag?: string 501 | ): Promise<UpdateStatusResult> { 502 | await this.ensureInitialized(); 503 | 504 | try { 505 | const existingTask = await this.retryOperation(() => 506 | this.repository.getTask(this.projectId, taskId) 507 | ); 508 | 509 | if (!existingTask) { 510 | throw new Error(`Task ${taskId} not found`); 511 | } 512 | 513 | const oldStatus = existingTask.status; 514 | if (oldStatus === newStatus) { 515 | return { 516 | success: true, 517 | oldStatus, 518 | newStatus, 519 | taskId 520 | }; 521 | } 522 | 523 | // Update the task/subtask status 524 | await this.retryOperation(() => 525 | this.repository.updateTask(this.projectId, taskId, { 526 | status: newStatus, 527 | updatedAt: new Date().toISOString() 528 | }) 529 | ); 530 | 531 | // Note: Parent status auto-adjustment is handled by the backend API service 532 | // which has its own business logic for managing task relationships 533 | 534 | return { 535 | success: true, 536 | oldStatus, 537 | newStatus, 538 | taskId 539 | }; 540 | } catch (error) { 541 | throw new TaskMasterError( 542 | 'Failed to update task status via API', 543 | ERROR_CODES.STORAGE_ERROR, 544 | { operation: 'updateTaskStatus', taskId, newStatus, tag }, 545 | error as Error 546 | ); 547 | } 548 | } 549 | 550 | /** 551 | * Get all available tags 552 | */ 553 | async getAllTags(): Promise<string[]> { 554 | return this.listTags(); 555 | } 556 | 557 | /** 558 | * Delete all tasks for a tag 559 | */ 560 | async deleteTag(tag: string): Promise<void> { 561 | await this.ensureInitialized(); 562 | 563 | try { 564 | await this.retryOperation(() => 565 | this.repository.deleteTag(this.projectId, tag) 566 | ); 567 | 568 | this.tagsCache.delete(tag); 569 | } catch (error) { 570 | throw new TaskMasterError( 571 | 'Failed to delete tag via API', 572 | ERROR_CODES.STORAGE_ERROR, 573 | { operation: 'deleteTag', tag }, 574 | error as Error 575 | ); 576 | } 577 | } 578 | 579 | /** 580 | * Rename a tag 581 | */ 582 | async renameTag(oldTag: string, newTag: string): Promise<void> { 583 | await this.ensureInitialized(); 584 | 585 | try { 586 | const tagData = this.tagsCache.get(oldTag); 587 | if (!tagData) { 588 | throw new Error(`Tag ${oldTag} not found`); 589 | } 590 | 591 | // Create new tag with same data 592 | const newTagData = { ...tagData, name: newTag }; 593 | await this.repository.createTag(this.projectId, newTagData); 594 | 595 | // Delete old tag 596 | await this.repository.deleteTag(this.projectId, oldTag); 597 | 598 | // Update cache 599 | this.tagsCache.delete(oldTag); 600 | this.tagsCache.set(newTag, newTagData); 601 | } catch (error) { 602 | throw new TaskMasterError( 603 | 'Failed to rename tag via API', 604 | ERROR_CODES.STORAGE_ERROR, 605 | { operation: 'renameTag', oldTag, newTag }, 606 | error as Error 607 | ); 608 | } 609 | } 610 | 611 | /** 612 | * Copy a tag 613 | */ 614 | async copyTag(sourceTag: string, targetTag: string): Promise<void> { 615 | await this.ensureInitialized(); 616 | 617 | try { 618 | const sourceData = this.tagsCache.get(sourceTag); 619 | if (!sourceData) { 620 | throw new Error(`Source tag ${sourceTag} not found`); 621 | } 622 | 623 | // Create new tag with copied data 624 | const targetData = { ...sourceData, name: targetTag }; 625 | await this.repository.createTag(this.projectId, targetData); 626 | 627 | // Update cache 628 | this.tagsCache.set(targetTag, targetData); 629 | } catch (error) { 630 | throw new TaskMasterError( 631 | 'Failed to copy tag via API', 632 | ERROR_CODES.STORAGE_ERROR, 633 | { operation: 'copyTag', sourceTag, targetTag }, 634 | error as Error 635 | ); 636 | } 637 | } 638 | 639 | /** 640 | * Get storage statistics 641 | */ 642 | async getStats(): Promise<StorageStats> { 643 | await this.ensureInitialized(); 644 | 645 | try { 646 | const tasks = await this.repository.getTasks(this.projectId); 647 | const tags = await this.repository.getTags(this.projectId); 648 | 649 | const tagStats = tags.map((tag) => ({ 650 | tag: tag.name, 651 | taskCount: tag.tasks.length, 652 | lastModified: new Date().toISOString() // TODO: Get actual last modified from tag data 653 | })); 654 | 655 | return { 656 | totalTasks: tasks.length, 657 | totalTags: tags.length, 658 | storageSize: 0, // Not applicable for API storage 659 | lastModified: new Date().toISOString(), 660 | tagStats 661 | }; 662 | } catch (error) { 663 | throw new TaskMasterError( 664 | 'Failed to get stats from API', 665 | ERROR_CODES.STORAGE_ERROR, 666 | { operation: 'getStats' }, 667 | error as Error 668 | ); 669 | } 670 | } 671 | 672 | /** 673 | * Create backup 674 | */ 675 | async backup(): Promise<string> { 676 | await this.ensureInitialized(); 677 | 678 | try { 679 | // Export all data 680 | await this.repository.getTasks(this.projectId); 681 | await this.repository.getTags(this.projectId); 682 | 683 | // TODO: In a real implementation, this would: 684 | // 1. Create backup data structure with tasks and tags 685 | // 2. Save the backup to a storage service 686 | // For now, return a backup identifier 687 | return `backup-${this.projectId}-${Date.now()}`; 688 | } catch (error) { 689 | throw new TaskMasterError( 690 | 'Failed to create backup via API', 691 | ERROR_CODES.STORAGE_ERROR, 692 | { operation: 'backup' }, 693 | error as Error 694 | ); 695 | } 696 | } 697 | 698 | /** 699 | * Restore from backup 700 | */ 701 | async restore(backupId: string): Promise<void> { 702 | await this.ensureInitialized(); 703 | 704 | // This would restore from a backup service 705 | // Implementation depends on backup strategy 706 | throw new TaskMasterError( 707 | 'Restore not implemented for API storage', 708 | ERROR_CODES.NOT_IMPLEMENTED, 709 | { operation: 'restore', backupId } 710 | ); 711 | } 712 | 713 | /** 714 | * Clear all data 715 | */ 716 | async clear(): Promise<void> { 717 | await this.ensureInitialized(); 718 | 719 | try { 720 | // Delete all tasks 721 | const tasks = await this.repository.getTasks(this.projectId); 722 | if (tasks.length > 0) { 723 | await this.repository.bulkDeleteTasks( 724 | this.projectId, 725 | tasks.map((t) => t.id) 726 | ); 727 | } 728 | 729 | // Delete all tags 730 | const tags = await this.repository.getTags(this.projectId); 731 | for (const tag of tags) { 732 | await this.repository.deleteTag(this.projectId, tag.name); 733 | } 734 | 735 | // Clear cache 736 | this.tagsCache.clear(); 737 | } catch (error) { 738 | throw new TaskMasterError( 739 | 'Failed to clear data via API', 740 | ERROR_CODES.STORAGE_ERROR, 741 | { operation: 'clear' }, 742 | error as Error 743 | ); 744 | } 745 | } 746 | 747 | /** 748 | * Close connection 749 | */ 750 | async close(): Promise<void> { 751 | this.initialized = false; 752 | this.tagsCache.clear(); 753 | } 754 | 755 | /** 756 | * Ensure storage is initialized 757 | */ 758 | private async ensureInitialized(): Promise<void> { 759 | if (!this.initialized) { 760 | await this.initialize(); 761 | } 762 | } 763 | 764 | /** 765 | * Retry an operation with exponential backoff 766 | */ 767 | private async retryOperation<T>( 768 | operation: () => Promise<T>, 769 | attempt: number = 1 770 | ): Promise<T> { 771 | try { 772 | return await operation(); 773 | } catch (error) { 774 | if (this.enableRetry && attempt < this.maxRetries) { 775 | const delay = Math.pow(2, attempt) * 1000; 776 | await new Promise((resolve) => setTimeout(resolve, delay)); 777 | return this.retryOperation(operation, attempt + 1); 778 | } 779 | throw error; 780 | } 781 | } 782 | } 783 | ``` -------------------------------------------------------------------------------- /src/ai-providers/gemini-cli.js: -------------------------------------------------------------------------------- ```javascript 1 | /** 2 | * src/ai-providers/gemini-cli.js 3 | * 4 | * Implementation for interacting with Gemini models via Gemini CLI 5 | * using the ai-sdk-provider-gemini-cli package. 6 | */ 7 | 8 | import { generateObject, generateText, streamText } from 'ai'; 9 | import { parse } from 'jsonc-parser'; 10 | import { BaseAIProvider } from './base-provider.js'; 11 | import { log } from '../../scripts/modules/utils.js'; 12 | 13 | let createGeminiProvider; 14 | 15 | async function loadGeminiCliModule() { 16 | if (!createGeminiProvider) { 17 | try { 18 | const mod = await import('ai-sdk-provider-gemini-cli'); 19 | createGeminiProvider = mod.createGeminiProvider; 20 | } catch (err) { 21 | throw new Error( 22 | "Gemini CLI SDK is not installed. Please install 'ai-sdk-provider-gemini-cli' to use the gemini-cli provider." 23 | ); 24 | } 25 | } 26 | } 27 | 28 | export class GeminiCliProvider extends BaseAIProvider { 29 | constructor() { 30 | super(); 31 | this.name = 'Gemini CLI'; 32 | } 33 | 34 | /** 35 | * Override validateAuth to handle Gemini CLI authentication options 36 | * @param {object} params - Parameters to validate 37 | */ 38 | validateAuth(params) { 39 | // Gemini CLI is designed to use pre-configured OAuth authentication 40 | // Users choose gemini-cli specifically to leverage their existing 41 | // gemini auth login credentials, not to use API keys. 42 | // We support API keys for compatibility, but the expected usage 43 | // is through CLI authentication (no API key required). 44 | // No validation needed - the SDK will handle auth internally 45 | } 46 | 47 | /** 48 | * Creates and returns a Gemini CLI client instance. 49 | * @param {object} params - Parameters for client initialization 50 | * @param {string} [params.apiKey] - Optional Gemini API key (rarely used with gemini-cli) 51 | * @param {string} [params.baseURL] - Optional custom API endpoint 52 | * @returns {Promise<Function>} Gemini CLI client function 53 | * @throws {Error} If initialization fails 54 | */ 55 | async getClient(params) { 56 | try { 57 | // Load the Gemini CLI module dynamically 58 | await loadGeminiCliModule(); 59 | // Primary use case: Use existing gemini CLI authentication 60 | // Secondary use case: Direct API key (for compatibility) 61 | let authOptions = {}; 62 | 63 | if (params.apiKey && params.apiKey !== 'gemini-cli-no-key-required') { 64 | // API key provided - use it for compatibility 65 | authOptions = { 66 | authType: 'api-key', 67 | apiKey: params.apiKey 68 | }; 69 | } else { 70 | // Expected case: Use gemini CLI authentication via OAuth 71 | authOptions = { 72 | authType: 'oauth-personal' 73 | }; 74 | } 75 | 76 | // Add baseURL if provided (for custom endpoints) 77 | if (params.baseURL) { 78 | authOptions.baseURL = params.baseURL; 79 | } 80 | 81 | // Create and return the provider 82 | return createGeminiProvider(authOptions); 83 | } catch (error) { 84 | this.handleError('client initialization', error); 85 | } 86 | } 87 | 88 | /** 89 | * Extracts system messages from the messages array and returns them separately. 90 | * This is needed because ai-sdk-provider-gemini-cli expects system prompts as a separate parameter. 91 | * @param {Array} messages - Array of message objects 92 | * @param {Object} options - Options for system prompt enhancement 93 | * @param {boolean} options.enforceJsonOutput - Whether to add JSON enforcement to system prompt 94 | * @returns {Object} - {systemPrompt: string|undefined, messages: Array} 95 | */ 96 | _extractSystemMessage(messages, options = {}) { 97 | if (!messages || !Array.isArray(messages)) { 98 | return { systemPrompt: undefined, messages: messages || [] }; 99 | } 100 | 101 | const systemMessages = messages.filter((msg) => msg.role === 'system'); 102 | const nonSystemMessages = messages.filter((msg) => msg.role !== 'system'); 103 | 104 | // Combine multiple system messages if present 105 | let systemPrompt = 106 | systemMessages.length > 0 107 | ? systemMessages.map((msg) => msg.content).join('\n\n') 108 | : undefined; 109 | 110 | // Add Gemini CLI specific JSON enforcement if requested 111 | if (options.enforceJsonOutput) { 112 | const jsonEnforcement = this._getJsonEnforcementPrompt(); 113 | systemPrompt = systemPrompt 114 | ? `${systemPrompt}\n\n${jsonEnforcement}` 115 | : jsonEnforcement; 116 | } 117 | 118 | return { systemPrompt, messages: nonSystemMessages }; 119 | } 120 | 121 | /** 122 | * Gets a Gemini CLI specific system prompt to enforce strict JSON output 123 | * @returns {string} JSON enforcement system prompt 124 | */ 125 | _getJsonEnforcementPrompt() { 126 | return `CRITICAL: You MUST respond with ONLY valid JSON. Do not include any explanatory text, markdown formatting, code block markers, or conversational phrases like "Here is" or "Of course". Your entire response must be parseable JSON that starts with { or [ and ends with } or ]. No exceptions.`; 127 | } 128 | 129 | /** 130 | * Checks if a string is valid JSON 131 | * @param {string} text - Text to validate 132 | * @returns {boolean} True if valid JSON 133 | */ 134 | _isValidJson(text) { 135 | if (!text || typeof text !== 'string') { 136 | return false; 137 | } 138 | 139 | try { 140 | JSON.parse(text.trim()); 141 | return true; 142 | } catch { 143 | return false; 144 | } 145 | } 146 | 147 | /** 148 | * Detects if the user prompt is requesting JSON output 149 | * @param {Array} messages - Array of message objects 150 | * @returns {boolean} True if JSON output is likely expected 151 | */ 152 | _detectJsonRequest(messages) { 153 | const userMessages = messages.filter((msg) => msg.role === 'user'); 154 | const combinedText = userMessages 155 | .map((msg) => msg.content) 156 | .join(' ') 157 | .toLowerCase(); 158 | 159 | // Look for indicators that JSON output is expected 160 | const jsonIndicators = [ 161 | 'json', 162 | 'respond only with', 163 | 'return only', 164 | 'output only', 165 | 'format:', 166 | 'structure:', 167 | 'schema:', 168 | '{"', 169 | '[{', 170 | 'subtasks', 171 | 'array', 172 | 'object' 173 | ]; 174 | 175 | return jsonIndicators.some((indicator) => combinedText.includes(indicator)); 176 | } 177 | 178 | /** 179 | * Simplifies complex prompts for gemini-cli to improve JSON output compliance 180 | * @param {Array} messages - Array of message objects 181 | * @returns {Array} Simplified messages array 182 | */ 183 | _simplifyJsonPrompts(messages) { 184 | // First, check if this is an expand-task operation by looking at the system message 185 | const systemMsg = messages.find((m) => m.role === 'system'); 186 | const isExpandTask = 187 | systemMsg && 188 | systemMsg.content.includes( 189 | 'You are an AI assistant helping with task breakdown. Generate exactly' 190 | ); 191 | 192 | if (!isExpandTask) { 193 | return messages; // Not an expand task, return unchanged 194 | } 195 | 196 | // Extract subtask count from system message 197 | const subtaskCountMatch = systemMsg.content.match( 198 | /Generate exactly (\d+) subtasks/ 199 | ); 200 | const subtaskCount = subtaskCountMatch ? subtaskCountMatch[1] : '10'; 201 | 202 | log( 203 | 'debug', 204 | `${this.name} detected expand-task operation, simplifying for ${subtaskCount} subtasks` 205 | ); 206 | 207 | return messages.map((msg) => { 208 | if (msg.role !== 'user') { 209 | return msg; 210 | } 211 | 212 | // For expand-task user messages, create a much simpler, more direct prompt 213 | // that doesn't depend on specific task content 214 | const simplifiedPrompt = `Generate exactly ${subtaskCount} subtasks in the following JSON format. 215 | 216 | CRITICAL INSTRUCTION: You must respond with ONLY valid JSON. No explanatory text, no "Here is", no "Of course", no markdown - just the JSON object. 217 | 218 | Required JSON structure: 219 | { 220 | "subtasks": [ 221 | { 222 | "id": 1, 223 | "title": "Specific actionable task title", 224 | "description": "Clear task description", 225 | "dependencies": [], 226 | "details": "Implementation details and guidance", 227 | "testStrategy": "Testing approach" 228 | } 229 | ] 230 | } 231 | 232 | Generate ${subtaskCount} subtasks based on the original task context. Return ONLY the JSON object.`; 233 | 234 | log( 235 | 'debug', 236 | `${this.name} simplified user prompt for better JSON compliance` 237 | ); 238 | return { ...msg, content: simplifiedPrompt }; 239 | }); 240 | } 241 | 242 | /** 243 | * Extract JSON from Gemini's response using a tolerant parser. 244 | * 245 | * Optimized approach that progressively tries different parsing strategies: 246 | * 1. Direct parsing after cleanup 247 | * 2. Smart boundary detection with single-pass analysis 248 | * 3. Limited character-by-character fallback for edge cases 249 | * 250 | * @param {string} text - Raw text which may contain JSON 251 | * @returns {string} A valid JSON string if extraction succeeds, otherwise the original text 252 | */ 253 | extractJson(text) { 254 | if (!text || typeof text !== 'string') { 255 | return text; 256 | } 257 | 258 | let content = text.trim(); 259 | 260 | // Early exit for very short content 261 | if (content.length < 2) { 262 | return text; 263 | } 264 | 265 | // Strip common wrappers in a single pass 266 | content = content 267 | // Remove markdown fences 268 | .replace(/^.*?```(?:json)?\s*([\s\S]*?)\s*```.*$/i, '$1') 269 | // Remove variable declarations 270 | .replace(/^\s*(?:const|let|var)\s+\w+\s*=\s*([\s\S]*?)(?:;|\s*)$/i, '$1') 271 | // Remove common prefixes 272 | .replace(/^(?:Here's|The)\s+(?:the\s+)?JSON.*?[:]\s*/i, '') 273 | .trim(); 274 | 275 | // Find the first JSON-like structure 276 | const firstObj = content.indexOf('{'); 277 | const firstArr = content.indexOf('['); 278 | 279 | if (firstObj === -1 && firstArr === -1) { 280 | return text; 281 | } 282 | 283 | const start = 284 | firstArr === -1 285 | ? firstObj 286 | : firstObj === -1 287 | ? firstArr 288 | : Math.min(firstObj, firstArr); 289 | content = content.slice(start); 290 | 291 | // Optimized parsing function with error collection 292 | const tryParse = (value) => { 293 | if (!value || value.length < 2) return undefined; 294 | 295 | const errors = []; 296 | try { 297 | const result = parse(value, errors, { 298 | allowTrailingComma: true, 299 | allowEmptyContent: false 300 | }); 301 | if (errors.length === 0 && result !== undefined) { 302 | return JSON.stringify(result, null, 2); 303 | } 304 | } catch { 305 | // Parsing failed completely 306 | } 307 | return undefined; 308 | }; 309 | 310 | // Try parsing the full content first 311 | const fullParse = tryParse(content); 312 | if (fullParse !== undefined) { 313 | return fullParse; 314 | } 315 | 316 | // Smart boundary detection - single pass with optimizations 317 | const openChar = content[0]; 318 | const closeChar = openChar === '{' ? '}' : ']'; 319 | 320 | let depth = 0; 321 | let inString = false; 322 | let escapeNext = false; 323 | let lastValidEnd = -1; 324 | 325 | // Single-pass boundary detection with early termination 326 | for (let i = 0; i < content.length && i < 10000; i++) { 327 | // Limit scan for performance 328 | const char = content[i]; 329 | 330 | if (escapeNext) { 331 | escapeNext = false; 332 | continue; 333 | } 334 | 335 | if (char === '\\') { 336 | escapeNext = true; 337 | continue; 338 | } 339 | 340 | if (char === '"') { 341 | inString = !inString; 342 | continue; 343 | } 344 | 345 | if (inString) continue; 346 | 347 | if (char === openChar) { 348 | depth++; 349 | } else if (char === closeChar) { 350 | depth--; 351 | if (depth === 0) { 352 | lastValidEnd = i + 1; 353 | // Try parsing immediately on first valid boundary 354 | const candidate = content.slice(0, lastValidEnd); 355 | const parsed = tryParse(candidate); 356 | if (parsed !== undefined) { 357 | return parsed; 358 | } 359 | } 360 | } 361 | } 362 | 363 | // If we found valid boundaries but parsing failed, try limited fallback 364 | if (lastValidEnd > 0) { 365 | const maxAttempts = Math.min(5, Math.floor(lastValidEnd / 100)); // Limit attempts 366 | for (let i = 0; i < maxAttempts; i++) { 367 | const testEnd = Math.max( 368 | lastValidEnd - i * 50, 369 | Math.floor(lastValidEnd * 0.8) 370 | ); 371 | const candidate = content.slice(0, testEnd); 372 | const parsed = tryParse(candidate); 373 | if (parsed !== undefined) { 374 | return parsed; 375 | } 376 | } 377 | } 378 | 379 | return text; 380 | } 381 | 382 | /** 383 | * Generates text using Gemini CLI model 384 | * Overrides base implementation to properly handle system messages and enforce JSON output when needed 385 | */ 386 | async generateText(params) { 387 | try { 388 | this.validateParams(params); 389 | this.validateMessages(params.messages); 390 | 391 | log( 392 | 'debug', 393 | `Generating ${this.name} text with model: ${params.modelId}` 394 | ); 395 | 396 | // Detect if JSON output is expected and enforce it for better gemini-cli compatibility 397 | const enforceJsonOutput = this._detectJsonRequest(params.messages); 398 | 399 | // Debug logging to understand what's happening 400 | log('debug', `${this.name} JSON detection analysis:`, { 401 | enforceJsonOutput, 402 | messageCount: params.messages.length, 403 | messages: params.messages.map((msg) => ({ 404 | role: msg.role, 405 | contentPreview: msg.content 406 | ? msg.content.substring(0, 200) + '...' 407 | : 'empty' 408 | })) 409 | }); 410 | 411 | if (enforceJsonOutput) { 412 | log( 413 | 'debug', 414 | `${this.name} detected JSON request - applying strict JSON enforcement system prompt` 415 | ); 416 | } 417 | 418 | // For gemini-cli, simplify complex prompts before processing 419 | let processedMessages = params.messages; 420 | if (enforceJsonOutput) { 421 | processedMessages = this._simplifyJsonPrompts(params.messages); 422 | } 423 | 424 | // Extract system messages for separate handling with optional JSON enforcement 425 | const { systemPrompt, messages } = this._extractSystemMessage( 426 | processedMessages, 427 | { enforceJsonOutput } 428 | ); 429 | 430 | // Debug the final system prompt being sent 431 | log('debug', `${this.name} final system prompt:`, { 432 | systemPromptLength: systemPrompt ? systemPrompt.length : 0, 433 | systemPromptPreview: systemPrompt 434 | ? systemPrompt.substring(0, 300) + '...' 435 | : 'none', 436 | finalMessageCount: messages.length 437 | }); 438 | 439 | const client = await this.getClient(params); 440 | const result = await generateText({ 441 | model: client(params.modelId), 442 | system: systemPrompt, 443 | messages: messages, 444 | maxTokens: params.maxTokens, 445 | temperature: params.temperature 446 | }); 447 | 448 | // If we detected a JSON request and gemini-cli returned conversational text, 449 | // attempt to extract JSON from the response 450 | let finalText = result.text; 451 | if (enforceJsonOutput && result.text && !this._isValidJson(result.text)) { 452 | log( 453 | 'debug', 454 | `${this.name} response appears conversational, attempting JSON extraction` 455 | ); 456 | 457 | // Log first 1000 chars of the response to see what Gemini actually returned 458 | log('debug', `${this.name} raw response preview:`, { 459 | responseLength: result.text.length, 460 | responseStart: result.text.substring(0, 1000) 461 | }); 462 | 463 | const extractedJson = this.extractJson(result.text); 464 | if (this._isValidJson(extractedJson)) { 465 | log( 466 | 'debug', 467 | `${this.name} successfully extracted JSON from conversational response` 468 | ); 469 | finalText = extractedJson; 470 | } else { 471 | log( 472 | 'debug', 473 | `${this.name} JSON extraction failed, returning original response` 474 | ); 475 | 476 | // Log what extraction returned to debug why it failed 477 | log('debug', `${this.name} extraction result preview:`, { 478 | extractedLength: extractedJson ? extractedJson.length : 0, 479 | extractedStart: extractedJson 480 | ? extractedJson.substring(0, 500) 481 | : 'null' 482 | }); 483 | } 484 | } 485 | 486 | log( 487 | 'debug', 488 | `${this.name} generateText completed successfully for model: ${params.modelId}` 489 | ); 490 | 491 | return { 492 | text: finalText, 493 | usage: { 494 | inputTokens: result.usage?.promptTokens, 495 | outputTokens: result.usage?.completionTokens, 496 | totalTokens: result.usage?.totalTokens 497 | } 498 | }; 499 | } catch (error) { 500 | this.handleError('text generation', error); 501 | } 502 | } 503 | 504 | /** 505 | * Streams text using Gemini CLI model 506 | * Overrides base implementation to properly handle system messages and enforce JSON output when needed 507 | */ 508 | async streamText(params) { 509 | try { 510 | this.validateParams(params); 511 | this.validateMessages(params.messages); 512 | 513 | log('debug', `Streaming ${this.name} text with model: ${params.modelId}`); 514 | 515 | // Detect if JSON output is expected and enforce it for better gemini-cli compatibility 516 | const enforceJsonOutput = this._detectJsonRequest(params.messages); 517 | 518 | // Debug logging to understand what's happening 519 | log('debug', `${this.name} JSON detection analysis:`, { 520 | enforceJsonOutput, 521 | messageCount: params.messages.length, 522 | messages: params.messages.map((msg) => ({ 523 | role: msg.role, 524 | contentPreview: msg.content 525 | ? msg.content.substring(0, 200) + '...' 526 | : 'empty' 527 | })) 528 | }); 529 | 530 | if (enforceJsonOutput) { 531 | log( 532 | 'debug', 533 | `${this.name} detected JSON request - applying strict JSON enforcement system prompt` 534 | ); 535 | } 536 | 537 | // Extract system messages for separate handling with optional JSON enforcement 538 | const { systemPrompt, messages } = this._extractSystemMessage( 539 | params.messages, 540 | { enforceJsonOutput } 541 | ); 542 | 543 | const client = await this.getClient(params); 544 | const stream = await streamText({ 545 | model: client(params.modelId), 546 | system: systemPrompt, 547 | messages: messages, 548 | maxTokens: params.maxTokens, 549 | temperature: params.temperature 550 | }); 551 | 552 | log( 553 | 'debug', 554 | `${this.name} streamText initiated successfully for model: ${params.modelId}` 555 | ); 556 | 557 | // Note: For streaming, we can't intercept and modify the response in real-time 558 | // The JSON extraction would need to happen on the consuming side 559 | return stream; 560 | } catch (error) { 561 | this.handleError('text streaming', error); 562 | } 563 | } 564 | 565 | /** 566 | * Generates a structured object using Gemini CLI model 567 | * Overrides base implementation to handle Gemini-specific JSON formatting issues and system messages 568 | */ 569 | async generateObject(params) { 570 | try { 571 | // First try the standard generateObject from base class 572 | return await super.generateObject(params); 573 | } catch (error) { 574 | // If it's a JSON parsing error, try to extract and parse JSON manually 575 | if (error.message?.includes('JSON') || error.message?.includes('parse')) { 576 | log( 577 | 'debug', 578 | `Gemini CLI generateObject failed with parsing error, attempting manual extraction` 579 | ); 580 | 581 | try { 582 | // Validate params first 583 | this.validateParams(params); 584 | this.validateMessages(params.messages); 585 | 586 | if (!params.schema) { 587 | throw new Error('Schema is required for object generation'); 588 | } 589 | if (!params.objectName) { 590 | throw new Error('Object name is required for object generation'); 591 | } 592 | 593 | // Extract system messages for separate handling with JSON enforcement 594 | const { systemPrompt, messages } = this._extractSystemMessage( 595 | params.messages, 596 | { enforceJsonOutput: true } 597 | ); 598 | 599 | // Call generateObject directly with our client 600 | const client = await this.getClient(params); 601 | const result = await generateObject({ 602 | model: client(params.modelId), 603 | system: systemPrompt, 604 | messages: messages, 605 | schema: params.schema, 606 | mode: 'json', // Use json mode instead of auto for Gemini 607 | maxTokens: params.maxTokens, 608 | temperature: params.temperature 609 | }); 610 | 611 | // If we get rawResponse text, try to extract JSON from it 612 | if (result.rawResponse?.text && !result.object) { 613 | const extractedJson = this.extractJson(result.rawResponse.text); 614 | try { 615 | result.object = JSON.parse(extractedJson); 616 | } catch (parseError) { 617 | log( 618 | 'error', 619 | `Failed to parse extracted JSON: ${parseError.message}` 620 | ); 621 | log( 622 | 'debug', 623 | `Extracted JSON: ${extractedJson.substring(0, 500)}...` 624 | ); 625 | throw new Error( 626 | `Gemini CLI returned invalid JSON that could not be parsed: ${parseError.message}` 627 | ); 628 | } 629 | } 630 | 631 | return { 632 | object: result.object, 633 | usage: { 634 | inputTokens: result.usage?.promptTokens, 635 | outputTokens: result.usage?.completionTokens, 636 | totalTokens: result.usage?.totalTokens 637 | } 638 | }; 639 | } catch (retryError) { 640 | log( 641 | 'error', 642 | `Gemini CLI manual JSON extraction failed: ${retryError.message}` 643 | ); 644 | // Re-throw the original error with more context 645 | throw new Error( 646 | `${this.name} failed to generate valid JSON object: ${error.message}` 647 | ); 648 | } 649 | } 650 | 651 | // For non-parsing errors, just re-throw 652 | throw error; 653 | } 654 | } 655 | 656 | getRequiredApiKeyName() { 657 | return 'GEMINI_API_KEY'; 658 | } 659 | 660 | isRequiredApiKey() { 661 | return false; 662 | } 663 | } 664 | ``` -------------------------------------------------------------------------------- /tests/integration/move-task-cross-tag.integration.test.js: -------------------------------------------------------------------------------- ```javascript 1 | import { jest } from '@jest/globals'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import { fileURLToPath } from 'url'; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = path.dirname(__filename); 8 | 9 | // Mock dependencies before importing 10 | const mockUtils = { 11 | readJSON: jest.fn(), 12 | writeJSON: jest.fn(), 13 | findProjectRoot: jest.fn(() => '/test/project/root'), 14 | log: jest.fn(), 15 | setTasksForTag: jest.fn(), 16 | traverseDependencies: jest.fn((sourceTasks, allTasks, options = {}) => { 17 | // Mock realistic dependency behavior for testing 18 | const { direction = 'forward' } = options; 19 | 20 | if (direction === 'forward') { 21 | // Return dependencies that tasks have 22 | const result = []; 23 | sourceTasks.forEach((task) => { 24 | if (task.dependencies && Array.isArray(task.dependencies)) { 25 | result.push(...task.dependencies); 26 | } 27 | }); 28 | return result; 29 | } else if (direction === 'reverse') { 30 | // Return tasks that depend on the source tasks 31 | const sourceIds = sourceTasks.map((t) => t.id); 32 | const normalizedSourceIds = sourceIds.map((id) => String(id)); 33 | const result = []; 34 | allTasks.forEach((task) => { 35 | if (task.dependencies && Array.isArray(task.dependencies)) { 36 | const hasDependency = task.dependencies.some((depId) => 37 | normalizedSourceIds.includes(String(depId)) 38 | ); 39 | if (hasDependency) { 40 | result.push(task.id); 41 | } 42 | } 43 | }); 44 | return result; 45 | } 46 | return []; 47 | }) 48 | }; 49 | 50 | // Mock the utils module 51 | jest.unstable_mockModule('../../scripts/modules/utils.js', () => mockUtils); 52 | 53 | // Mock other dependencies 54 | jest.unstable_mockModule( 55 | '../../scripts/modules/task-manager/is-task-dependent.js', 56 | () => ({ 57 | default: jest.fn(() => false) 58 | }) 59 | ); 60 | 61 | jest.unstable_mockModule('../../scripts/modules/dependency-manager.js', () => ({ 62 | findCrossTagDependencies: jest.fn(() => { 63 | // Since dependencies can only exist within the same tag, 64 | // this function should never find any cross-tag conflicts 65 | return []; 66 | }), 67 | getDependentTaskIds: jest.fn( 68 | (sourceTasks, crossTagDependencies, allTasks) => { 69 | // Since we now use findAllDependenciesRecursively in the actual implementation, 70 | // this mock simulates finding all dependencies recursively within the same tag 71 | const dependentIds = new Set(); 72 | const processedIds = new Set(); 73 | 74 | function findAllDependencies(taskId) { 75 | if (processedIds.has(taskId)) return; 76 | processedIds.add(taskId); 77 | 78 | const task = allTasks.find((t) => t.id === taskId); 79 | if (!task || !Array.isArray(task.dependencies)) return; 80 | 81 | task.dependencies.forEach((depId) => { 82 | const normalizedDepId = 83 | typeof depId === 'string' ? parseInt(depId, 10) : depId; 84 | if (!isNaN(normalizedDepId) && normalizedDepId !== taskId) { 85 | dependentIds.add(normalizedDepId); 86 | findAllDependencies(normalizedDepId); 87 | } 88 | }); 89 | } 90 | 91 | sourceTasks.forEach((sourceTask) => { 92 | if (sourceTask && sourceTask.id) { 93 | findAllDependencies(sourceTask.id); 94 | } 95 | }); 96 | 97 | return Array.from(dependentIds); 98 | } 99 | ), 100 | validateSubtaskMove: jest.fn((taskId, sourceTag, targetTag) => { 101 | // Throw error for subtask IDs 102 | const taskIdStr = String(taskId); 103 | if (taskIdStr.includes('.')) { 104 | throw new Error('Cannot move subtasks directly between tags'); 105 | } 106 | }) 107 | })); 108 | 109 | jest.unstable_mockModule( 110 | '../../scripts/modules/task-manager/generate-task-files.js', 111 | () => ({ 112 | default: jest.fn().mockResolvedValue() 113 | }) 114 | ); 115 | 116 | // Import the modules we'll be testing after mocking 117 | const { moveTasksBetweenTags } = await import( 118 | '../../scripts/modules/task-manager/move-task.js' 119 | ); 120 | 121 | describe('Cross-Tag Task Movement Integration Tests', () => { 122 | let testDataPath; 123 | let mockTasksData; 124 | 125 | beforeEach(() => { 126 | // Setup test data path 127 | testDataPath = path.join(__dirname, 'temp-test-tasks.json'); 128 | 129 | // Initialize mock data with multiple tags 130 | mockTasksData = { 131 | backlog: { 132 | tasks: [ 133 | { 134 | id: 1, 135 | title: 'Backlog Task 1', 136 | description: 'A task in backlog', 137 | status: 'pending', 138 | dependencies: [], 139 | priority: 'medium', 140 | tag: 'backlog' 141 | }, 142 | { 143 | id: 2, 144 | title: 'Backlog Task 2', 145 | description: 'Another task in backlog', 146 | status: 'pending', 147 | dependencies: [1], 148 | priority: 'high', 149 | tag: 'backlog' 150 | }, 151 | { 152 | id: 3, 153 | title: 'Backlog Task 3', 154 | description: 'Independent task', 155 | status: 'pending', 156 | dependencies: [], 157 | priority: 'low', 158 | tag: 'backlog' 159 | } 160 | ] 161 | }, 162 | 'in-progress': { 163 | tasks: [ 164 | { 165 | id: 4, 166 | title: 'In Progress Task 1', 167 | description: 'A task being worked on', 168 | status: 'in-progress', 169 | dependencies: [], 170 | priority: 'high', 171 | tag: 'in-progress' 172 | } 173 | ] 174 | }, 175 | done: { 176 | tasks: [ 177 | { 178 | id: 5, 179 | title: 'Completed Task 1', 180 | description: 'A completed task', 181 | status: 'done', 182 | dependencies: [], 183 | priority: 'medium', 184 | tag: 'done' 185 | } 186 | ] 187 | } 188 | }; 189 | 190 | // Setup mock utils 191 | mockUtils.readJSON.mockReturnValue(mockTasksData); 192 | mockUtils.writeJSON.mockImplementation((path, data, projectRoot, tag) => { 193 | // Simulate writing to file 194 | return Promise.resolve(); 195 | }); 196 | }); 197 | 198 | afterEach(() => { 199 | jest.clearAllMocks(); 200 | // Clean up temp file if it exists 201 | if (fs.existsSync(testDataPath)) { 202 | fs.unlinkSync(testDataPath); 203 | } 204 | }); 205 | 206 | describe('Basic Cross-Tag Movement', () => { 207 | it('should move a single task between tags successfully', async () => { 208 | const taskIds = [1]; 209 | const sourceTag = 'backlog'; 210 | const targetTag = 'in-progress'; 211 | 212 | const result = await moveTasksBetweenTags( 213 | testDataPath, 214 | taskIds, 215 | sourceTag, 216 | targetTag, 217 | {}, 218 | { projectRoot: '/test/project' } 219 | ); 220 | 221 | // Verify readJSON was called with correct parameters 222 | expect(mockUtils.readJSON).toHaveBeenCalledWith( 223 | testDataPath, 224 | '/test/project', 225 | sourceTag 226 | ); 227 | 228 | // Verify writeJSON was called with updated data 229 | expect(mockUtils.writeJSON).toHaveBeenCalledWith( 230 | testDataPath, 231 | expect.objectContaining({ 232 | backlog: expect.objectContaining({ 233 | tasks: expect.arrayContaining([ 234 | expect.objectContaining({ id: 2 }), 235 | expect.objectContaining({ id: 3 }) 236 | ]) 237 | }), 238 | 'in-progress': expect.objectContaining({ 239 | tasks: expect.arrayContaining([ 240 | expect.objectContaining({ id: 4 }), 241 | expect.objectContaining({ 242 | id: 1, 243 | tag: 'in-progress' 244 | }) 245 | ]) 246 | }) 247 | }), 248 | '/test/project', 249 | null 250 | ); 251 | 252 | // Verify result structure 253 | expect(result).toEqual({ 254 | message: 'Successfully moved 1 tasks from "backlog" to "in-progress"', 255 | movedTasks: [ 256 | { 257 | id: 1, 258 | fromTag: 'backlog', 259 | toTag: 'in-progress' 260 | } 261 | ] 262 | }); 263 | }); 264 | 265 | it('should move multiple tasks between tags', async () => { 266 | const taskIds = [1, 3]; 267 | const sourceTag = 'backlog'; 268 | const targetTag = 'done'; 269 | 270 | const result = await moveTasksBetweenTags( 271 | testDataPath, 272 | taskIds, 273 | sourceTag, 274 | targetTag, 275 | {}, 276 | { projectRoot: '/test/project' } 277 | ); 278 | 279 | // Verify the moved tasks are in the target tag 280 | expect(mockUtils.writeJSON).toHaveBeenCalledWith( 281 | testDataPath, 282 | expect.objectContaining({ 283 | backlog: expect.objectContaining({ 284 | tasks: expect.arrayContaining([expect.objectContaining({ id: 2 })]) 285 | }), 286 | done: expect.objectContaining({ 287 | tasks: expect.arrayContaining([ 288 | expect.objectContaining({ id: 5 }), 289 | expect.objectContaining({ 290 | id: 1, 291 | tag: 'done' 292 | }), 293 | expect.objectContaining({ 294 | id: 3, 295 | tag: 'done' 296 | }) 297 | ]) 298 | }) 299 | }), 300 | '/test/project', 301 | null 302 | ); 303 | 304 | // Verify result structure 305 | expect(result.movedTasks).toHaveLength(2); 306 | expect(result.movedTasks).toEqual( 307 | expect.arrayContaining([ 308 | { id: 1, fromTag: 'backlog', toTag: 'done' }, 309 | { id: 3, fromTag: 'backlog', toTag: 'done' } 310 | ]) 311 | ); 312 | }); 313 | 314 | it('should create target tag if it does not exist', async () => { 315 | const taskIds = [1]; 316 | const sourceTag = 'backlog'; 317 | const targetTag = 'new-tag'; 318 | 319 | const result = await moveTasksBetweenTags( 320 | testDataPath, 321 | taskIds, 322 | sourceTag, 323 | targetTag, 324 | {}, 325 | { projectRoot: '/test/project' } 326 | ); 327 | 328 | // Verify new tag was created 329 | expect(mockUtils.writeJSON).toHaveBeenCalledWith( 330 | testDataPath, 331 | expect.objectContaining({ 332 | 'new-tag': expect.objectContaining({ 333 | tasks: expect.arrayContaining([ 334 | expect.objectContaining({ 335 | id: 1, 336 | tag: 'new-tag' 337 | }) 338 | ]) 339 | }) 340 | }), 341 | '/test/project', 342 | null 343 | ); 344 | }); 345 | }); 346 | 347 | describe('Dependency Handling', () => { 348 | it('should move task with dependencies when withDependencies is true', async () => { 349 | const taskIds = [2]; // Task 2 depends on Task 1 350 | const sourceTag = 'backlog'; 351 | const targetTag = 'in-progress'; 352 | 353 | const result = await moveTasksBetweenTags( 354 | testDataPath, 355 | taskIds, 356 | sourceTag, 357 | targetTag, 358 | { withDependencies: true }, 359 | { projectRoot: '/test/project' } 360 | ); 361 | 362 | // Verify both task 2 and its dependency (task 1) were moved 363 | expect(mockUtils.writeJSON).toHaveBeenCalledWith( 364 | testDataPath, 365 | expect.objectContaining({ 366 | backlog: expect.objectContaining({ 367 | tasks: expect.arrayContaining([expect.objectContaining({ id: 3 })]) 368 | }), 369 | 'in-progress': expect.objectContaining({ 370 | tasks: expect.arrayContaining([ 371 | expect.objectContaining({ id: 4 }), 372 | expect.objectContaining({ 373 | id: 1, 374 | tag: 'in-progress' 375 | }), 376 | expect.objectContaining({ 377 | id: 2, 378 | tag: 'in-progress' 379 | }) 380 | ]) 381 | }) 382 | }), 383 | '/test/project', 384 | null 385 | ); 386 | }); 387 | 388 | it('should move task normally when ignoreDependencies is true (no cross-tag conflicts to ignore)', async () => { 389 | const taskIds = [2]; // Task 2 depends on Task 1 390 | const sourceTag = 'backlog'; 391 | const targetTag = 'in-progress'; 392 | 393 | const result = await moveTasksBetweenTags( 394 | testDataPath, 395 | taskIds, 396 | sourceTag, 397 | targetTag, 398 | { ignoreDependencies: true }, 399 | { projectRoot: '/test/project' } 400 | ); 401 | 402 | // Since dependencies only exist within tags, there are no cross-tag conflicts to ignore 403 | // Task 2 moves with its dependencies intact 404 | expect(mockUtils.writeJSON).toHaveBeenCalledWith( 405 | testDataPath, 406 | expect.objectContaining({ 407 | backlog: expect.objectContaining({ 408 | tasks: expect.arrayContaining([ 409 | expect.objectContaining({ id: 1 }), 410 | expect.objectContaining({ id: 3 }) 411 | ]) 412 | }), 413 | 'in-progress': expect.objectContaining({ 414 | tasks: expect.arrayContaining([ 415 | expect.objectContaining({ id: 4 }), 416 | expect.objectContaining({ 417 | id: 2, 418 | tag: 'in-progress', 419 | dependencies: [1] // Dependencies preserved since no cross-tag conflicts 420 | }) 421 | ]) 422 | }) 423 | }), 424 | '/test/project', 425 | null 426 | ); 427 | }); 428 | 429 | it('should provide advisory tips when ignoreDependencies breaks deps', async () => { 430 | // Move a task that has dependencies so cross-tag conflicts would be broken 431 | const taskIds = [2]; // backlog:2 depends on 1 432 | const sourceTag = 'backlog'; 433 | const targetTag = 'in-progress'; 434 | 435 | // Override cross-tag detection to simulate conflicts for this case 436 | const depManager = await import( 437 | '../../scripts/modules/dependency-manager.js' 438 | ); 439 | depManager.findCrossTagDependencies.mockReturnValueOnce([ 440 | { taskId: 2, dependencyId: 1, dependencyTag: sourceTag } 441 | ]); 442 | 443 | const result = await moveTasksBetweenTags( 444 | testDataPath, 445 | taskIds, 446 | sourceTag, 447 | targetTag, 448 | { ignoreDependencies: true }, 449 | { projectRoot: '/test/project' } 450 | ); 451 | 452 | expect(Array.isArray(result.tips)).toBe(true); 453 | const expectedTips = [ 454 | 'Run "task-master validate-dependencies" to check for dependency issues.', 455 | 'Run "task-master fix-dependencies" to automatically repair dangling dependencies.' 456 | ]; 457 | expect(result.tips).toHaveLength(expectedTips.length); 458 | expect(result.tips).toEqual(expect.arrayContaining(expectedTips)); 459 | }); 460 | 461 | it('should move task without cross-tag dependency conflicts (since dependencies only exist within tags)', async () => { 462 | const taskIds = [2]; // Task 2 depends on Task 1 (both in same tag) 463 | const sourceTag = 'backlog'; 464 | const targetTag = 'in-progress'; 465 | 466 | // Since dependencies can only exist within the same tag, 467 | // there should be no cross-tag conflicts 468 | const result = await moveTasksBetweenTags( 469 | testDataPath, 470 | taskIds, 471 | sourceTag, 472 | targetTag, 473 | {}, 474 | { projectRoot: '/test/project' } 475 | ); 476 | 477 | // Verify task was moved successfully (without dependencies) 478 | expect(mockUtils.writeJSON).toHaveBeenCalledWith( 479 | testDataPath, 480 | expect.objectContaining({ 481 | backlog: expect.objectContaining({ 482 | tasks: expect.arrayContaining([ 483 | expect.objectContaining({ id: 1 }), // Task 1 stays in backlog 484 | expect.objectContaining({ id: 3 }) 485 | ]) 486 | }), 487 | 'in-progress': expect.objectContaining({ 488 | tasks: expect.arrayContaining([ 489 | expect.objectContaining({ id: 4 }), 490 | expect.objectContaining({ 491 | id: 2, 492 | tag: 'in-progress' 493 | }) 494 | ]) 495 | }) 496 | }), 497 | '/test/project', 498 | null 499 | ); 500 | }); 501 | }); 502 | 503 | describe('Error Handling', () => { 504 | it('should throw error for invalid source tag', async () => { 505 | const taskIds = [1]; 506 | const sourceTag = 'nonexistent-tag'; 507 | const targetTag = 'in-progress'; 508 | 509 | // Mock readJSON to return data without the source tag 510 | mockUtils.readJSON.mockReturnValue({ 511 | 'in-progress': { tasks: [] } 512 | }); 513 | 514 | await expect( 515 | moveTasksBetweenTags( 516 | testDataPath, 517 | taskIds, 518 | sourceTag, 519 | targetTag, 520 | {}, 521 | { projectRoot: '/test/project' } 522 | ) 523 | ).rejects.toThrow('Source tag "nonexistent-tag" not found or invalid'); 524 | }); 525 | 526 | it('should throw error for invalid task IDs', async () => { 527 | const taskIds = [999]; // Non-existent task ID 528 | const sourceTag = 'backlog'; 529 | const targetTag = 'in-progress'; 530 | 531 | await expect( 532 | moveTasksBetweenTags( 533 | testDataPath, 534 | taskIds, 535 | sourceTag, 536 | targetTag, 537 | {}, 538 | { projectRoot: '/test/project' } 539 | ) 540 | ).rejects.toThrow('Task 999 not found in source tag "backlog"'); 541 | }); 542 | 543 | it('should throw error for subtask movement', async () => { 544 | const taskIds = ['1.1']; // Subtask ID 545 | const sourceTag = 'backlog'; 546 | const targetTag = 'in-progress'; 547 | 548 | await expect( 549 | moveTasksBetweenTags( 550 | testDataPath, 551 | taskIds, 552 | sourceTag, 553 | targetTag, 554 | {}, 555 | { projectRoot: '/test/project' } 556 | ) 557 | ).rejects.toThrow('Cannot move subtasks directly between tags'); 558 | }); 559 | 560 | it('should handle ID conflicts in target tag', async () => { 561 | // Setup data with conflicting IDs 562 | const conflictingData = { 563 | backlog: { 564 | tasks: [ 565 | { 566 | id: 1, 567 | title: 'Backlog Task', 568 | tag: 'backlog' 569 | } 570 | ] 571 | }, 572 | 'in-progress': { 573 | tasks: [ 574 | { 575 | id: 1, // Same ID as in backlog 576 | title: 'In Progress Task', 577 | tag: 'in-progress' 578 | } 579 | ] 580 | } 581 | }; 582 | 583 | mockUtils.readJSON.mockReturnValue(conflictingData); 584 | 585 | const taskIds = [1]; 586 | const sourceTag = 'backlog'; 587 | const targetTag = 'in-progress'; 588 | 589 | await expect( 590 | moveTasksBetweenTags( 591 | testDataPath, 592 | taskIds, 593 | sourceTag, 594 | targetTag, 595 | {}, 596 | { projectRoot: '/test/project' } 597 | ) 598 | ).rejects.toThrow('Task 1 already exists in target tag "in-progress"'); 599 | 600 | // Validate suggestions on the error payload 601 | try { 602 | await moveTasksBetweenTags( 603 | testDataPath, 604 | taskIds, 605 | sourceTag, 606 | targetTag, 607 | {}, 608 | { projectRoot: '/test/project' } 609 | ); 610 | } catch (err) { 611 | expect(err.code).toBe('TASK_ALREADY_EXISTS'); 612 | expect(Array.isArray(err.data?.suggestions)).toBe(true); 613 | const s = (err.data?.suggestions || []).join(' '); 614 | expect(s).toContain('different target tag'); 615 | expect(s).toContain('different set of IDs'); 616 | expect(s).toContain('within-tag'); 617 | } 618 | }); 619 | }); 620 | 621 | describe('Edge Cases', () => { 622 | it('should handle empty task list in source tag', async () => { 623 | const emptyData = { 624 | backlog: { tasks: [] }, 625 | 'in-progress': { tasks: [] } 626 | }; 627 | 628 | mockUtils.readJSON.mockReturnValue(emptyData); 629 | 630 | const taskIds = [1]; 631 | const sourceTag = 'backlog'; 632 | const targetTag = 'in-progress'; 633 | 634 | await expect( 635 | moveTasksBetweenTags( 636 | testDataPath, 637 | taskIds, 638 | sourceTag, 639 | targetTag, 640 | {}, 641 | { projectRoot: '/test/project' } 642 | ) 643 | ).rejects.toThrow('Task 1 not found in source tag "backlog"'); 644 | }); 645 | 646 | it('should preserve task metadata during move', async () => { 647 | const taskIds = [1]; 648 | const sourceTag = 'backlog'; 649 | const targetTag = 'in-progress'; 650 | 651 | const result = await moveTasksBetweenTags( 652 | testDataPath, 653 | taskIds, 654 | sourceTag, 655 | targetTag, 656 | {}, 657 | { projectRoot: '/test/project' } 658 | ); 659 | 660 | // Verify task metadata is preserved 661 | expect(mockUtils.writeJSON).toHaveBeenCalledWith( 662 | testDataPath, 663 | expect.objectContaining({ 664 | 'in-progress': expect.objectContaining({ 665 | tasks: expect.arrayContaining([ 666 | expect.objectContaining({ 667 | id: 1, 668 | title: 'Backlog Task 1', 669 | description: 'A task in backlog', 670 | status: 'pending', 671 | priority: 'medium', 672 | tag: 'in-progress', // Tag should be updated 673 | metadata: expect.objectContaining({ 674 | moveHistory: expect.arrayContaining([ 675 | expect.objectContaining({ 676 | fromTag: 'backlog', 677 | toTag: 'in-progress', 678 | timestamp: expect.any(String) 679 | }) 680 | ]) 681 | }) 682 | }) 683 | ]) 684 | }) 685 | }), 686 | '/test/project', 687 | null 688 | ); 689 | }); 690 | 691 | // Note: force flag deprecated for cross-tag moves; covered by with/ignore dependencies tests 692 | }); 693 | 694 | describe('Complex Scenarios', () => { 695 | it('should handle complex moves without cross-tag conflicts (dependencies only within tags)', async () => { 696 | // Setup data with valid within-tag dependencies 697 | const validData = { 698 | backlog: { 699 | tasks: [ 700 | { 701 | id: 1, 702 | title: 'Task 1', 703 | dependencies: [], // No dependencies 704 | tag: 'backlog' 705 | }, 706 | { 707 | id: 3, 708 | title: 'Task 3', 709 | dependencies: [1], // Depends on Task 1 (same tag) 710 | tag: 'backlog' 711 | } 712 | ] 713 | }, 714 | 'in-progress': { 715 | tasks: [ 716 | { 717 | id: 2, 718 | title: 'Task 2', 719 | dependencies: [], // No dependencies 720 | tag: 'in-progress' 721 | } 722 | ] 723 | } 724 | }; 725 | 726 | mockUtils.readJSON.mockReturnValue(validData); 727 | 728 | const taskIds = [3]; 729 | const sourceTag = 'backlog'; 730 | const targetTag = 'in-progress'; 731 | 732 | // Should succeed since there are no cross-tag conflicts 733 | const result = await moveTasksBetweenTags( 734 | testDataPath, 735 | taskIds, 736 | sourceTag, 737 | targetTag, 738 | {}, 739 | { projectRoot: '/test/project' } 740 | ); 741 | 742 | expect(result).toEqual({ 743 | message: 'Successfully moved 1 tasks from "backlog" to "in-progress"', 744 | movedTasks: [{ id: 3, fromTag: 'backlog', toTag: 'in-progress' }] 745 | }); 746 | }); 747 | 748 | it('should handle bulk move with mixed dependency scenarios', async () => { 749 | const taskIds = [1, 2, 3]; // Multiple tasks with dependencies 750 | const sourceTag = 'backlog'; 751 | const targetTag = 'in-progress'; 752 | 753 | const result = await moveTasksBetweenTags( 754 | testDataPath, 755 | taskIds, 756 | sourceTag, 757 | targetTag, 758 | { withDependencies: true }, 759 | { projectRoot: '/test/project' } 760 | ); 761 | 762 | // Verify all tasks were moved 763 | expect(mockUtils.writeJSON).toHaveBeenCalledWith( 764 | testDataPath, 765 | expect.objectContaining({ 766 | backlog: expect.objectContaining({ 767 | tasks: [] // All tasks should be moved 768 | }), 769 | 'in-progress': expect.objectContaining({ 770 | tasks: expect.arrayContaining([ 771 | expect.objectContaining({ id: 4 }), 772 | expect.objectContaining({ id: 1, tag: 'in-progress' }), 773 | expect.objectContaining({ id: 2, tag: 'in-progress' }), 774 | expect.objectContaining({ id: 3, tag: 'in-progress' }) 775 | ]) 776 | }) 777 | }), 778 | '/test/project', 779 | null 780 | ); 781 | 782 | // Verify result structure 783 | expect(result.movedTasks).toHaveLength(3); 784 | expect(result.movedTasks).toEqual( 785 | expect.arrayContaining([ 786 | { id: 1, fromTag: 'backlog', toTag: 'in-progress' }, 787 | { id: 2, fromTag: 'backlog', toTag: 'in-progress' }, 788 | { id: 3, fromTag: 'backlog', toTag: 'in-progress' } 789 | ]) 790 | ); 791 | }); 792 | }); 793 | }); 794 | ``` -------------------------------------------------------------------------------- /tests/unit/ai-providers/gemini-cli.test.js: -------------------------------------------------------------------------------- ```javascript 1 | import { jest } from '@jest/globals'; 2 | 3 | // Mock the ai module 4 | jest.unstable_mockModule('ai', () => ({ 5 | generateObject: jest.fn(), 6 | generateText: jest.fn(), 7 | streamText: jest.fn() 8 | })); 9 | 10 | // Mock the gemini-cli SDK module 11 | jest.unstable_mockModule('ai-sdk-provider-gemini-cli', () => ({ 12 | createGeminiProvider: jest.fn((options) => { 13 | const provider = (modelId, settings) => ({ 14 | // Mock language model 15 | id: modelId, 16 | settings, 17 | authOptions: options 18 | }); 19 | provider.languageModel = jest.fn((id, settings) => ({ id, settings })); 20 | provider.chat = provider.languageModel; 21 | return provider; 22 | }) 23 | })); 24 | 25 | // Mock the base provider 26 | jest.unstable_mockModule('../../../src/ai-providers/base-provider.js', () => ({ 27 | BaseAIProvider: class { 28 | constructor() { 29 | this.name = 'Base Provider'; 30 | } 31 | handleError(context, error) { 32 | throw error; 33 | } 34 | validateParams(params) { 35 | // Basic validation 36 | if (!params.modelId) { 37 | throw new Error('Model ID is required'); 38 | } 39 | } 40 | validateMessages(messages) { 41 | if (!messages || !Array.isArray(messages)) { 42 | throw new Error('Invalid messages array'); 43 | } 44 | } 45 | async generateObject(params) { 46 | // Mock implementation that can be overridden 47 | throw new Error('Mock base generateObject error'); 48 | } 49 | } 50 | })); 51 | 52 | // Mock the log module 53 | jest.unstable_mockModule('../../../scripts/modules/utils.js', () => ({ 54 | log: jest.fn() 55 | })); 56 | 57 | // Import after mocking 58 | const { GeminiCliProvider } = await import( 59 | '../../../src/ai-providers/gemini-cli.js' 60 | ); 61 | const { createGeminiProvider } = await import('ai-sdk-provider-gemini-cli'); 62 | const { generateObject, generateText, streamText } = await import('ai'); 63 | const { log } = await import('../../../scripts/modules/utils.js'); 64 | 65 | describe('GeminiCliProvider', () => { 66 | let provider; 67 | let consoleLogSpy; 68 | 69 | beforeEach(() => { 70 | provider = new GeminiCliProvider(); 71 | jest.clearAllMocks(); 72 | consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); 73 | }); 74 | 75 | afterEach(() => { 76 | consoleLogSpy.mockRestore(); 77 | }); 78 | 79 | describe('constructor', () => { 80 | it('should set the provider name to Gemini CLI', () => { 81 | expect(provider.name).toBe('Gemini CLI'); 82 | }); 83 | }); 84 | 85 | describe('validateAuth', () => { 86 | it('should not throw an error when API key is provided', () => { 87 | expect(() => provider.validateAuth({ apiKey: 'test-key' })).not.toThrow(); 88 | expect(consoleLogSpy).not.toHaveBeenCalled(); 89 | }); 90 | 91 | it('should not require API key and should not log messages', () => { 92 | expect(() => provider.validateAuth({})).not.toThrow(); 93 | expect(consoleLogSpy).not.toHaveBeenCalled(); 94 | }); 95 | 96 | it('should not require any parameters', () => { 97 | expect(() => provider.validateAuth()).not.toThrow(); 98 | expect(consoleLogSpy).not.toHaveBeenCalled(); 99 | }); 100 | }); 101 | 102 | describe('getClient', () => { 103 | it('should return a gemini client with API key auth when apiKey is provided', async () => { 104 | const client = await provider.getClient({ apiKey: 'test-api-key' }); 105 | 106 | expect(client).toBeDefined(); 107 | expect(typeof client).toBe('function'); 108 | expect(createGeminiProvider).toHaveBeenCalledWith({ 109 | authType: 'api-key', 110 | apiKey: 'test-api-key' 111 | }); 112 | }); 113 | 114 | it('should return a gemini client with OAuth auth when no apiKey is provided', async () => { 115 | const client = await provider.getClient({}); 116 | 117 | expect(client).toBeDefined(); 118 | expect(typeof client).toBe('function'); 119 | expect(createGeminiProvider).toHaveBeenCalledWith({ 120 | authType: 'oauth-personal' 121 | }); 122 | }); 123 | 124 | it('should include baseURL when provided', async () => { 125 | const client = await provider.getClient({ 126 | apiKey: 'test-key', 127 | baseURL: 'https://custom-endpoint.com' 128 | }); 129 | 130 | expect(client).toBeDefined(); 131 | expect(createGeminiProvider).toHaveBeenCalledWith({ 132 | authType: 'api-key', 133 | apiKey: 'test-key', 134 | baseURL: 'https://custom-endpoint.com' 135 | }); 136 | }); 137 | 138 | it('should have languageModel and chat methods', async () => { 139 | const client = await provider.getClient({ apiKey: 'test-key' }); 140 | expect(client.languageModel).toBeDefined(); 141 | expect(client.chat).toBeDefined(); 142 | expect(client.chat).toBe(client.languageModel); 143 | }); 144 | }); 145 | 146 | describe('_extractSystemMessage', () => { 147 | it('should extract single system message', () => { 148 | const messages = [ 149 | { role: 'system', content: 'You are a helpful assistant' }, 150 | { role: 'user', content: 'Hello' } 151 | ]; 152 | const result = provider._extractSystemMessage(messages); 153 | expect(result.systemPrompt).toBe('You are a helpful assistant'); 154 | expect(result.messages).toEqual([{ role: 'user', content: 'Hello' }]); 155 | }); 156 | 157 | it('should combine multiple system messages', () => { 158 | const messages = [ 159 | { role: 'system', content: 'You are helpful' }, 160 | { role: 'system', content: 'Be concise' }, 161 | { role: 'user', content: 'Hello' } 162 | ]; 163 | const result = provider._extractSystemMessage(messages); 164 | expect(result.systemPrompt).toBe('You are helpful\n\nBe concise'); 165 | expect(result.messages).toEqual([{ role: 'user', content: 'Hello' }]); 166 | }); 167 | 168 | it('should handle messages without system prompts', () => { 169 | const messages = [ 170 | { role: 'user', content: 'Hello' }, 171 | { role: 'assistant', content: 'Hi there' } 172 | ]; 173 | const result = provider._extractSystemMessage(messages); 174 | expect(result.systemPrompt).toBeUndefined(); 175 | expect(result.messages).toEqual(messages); 176 | }); 177 | 178 | it('should handle empty or invalid input', () => { 179 | expect(provider._extractSystemMessage([])).toEqual({ 180 | systemPrompt: undefined, 181 | messages: [] 182 | }); 183 | expect(provider._extractSystemMessage(null)).toEqual({ 184 | systemPrompt: undefined, 185 | messages: [] 186 | }); 187 | expect(provider._extractSystemMessage(undefined)).toEqual({ 188 | systemPrompt: undefined, 189 | messages: [] 190 | }); 191 | }); 192 | 193 | it('should add JSON enforcement when enforceJsonOutput is true', () => { 194 | const messages = [ 195 | { role: 'system', content: 'You are a helpful assistant' }, 196 | { role: 'user', content: 'Hello' } 197 | ]; 198 | const result = provider._extractSystemMessage(messages, { 199 | enforceJsonOutput: true 200 | }); 201 | expect(result.systemPrompt).toContain('You are a helpful assistant'); 202 | expect(result.systemPrompt).toContain( 203 | 'CRITICAL: You MUST respond with ONLY valid JSON' 204 | ); 205 | expect(result.messages).toEqual([{ role: 'user', content: 'Hello' }]); 206 | }); 207 | 208 | it('should add JSON enforcement with no existing system message', () => { 209 | const messages = [{ role: 'user', content: 'Return JSON format' }]; 210 | const result = provider._extractSystemMessage(messages, { 211 | enforceJsonOutput: true 212 | }); 213 | expect(result.systemPrompt).toBe( 214 | 'CRITICAL: You MUST respond with ONLY valid JSON. Do not include any explanatory text, markdown formatting, code block markers, or conversational phrases like "Here is" or "Of course". Your entire response must be parseable JSON that starts with { or [ and ends with } or ]. No exceptions.' 215 | ); 216 | expect(result.messages).toEqual([ 217 | { role: 'user', content: 'Return JSON format' } 218 | ]); 219 | }); 220 | }); 221 | 222 | describe('_detectJsonRequest', () => { 223 | it('should detect JSON requests from user messages', () => { 224 | const messages = [ 225 | { 226 | role: 'user', 227 | content: 'Please return JSON format with subtasks array' 228 | } 229 | ]; 230 | expect(provider._detectJsonRequest(messages)).toBe(true); 231 | }); 232 | 233 | it('should detect various JSON indicators', () => { 234 | const testCases = [ 235 | 'respond only with valid JSON', 236 | 'return JSON format', 237 | 'output schema: {"test": true}', 238 | 'format: [{"id": 1}]', 239 | 'Please return subtasks in array format', 240 | 'Return an object with properties' 241 | ]; 242 | 243 | testCases.forEach((content) => { 244 | const messages = [{ role: 'user', content }]; 245 | expect(provider._detectJsonRequest(messages)).toBe(true); 246 | }); 247 | }); 248 | 249 | it('should not detect JSON requests for regular conversation', () => { 250 | const messages = [{ role: 'user', content: 'Hello, how are you today?' }]; 251 | expect(provider._detectJsonRequest(messages)).toBe(false); 252 | }); 253 | 254 | it('should handle multiple user messages', () => { 255 | const messages = [ 256 | { role: 'user', content: 'Hello' }, 257 | { role: 'assistant', content: 'Hi there' }, 258 | { role: 'user', content: 'Now please return JSON format' } 259 | ]; 260 | expect(provider._detectJsonRequest(messages)).toBe(true); 261 | }); 262 | }); 263 | 264 | describe('_getJsonEnforcementPrompt', () => { 265 | it('should return strict JSON enforcement prompt', () => { 266 | const prompt = provider._getJsonEnforcementPrompt(); 267 | expect(prompt).toContain('CRITICAL'); 268 | expect(prompt).toContain('ONLY valid JSON'); 269 | expect(prompt).toContain('No exceptions'); 270 | }); 271 | }); 272 | 273 | describe('_isValidJson', () => { 274 | it('should return true for valid JSON objects', () => { 275 | expect(provider._isValidJson('{"test": true}')).toBe(true); 276 | expect(provider._isValidJson('{"subtasks": [{"id": 1}]}')).toBe(true); 277 | }); 278 | 279 | it('should return true for valid JSON arrays', () => { 280 | expect(provider._isValidJson('[1, 2, 3]')).toBe(true); 281 | expect(provider._isValidJson('[{"id": 1}, {"id": 2}]')).toBe(true); 282 | }); 283 | 284 | it('should return false for invalid JSON', () => { 285 | expect(provider._isValidJson('Of course. Here is...')).toBe(false); 286 | expect(provider._isValidJson('{"invalid": json}')).toBe(false); 287 | expect(provider._isValidJson('not json at all')).toBe(false); 288 | }); 289 | 290 | it('should handle edge cases', () => { 291 | expect(provider._isValidJson('')).toBe(false); 292 | expect(provider._isValidJson(null)).toBe(false); 293 | expect(provider._isValidJson(undefined)).toBe(false); 294 | expect(provider._isValidJson(' {"test": true} ')).toBe(true); // with whitespace 295 | }); 296 | }); 297 | 298 | describe('extractJson', () => { 299 | it('should extract JSON from markdown code blocks', () => { 300 | const input = '```json\n{"subtasks": [{"id": 1}]}\n```'; 301 | const result = provider.extractJson(input); 302 | const parsed = JSON.parse(result); 303 | expect(parsed).toEqual({ subtasks: [{ id: 1 }] }); 304 | }); 305 | 306 | it('should extract JSON with explanatory text', () => { 307 | const input = 'Here\'s the JSON response:\n{"subtasks": [{"id": 1}]}'; 308 | const result = provider.extractJson(input); 309 | const parsed = JSON.parse(result); 310 | expect(parsed).toEqual({ subtasks: [{ id: 1 }] }); 311 | }); 312 | 313 | it('should handle variable declarations', () => { 314 | const input = 'const result = {"subtasks": [{"id": 1}]};'; 315 | const result = provider.extractJson(input); 316 | const parsed = JSON.parse(result); 317 | expect(parsed).toEqual({ subtasks: [{ id: 1 }] }); 318 | }); 319 | 320 | it('should handle trailing commas with jsonc-parser', () => { 321 | const input = '{"subtasks": [{"id": 1,}],}'; 322 | const result = provider.extractJson(input); 323 | const parsed = JSON.parse(result); 324 | expect(parsed).toEqual({ subtasks: [{ id: 1 }] }); 325 | }); 326 | 327 | it('should handle arrays', () => { 328 | const input = 'The result is: [1, 2, 3]'; 329 | const result = provider.extractJson(input); 330 | const parsed = JSON.parse(result); 331 | expect(parsed).toEqual([1, 2, 3]); 332 | }); 333 | 334 | it('should handle nested objects with proper bracket matching', () => { 335 | const input = 336 | 'Response: {"outer": {"inner": {"value": "test"}}} extra text'; 337 | const result = provider.extractJson(input); 338 | const parsed = JSON.parse(result); 339 | expect(parsed).toEqual({ outer: { inner: { value: 'test' } } }); 340 | }); 341 | 342 | it('should handle escaped quotes in strings', () => { 343 | const input = '{"message": "He said \\"hello\\" to me"}'; 344 | const result = provider.extractJson(input); 345 | const parsed = JSON.parse(result); 346 | expect(parsed).toEqual({ message: 'He said "hello" to me' }); 347 | }); 348 | 349 | it('should return original text if no JSON found', () => { 350 | const input = 'No JSON here'; 351 | expect(provider.extractJson(input)).toBe(input); 352 | }); 353 | 354 | it('should handle null or non-string input', () => { 355 | expect(provider.extractJson(null)).toBe(null); 356 | expect(provider.extractJson(undefined)).toBe(undefined); 357 | expect(provider.extractJson(123)).toBe(123); 358 | }); 359 | 360 | it('should handle partial JSON by finding valid boundaries', () => { 361 | const input = '{"valid": true, "partial": "incomplete'; 362 | // Should return original text since no valid JSON can be extracted 363 | expect(provider.extractJson(input)).toBe(input); 364 | }); 365 | 366 | it('should handle performance edge cases with large text', () => { 367 | // Test with large text that has JSON at the end 368 | const largePrefix = 'This is a very long explanation. '.repeat(1000); 369 | const json = '{"result": "success"}'; 370 | const input = largePrefix + json; 371 | 372 | const result = provider.extractJson(input); 373 | const parsed = JSON.parse(result); 374 | expect(parsed).toEqual({ result: 'success' }); 375 | }); 376 | 377 | it('should handle early termination for very large invalid content', () => { 378 | // Test that it doesn't hang on very large content without JSON 379 | const largeText = 'No JSON here. '.repeat(2000); 380 | const result = provider.extractJson(largeText); 381 | expect(result).toBe(largeText); 382 | }); 383 | }); 384 | 385 | describe('generateObject', () => { 386 | const mockParams = { 387 | modelId: 'gemini-2.0-flash-exp', 388 | apiKey: 'test-key', 389 | messages: [{ role: 'user', content: 'Test message' }], 390 | schema: { type: 'object', properties: {} }, 391 | objectName: 'testObject' 392 | }; 393 | 394 | beforeEach(() => { 395 | jest.clearAllMocks(); 396 | }); 397 | 398 | it('should handle JSON parsing errors by attempting manual extraction', async () => { 399 | // Mock the parent generateObject to throw a JSON parsing error 400 | jest 401 | .spyOn( 402 | Object.getPrototypeOf(Object.getPrototypeOf(provider)), 403 | 'generateObject' 404 | ) 405 | .mockRejectedValueOnce(new Error('Failed to parse JSON response')); 406 | 407 | // Mock generateObject from ai module to return text with JSON 408 | generateObject.mockResolvedValueOnce({ 409 | rawResponse: { 410 | text: 'Here is the JSON:\n```json\n{"subtasks": [{"id": 1}]}\n```' 411 | }, 412 | object: null, 413 | usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 } 414 | }); 415 | 416 | const result = await provider.generateObject(mockParams); 417 | 418 | expect(log).toHaveBeenCalledWith( 419 | 'debug', 420 | expect.stringContaining('attempting manual extraction') 421 | ); 422 | expect(generateObject).toHaveBeenCalledWith({ 423 | model: expect.objectContaining({ 424 | id: 'gemini-2.0-flash-exp', 425 | authOptions: expect.objectContaining({ 426 | authType: 'api-key', 427 | apiKey: 'test-key' 428 | }) 429 | }), 430 | messages: mockParams.messages, 431 | schema: mockParams.schema, 432 | mode: 'json', // Should use json mode for Gemini 433 | system: expect.stringContaining( 434 | 'CRITICAL: You MUST respond with ONLY valid JSON' 435 | ), 436 | maxTokens: undefined, 437 | temperature: undefined 438 | }); 439 | expect(result.object).toEqual({ subtasks: [{ id: 1 }] }); 440 | }); 441 | 442 | it('should throw error if manual extraction also fails', async () => { 443 | // Mock parent to throw JSON error 444 | jest 445 | .spyOn( 446 | Object.getPrototypeOf(Object.getPrototypeOf(provider)), 447 | 'generateObject' 448 | ) 449 | .mockRejectedValueOnce(new Error('Failed to parse JSON')); 450 | 451 | // Mock generateObject to return unparseable text 452 | generateObject.mockResolvedValueOnce({ 453 | rawResponse: { text: 'Not valid JSON at all' }, 454 | object: null 455 | }); 456 | 457 | await expect(provider.generateObject(mockParams)).rejects.toThrow( 458 | 'Gemini CLI failed to generate valid JSON object: Failed to parse JSON' 459 | ); 460 | }); 461 | 462 | it('should pass through non-JSON errors unchanged', async () => { 463 | const otherError = new Error('Network error'); 464 | jest 465 | .spyOn( 466 | Object.getPrototypeOf(Object.getPrototypeOf(provider)), 467 | 'generateObject' 468 | ) 469 | .mockRejectedValueOnce(otherError); 470 | 471 | await expect(provider.generateObject(mockParams)).rejects.toThrow( 472 | 'Network error' 473 | ); 474 | expect(generateObject).not.toHaveBeenCalled(); 475 | }); 476 | 477 | it('should handle successful response from parent', async () => { 478 | const mockResult = { 479 | object: { test: 'data' }, 480 | usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 } 481 | }; 482 | jest 483 | .spyOn( 484 | Object.getPrototypeOf(Object.getPrototypeOf(provider)), 485 | 'generateObject' 486 | ) 487 | .mockResolvedValueOnce(mockResult); 488 | 489 | const result = await provider.generateObject(mockParams); 490 | expect(result).toEqual(mockResult); 491 | expect(generateObject).not.toHaveBeenCalled(); 492 | }); 493 | }); 494 | 495 | describe('system message support', () => { 496 | const mockParams = { 497 | modelId: 'gemini-2.0-flash-exp', 498 | apiKey: 'test-key', 499 | messages: [ 500 | { role: 'system', content: 'You are a helpful assistant' }, 501 | { role: 'user', content: 'Hello' } 502 | ], 503 | maxTokens: 100, 504 | temperature: 0.7 505 | }; 506 | 507 | describe('generateText with system messages', () => { 508 | beforeEach(() => { 509 | jest.clearAllMocks(); 510 | }); 511 | 512 | it('should pass system prompt separately to AI SDK', async () => { 513 | const { generateText } = await import('ai'); 514 | generateText.mockResolvedValueOnce({ 515 | text: 'Hello! How can I help you?', 516 | usage: { promptTokens: 10, completionTokens: 8, totalTokens: 18 } 517 | }); 518 | 519 | const result = await provider.generateText(mockParams); 520 | 521 | expect(generateText).toHaveBeenCalledWith({ 522 | model: expect.objectContaining({ 523 | id: 'gemini-2.0-flash-exp' 524 | }), 525 | system: 'You are a helpful assistant', 526 | messages: [{ role: 'user', content: 'Hello' }], 527 | maxTokens: 100, 528 | temperature: 0.7 529 | }); 530 | expect(result.text).toBe('Hello! How can I help you?'); 531 | }); 532 | 533 | it('should handle messages without system prompt', async () => { 534 | const { generateText } = await import('ai'); 535 | const paramsNoSystem = { 536 | ...mockParams, 537 | messages: [{ role: 'user', content: 'Hello' }] 538 | }; 539 | 540 | generateText.mockResolvedValueOnce({ 541 | text: 'Hi there!', 542 | usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 } 543 | }); 544 | 545 | await provider.generateText(paramsNoSystem); 546 | 547 | expect(generateText).toHaveBeenCalledWith({ 548 | model: expect.objectContaining({ 549 | id: 'gemini-2.0-flash-exp' 550 | }), 551 | system: undefined, 552 | messages: [{ role: 'user', content: 'Hello' }], 553 | maxTokens: 100, 554 | temperature: 0.7 555 | }); 556 | }); 557 | }); 558 | 559 | describe('streamText with system messages', () => { 560 | it('should pass system prompt separately to AI SDK', async () => { 561 | const { streamText } = await import('ai'); 562 | const mockStream = { stream: 'mock-stream' }; 563 | streamText.mockResolvedValueOnce(mockStream); 564 | 565 | const result = await provider.streamText(mockParams); 566 | 567 | expect(streamText).toHaveBeenCalledWith({ 568 | model: expect.objectContaining({ 569 | id: 'gemini-2.0-flash-exp' 570 | }), 571 | system: 'You are a helpful assistant', 572 | messages: [{ role: 'user', content: 'Hello' }], 573 | maxTokens: 100, 574 | temperature: 0.7 575 | }); 576 | expect(result).toBe(mockStream); 577 | }); 578 | }); 579 | 580 | describe('generateObject with system messages', () => { 581 | const mockObjectParams = { 582 | ...mockParams, 583 | schema: { type: 'object', properties: {} }, 584 | objectName: 'testObject' 585 | }; 586 | 587 | it('should include system prompt in fallback generateObject call', async () => { 588 | // Mock parent to throw JSON error 589 | jest 590 | .spyOn( 591 | Object.getPrototypeOf(Object.getPrototypeOf(provider)), 592 | 'generateObject' 593 | ) 594 | .mockRejectedValueOnce(new Error('Failed to parse JSON')); 595 | 596 | // Mock direct generateObject call 597 | generateObject.mockResolvedValueOnce({ 598 | object: { result: 'success' }, 599 | usage: { promptTokens: 15, completionTokens: 10, totalTokens: 25 } 600 | }); 601 | 602 | const result = await provider.generateObject(mockObjectParams); 603 | 604 | expect(generateObject).toHaveBeenCalledWith({ 605 | model: expect.objectContaining({ 606 | id: 'gemini-2.0-flash-exp' 607 | }), 608 | system: expect.stringContaining('You are a helpful assistant'), 609 | messages: [{ role: 'user', content: 'Hello' }], 610 | schema: mockObjectParams.schema, 611 | mode: 'json', 612 | maxTokens: 100, 613 | temperature: 0.7 614 | }); 615 | expect(result.object).toEqual({ result: 'success' }); 616 | }); 617 | }); 618 | }); 619 | 620 | // Note: Error handling for module loading is tested in integration tests 621 | // since dynamic imports are difficult to mock properly in unit tests 622 | 623 | describe('authentication scenarios', () => { 624 | it('should use api-key auth type with API key', async () => { 625 | await provider.getClient({ apiKey: 'gemini-test-key' }); 626 | 627 | expect(createGeminiProvider).toHaveBeenCalledWith({ 628 | authType: 'api-key', 629 | apiKey: 'gemini-test-key' 630 | }); 631 | }); 632 | 633 | it('should use oauth-personal auth type without API key', async () => { 634 | await provider.getClient({}); 635 | 636 | expect(createGeminiProvider).toHaveBeenCalledWith({ 637 | authType: 'oauth-personal' 638 | }); 639 | }); 640 | 641 | it('should handle empty string API key as no API key', async () => { 642 | await provider.getClient({ apiKey: '' }); 643 | 644 | expect(createGeminiProvider).toHaveBeenCalledWith({ 645 | authType: 'oauth-personal' 646 | }); 647 | }); 648 | }); 649 | }); 650 | ```