From 63134a222c53717f6f029a79b10d5e80ca04c7ab Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:08:27 +0100 Subject: [PATCH] feat: add tm tags command to remote (#1386) Co-authored-by: Claude --- .gitignore | 4 +- .taskmaster/state.json | 4 +- .vscode/settings.json | 4 + CLAUDE.md | 10 + apps/cli/src/command-registry.ts | 28 +- apps/cli/src/commands/auth.command.ts | 16 +- .../src/commands/autopilot/abort.command.ts | 10 +- .../src/commands/autopilot/commit.command.ts | 2 +- .../commands/autopilot/complete.command.ts | 8 +- apps/cli/src/commands/autopilot/index.ts | 12 +- .../src/commands/autopilot/next.command.ts | 8 +- .../src/commands/autopilot/resume.command.ts | 6 +- apps/cli/src/commands/autopilot/shared.ts | 8 +- .../src/commands/autopilot/start.command.ts | 12 +- .../src/commands/autopilot/status.command.ts | 6 +- apps/cli/src/commands/briefs.command.ts | 382 ++++++++++++ apps/cli/src/commands/context.command.ts | 357 ++++-------- apps/cli/src/commands/export.command.ts | 16 +- apps/cli/src/commands/list.command.ts | 34 +- .../src/commands/models/custom-providers.ts | 6 +- apps/cli/src/commands/models/fetchers.ts | 4 +- apps/cli/src/commands/models/prompts.ts | 16 +- apps/cli/src/commands/models/setup.ts | 12 +- apps/cli/src/commands/next.command.ts | 10 +- apps/cli/src/commands/set-status.command.ts | 8 +- apps/cli/src/commands/show.command.ts | 12 +- apps/cli/src/commands/start.command.ts | 14 +- apps/cli/src/commands/tags.command.ts | 545 +++++++++++++++++ apps/cli/src/index.ts | 20 +- apps/cli/src/lib/model-management.ts | 4 +- apps/cli/src/types/tag-management.d.ts | 55 ++ .../src/ui/components/cardBox.component.ts | 60 ++ .../src/ui/components/dashboard.component.ts | 4 +- apps/cli/src/ui/components/index.ts | 3 +- .../src/ui/components/next-task.component.ts | 6 +- .../components/suggested-steps.component.ts | 2 +- .../ui/components/task-detail.component.ts | 14 +- apps/cli/src/ui/display/messages.ts | 102 ++++ apps/cli/src/ui/display/tables.ts | 145 +++++ .../ui/formatters/complexity-formatters.ts | 49 ++ .../ui/formatters/dependency-formatters.ts | 39 ++ .../src/ui/formatters/priority-formatters.ts | 25 + .../ui/formatters/status-formatters.spec.ts | 139 +++++ .../src/ui/formatters/status-formatters.ts | 179 ++++++ apps/cli/src/ui/index.ts | 47 +- apps/cli/src/ui/layout/helpers.spec.ts | 158 +++++ apps/cli/src/ui/layout/helpers.ts | 51 ++ apps/cli/src/utils/auth-helpers.ts | 68 +++ apps/cli/src/utils/auto-update.ts | 2 +- apps/cli/src/utils/brief-selection.ts | 284 +++++++++ apps/cli/src/utils/index.ts | 28 + apps/cli/src/utils/ui.spec.ts | 167 +----- apps/cli/src/utils/ui.ts | 428 +------------- package-lock.json | 1 + packages/tm-bridge/package.json | 3 +- packages/tm-bridge/src/add-tag-bridge.ts | 99 ++++ packages/tm-bridge/src/bridge-types.ts | 46 ++ packages/tm-bridge/src/bridge-utils.ts | 69 +++ packages/tm-bridge/src/expand-bridge.ts | 48 +- packages/tm-bridge/src/index.ts | 31 + packages/tm-bridge/src/tags-bridge.ts | 160 +++++ packages/tm-bridge/src/update-bridge.ts | 48 +- packages/tm-bridge/src/use-tag-bridge.ts | 145 +++++ .../tm-core/src/common/constants/index.ts | 18 +- .../interfaces/configuration.interface.ts | 4 +- .../common/interfaces/storage.interface.ts | 71 ++- .../tm-core/src/common/logger/logger.spec.ts | 4 +- .../src/common/mappers/TaskMapper.test.ts | 4 +- .../tm-core/src/common/mappers/TaskMapper.ts | 2 +- packages/tm-core/src/common/types/index.ts | 2 + packages/tm-core/src/common/utils/index.ts | 3 + .../tm-core/src/common/utils/path-helpers.ts | 40 ++ .../src/common/utils/path-normalizer.spec.ts | 6 +- .../src/common/utils/run-id-generator.spec.ts | 6 +- packages/tm-core/src/index.ts | 14 + .../src/modules/ai/providers/base-provider.ts | 4 +- .../src/modules/auth/auth-domain.spec.ts | 111 ++++ .../tm-core/src/modules/auth/auth-domain.ts | 27 +- packages/tm-core/src/modules/auth/config.ts | 54 +- packages/tm-core/src/modules/auth/index.ts | 1 - .../auth/managers/auth-manager.spec.ts | 2 +- .../src/modules/auth/managers/auth-manager.ts | 40 +- .../modules/auth/services/context-store.ts | 2 +- .../modules/auth/services/oauth-service.ts | 30 +- .../auth/services/organization.service.ts | 29 +- .../auth/services/supabase-session-storage.ts | 2 +- packages/tm-core/src/modules/auth/types.ts | 2 + .../src/modules/briefs/briefs-domain.ts | 182 ++++++ packages/tm-core/src/modules/briefs/index.ts | 7 + .../modules/briefs/services/brief-service.ts | 225 +++++++ packages/tm-core/src/modules/briefs/types.ts | 23 + .../src/modules/config/config-domain.ts | 2 +- .../config/managers/config-manager.spec.ts | 6 +- .../modules/config/managers/config-manager.ts | 6 +- .../services/config-loader.service.spec.ts | 4 +- .../config/services/config-loader.service.ts | 4 +- .../services/config-merger.service.spec.ts | 4 +- .../config-persistence.service.spec.ts | 4 +- .../services/config-persistence.service.ts | 2 +- ...nvironment-config-provider.service.spec.ts | 2 +- .../runtime-state-manager.service.spec.ts | 4 +- .../execution/executors/base-executor.ts | 4 +- .../execution/executors/claude-executor.ts | 10 +- .../execution/executors/executor-factory.ts | 4 +- .../execution/services/executor-service.ts | 16 +- .../modules/git/adapters/git-adapter.test.ts | 12 +- .../src/modules/git/adapters/git-adapter.ts | 4 +- .../tm-core/src/modules/git/git-domain.ts | 2 +- .../services/branch-name-generator.spec.ts | 2 +- .../services/commit-message-generator.test.ts | 2 +- .../git/services/commit-message-generator.ts | 2 +- .../git/services/scope-detector.test.ts | 2 +- .../git/services/template-engine.test.ts | 2 +- .../integration/clients/supabase-client.ts | 6 +- .../modules/integration/integration-domain.ts | 6 +- .../integration/services/export.service.ts | 8 +- .../services/task-expansion.service.ts | 4 +- .../services/task-retrieval.service.ts | 6 +- .../managers/complexity-report-manager.ts | 4 +- .../storage/adapters/activity-logger.ts | 2 +- .../modules/storage/adapters/api-storage.ts | 97 +++- .../adapters/file-storage/file-operations.ts | 2 +- .../adapters/file-storage/file-storage.ts | 225 ++++++- .../storage/services/storage-factory.ts | 29 +- .../src/modules/storage/utils/api-client.ts | 4 +- .../src/modules/tasks/services/tag.service.ts | 307 ++++++++++ .../modules/tasks/services/task-service.ts | 49 ++ .../tm-core/src/modules/tasks/tasks-domain.ts | 99 +++- .../managers/workflow-state-manager.spec.ts | 4 +- .../managers/workflow-state-manager.ts | 4 +- .../workflow-orchestrator.test.ts | 14 +- .../orchestrators/workflow-orchestrator.ts | 14 +- .../services/test-result-validator.test.ts | 6 +- .../services/test-result-validator.ts | 6 +- .../services/workflow-activity-logger.ts | 10 +- .../workflow/services/workflow.service.ts | 12 +- .../src/modules/workflow/workflow-domain.ts | 4 +- packages/tm-core/src/subpath-exports.test.ts | 2 +- packages/tm-core/src/tm-core.ts | 14 +- .../tm-core/tests/auth/auth-refresh.test.ts | 2 +- .../integration/auth-token-refresh.test.ts | 2 +- .../storage/activity-logger.test.ts | 10 +- packages/tm-core/tests/unit/executor.test.ts | 4 +- scripts/dev.js | 4 +- scripts/modules/commands.js | 135 +++-- .../modules/task-manager/tag-management.js | 81 +++ .../modules/task-manager/update-task-by-id.js | 4 +- sonar-project.properties | 24 + tests/fixtures/sample-tasks.js | 117 ++++ .../analyze-task-complexity.test.js | 1 + .../complexity-report-tag-isolation.test.js | 66 ++- .../modules/task-manager/expand-task.test.js | 148 ++++- .../task-manager/update-subtask-by-id.test.js | 47 +- .../task-manager/update-task-by-id.test.js | 547 +++++++++++++++++- 154 files changed, 6037 insertions(+), 1391 deletions(-) create mode 100644 apps/cli/src/commands/briefs.command.ts create mode 100644 apps/cli/src/commands/tags.command.ts create mode 100644 apps/cli/src/types/tag-management.d.ts create mode 100644 apps/cli/src/ui/components/cardBox.component.ts create mode 100644 apps/cli/src/ui/display/messages.ts create mode 100644 apps/cli/src/ui/display/tables.ts create mode 100644 apps/cli/src/ui/formatters/complexity-formatters.ts create mode 100644 apps/cli/src/ui/formatters/dependency-formatters.ts create mode 100644 apps/cli/src/ui/formatters/priority-formatters.ts create mode 100644 apps/cli/src/ui/formatters/status-formatters.spec.ts create mode 100644 apps/cli/src/ui/formatters/status-formatters.ts create mode 100644 apps/cli/src/ui/layout/helpers.spec.ts create mode 100644 apps/cli/src/ui/layout/helpers.ts create mode 100644 apps/cli/src/utils/auth-helpers.ts create mode 100644 apps/cli/src/utils/brief-selection.ts create mode 100644 apps/cli/src/utils/index.ts create mode 100644 packages/tm-bridge/src/add-tag-bridge.ts create mode 100644 packages/tm-bridge/src/bridge-types.ts create mode 100644 packages/tm-bridge/src/bridge-utils.ts create mode 100644 packages/tm-bridge/src/tags-bridge.ts create mode 100644 packages/tm-bridge/src/use-tag-bridge.ts create mode 100644 packages/tm-core/src/common/utils/path-helpers.ts create mode 100644 packages/tm-core/src/modules/auth/auth-domain.spec.ts create mode 100644 packages/tm-core/src/modules/briefs/briefs-domain.ts create mode 100644 packages/tm-core/src/modules/briefs/index.ts create mode 100644 packages/tm-core/src/modules/briefs/services/brief-service.ts create mode 100644 packages/tm-core/src/modules/briefs/types.ts create mode 100644 packages/tm-core/src/modules/tasks/services/tag.service.ts create mode 100644 sonar-project.properties diff --git a/.gitignore b/.gitignore index be9395b4..9c2bcbac 100644 --- a/.gitignore +++ b/.gitignore @@ -99,4 +99,6 @@ apps/extension/vsix-build/ .turbo # TaskMaster Workflow State (now stored in ~/.taskmaster/sessions/) -# No longer needed in .gitignore as state is stored globally \ No newline at end of file +# No longer needed in .gitignore as state is stored globally + +.scannerwork \ No newline at end of file diff --git a/.taskmaster/state.json b/.taskmaster/state.json index 76a4290a..a82519c8 100644 --- a/.taskmaster/state.json +++ b/.taskmaster/state.json @@ -1,6 +1,6 @@ { - "currentTag": "master", - "lastSwitched": "2025-10-27T09:28:03.574Z", + "currentTag": "tdd-phase-1-core-rails", + "lastSwitched": "2025-11-10T19:45:04.383Z", "branchTagMapping": { "v017-adds": "v017-adds", "next": "next" diff --git a/.vscode/settings.json b/.vscode/settings.json index ead27faa..1e521793 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,5 +23,9 @@ }, "[json]": { "editor.defaultFormatter": "biomejs.biome" + }, + "editor.codeActionsOnSave": { + "source.organizeImports.biome": "explicit", + "source.fixAll.biome": "explicit" } } diff --git a/CLAUDE.md b/CLAUDE.md index 0fe7d6e4..b2001421 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,6 +130,16 @@ function getTasks() { - ✅ MCP calls: `await tmCore.tasks.get(taskId)` (same intelligent ID parsing) - ✅ Single source of truth in tm-core +## Code Quality & Reusability Guidelines + +Apply standard software engineering principles: + +- **DRY (Don't Repeat Yourself)**: Extract patterns that appear 2+ times into reusable components or utilities +- **YAGNI (You Aren't Gonna Need It)**: Don't over-engineer. Create abstractions when duplication appears, not before +- **Maintainable**: Single source of truth. Change once, update everywhere +- **Readable**: Clear naming, proper structure, export from index files +- **Flexible**: Accept configuration options with sensible defaults + ## Documentation Guidelines - **Documentation location**: Write docs in `apps/docs/` (Mintlify site source), not `docs/` diff --git a/apps/cli/src/command-registry.ts b/apps/cli/src/command-registry.ts index ef6c35d2..d1519545 100644 --- a/apps/cli/src/command-registry.ts +++ b/apps/cli/src/command-registry.ts @@ -3,18 +3,20 @@ * Provides a single location for registering all CLI commands */ -import { Command } from 'commander'; +import type { Command } from 'commander'; +import { AuthCommand } from './commands/auth.command.js'; +import { AutopilotCommand } from './commands/autopilot/index.js'; +import { BriefsCommand } from './commands/briefs.command.js'; +import { ContextCommand } from './commands/context.command.js'; +import { ExportCommand } from './commands/export.command.js'; // Import all commands import { ListTasksCommand } from './commands/list.command.js'; -import { ShowCommand } from './commands/show.command.js'; import { NextCommand } from './commands/next.command.js'; -import { AuthCommand } from './commands/auth.command.js'; -import { ContextCommand } from './commands/context.command.js'; -import { StartCommand } from './commands/start.command.js'; import { SetStatusCommand } from './commands/set-status.command.js'; -import { ExportCommand } from './commands/export.command.js'; -import { AutopilotCommand } from './commands/autopilot/index.js'; +import { ShowCommand } from './commands/show.command.js'; +import { StartCommand } from './commands/start.command.js'; +import { TagsCommand } from './commands/tags.command.js'; /** * Command metadata for registration @@ -91,6 +93,18 @@ export class CommandRegistry { description: 'Manage workspace context (organization/brief)', commandClass: ContextCommand as any, category: 'auth' + }, + { + name: 'tags', + description: 'Manage tags for task organization', + commandClass: TagsCommand as any, + category: 'task' + }, + { + name: 'briefs', + description: 'Manage briefs (Hamster only)', + commandClass: BriefsCommand as any, + category: 'task' } ]; diff --git a/apps/cli/src/commands/auth.command.ts b/apps/cli/src/commands/auth.command.ts index e33f7f0d..be934a1f 100644 --- a/apps/cli/src/commands/auth.command.ts +++ b/apps/cli/src/commands/auth.command.ts @@ -3,19 +3,19 @@ * Extends Commander.Command for better integration with the framework */ -import { Command } from 'commander'; -import chalk from 'chalk'; -import inquirer from 'inquirer'; -import ora, { type Ora } from 'ora'; -import open from 'open'; import { + type AuthCredentials, AuthManager, - AuthenticationError, - type AuthCredentials + AuthenticationError } from '@tm/core'; +import chalk from 'chalk'; +import { Command } from 'commander'; +import inquirer from 'inquirer'; +import open from 'open'; +import ora, { type Ora } from 'ora'; +import { displayError } from '../utils/error-handler.js'; import * as ui from '../utils/ui.js'; import { ContextCommand } from './context.command.js'; -import { displayError } from '../utils/error-handler.js'; /** * Result type from auth command diff --git a/apps/cli/src/commands/autopilot/abort.command.ts b/apps/cli/src/commands/autopilot/abort.command.ts index f4bd66b8..f6b52903 100644 --- a/apps/cli/src/commands/autopilot/abort.command.ts +++ b/apps/cli/src/commands/autopilot/abort.command.ts @@ -2,16 +2,16 @@ * @fileoverview Abort Command - Safely terminate workflow */ -import { Command } from 'commander'; import { WorkflowOrchestrator } from '@tm/core'; +import { Command } from 'commander'; +import inquirer from 'inquirer'; import { AutopilotBaseOptions, - hasWorkflowState, - loadWorkflowState, + OutputFormatter, deleteWorkflowState, - OutputFormatter + hasWorkflowState, + loadWorkflowState } from './shared.js'; -import inquirer from 'inquirer'; import { getProjectRoot } from '../../utils/project-root.js'; interface AbortOptions extends AutopilotBaseOptions { diff --git a/apps/cli/src/commands/autopilot/commit.command.ts b/apps/cli/src/commands/autopilot/commit.command.ts index cd9c57b7..b5c38de8 100644 --- a/apps/cli/src/commands/autopilot/commit.command.ts +++ b/apps/cli/src/commands/autopilot/commit.command.ts @@ -2,8 +2,8 @@ * @fileoverview Commit Command - Create commit with enhanced message generation */ +import { CommitMessageGenerator, GitAdapter, WorkflowService } from '@tm/core'; import { Command } from 'commander'; -import { WorkflowService, GitAdapter, CommitMessageGenerator } from '@tm/core'; import { AutopilotBaseOptions, OutputFormatter } from './shared.js'; import { getProjectRoot } from '../../utils/project-root.js'; diff --git a/apps/cli/src/commands/autopilot/complete.command.ts b/apps/cli/src/commands/autopilot/complete.command.ts index 2a1b4899..ed971d85 100644 --- a/apps/cli/src/commands/autopilot/complete.command.ts +++ b/apps/cli/src/commands/autopilot/complete.command.ts @@ -2,14 +2,14 @@ * @fileoverview Complete Command - Complete current TDD phase with validation */ +import { type TestResult, WorkflowOrchestrator } from '@tm/core'; import { Command } from 'commander'; -import { WorkflowOrchestrator, TestResult } from '@tm/core'; import { getProjectRoot } from '../../utils/project-root.js'; import { - AutopilotBaseOptions, + type AutopilotBaseOptions, + OutputFormatter, hasWorkflowState, - loadWorkflowState, - OutputFormatter + loadWorkflowState } from './shared.js'; interface CompleteOptions extends AutopilotBaseOptions { diff --git a/apps/cli/src/commands/autopilot/index.ts b/apps/cli/src/commands/autopilot/index.ts index bbb08e60..c8fcda58 100644 --- a/apps/cli/src/commands/autopilot/index.ts +++ b/apps/cli/src/commands/autopilot/index.ts @@ -5,13 +5,13 @@ */ import { Command } from 'commander'; -import { StartCommand } from './start.command.js'; -import { ResumeCommand } from './resume.command.js'; -import { NextCommand } from './next.command.js'; -import { CompleteCommand } from './complete.command.js'; -import { CommitCommand } from './commit.command.js'; -import { StatusCommand } from './status.command.js'; import { AbortCommand } from './abort.command.js'; +import { CommitCommand } from './commit.command.js'; +import { CompleteCommand } from './complete.command.js'; +import { NextCommand } from './next.command.js'; +import { ResumeCommand } from './resume.command.js'; +import { StartCommand } from './start.command.js'; +import { StatusCommand } from './status.command.js'; /** * Shared command options for all autopilot commands diff --git a/apps/cli/src/commands/autopilot/next.command.ts b/apps/cli/src/commands/autopilot/next.command.ts index 97bf31e5..a081c3f3 100644 --- a/apps/cli/src/commands/autopilot/next.command.ts +++ b/apps/cli/src/commands/autopilot/next.command.ts @@ -2,14 +2,14 @@ * @fileoverview Next Command - Get next action in TDD workflow */ -import { Command } from 'commander'; import { WorkflowOrchestrator } from '@tm/core'; +import { Command } from 'commander'; import { getProjectRoot } from '../../utils/project-root.js'; import { - AutopilotBaseOptions, + type AutopilotBaseOptions, + OutputFormatter, hasWorkflowState, - loadWorkflowState, - OutputFormatter + loadWorkflowState } from './shared.js'; type NextOptions = AutopilotBaseOptions; diff --git a/apps/cli/src/commands/autopilot/resume.command.ts b/apps/cli/src/commands/autopilot/resume.command.ts index 35490bd1..8a460580 100644 --- a/apps/cli/src/commands/autopilot/resume.command.ts +++ b/apps/cli/src/commands/autopilot/resume.command.ts @@ -2,13 +2,13 @@ * @fileoverview Resume Command - Restore and resume TDD workflow */ -import { Command } from 'commander'; import { WorkflowOrchestrator } from '@tm/core'; +import { Command } from 'commander'; import { AutopilotBaseOptions, + OutputFormatter, hasWorkflowState, - loadWorkflowState, - OutputFormatter + loadWorkflowState } from './shared.js'; import { getProjectRoot } from '../../utils/project-root.js'; diff --git a/apps/cli/src/commands/autopilot/shared.ts b/apps/cli/src/commands/autopilot/shared.ts index 43a823a4..cfc265a3 100644 --- a/apps/cli/src/commands/autopilot/shared.ts +++ b/apps/cli/src/commands/autopilot/shared.ts @@ -3,12 +3,12 @@ */ import { - WorkflowOrchestrator, - WorkflowStateManager, + CommitMessageGenerator, GitAdapter, - CommitMessageGenerator + WorkflowOrchestrator, + WorkflowStateManager } from '@tm/core'; -import type { WorkflowState, WorkflowContext, SubtaskInfo } from '@tm/core'; +import type { SubtaskInfo, WorkflowContext, WorkflowState } from '@tm/core'; import chalk from 'chalk'; /** diff --git a/apps/cli/src/commands/autopilot/start.command.ts b/apps/cli/src/commands/autopilot/start.command.ts index bf51cd6d..b239cc18 100644 --- a/apps/cli/src/commands/autopilot/start.command.ts +++ b/apps/cli/src/commands/autopilot/start.command.ts @@ -2,16 +2,16 @@ * @fileoverview Start Command - Initialize and start TDD workflow */ +import { type WorkflowContext, createTmCore } from '@tm/core'; import { Command } from 'commander'; -import { createTmCore, type WorkflowContext } from '@tm/core'; import { AutopilotBaseOptions, - hasWorkflowState, - createOrchestrator, - createGitAdapter, OutputFormatter, - validateTaskId, - parseSubtasks + createGitAdapter, + createOrchestrator, + hasWorkflowState, + parseSubtasks, + validateTaskId } from './shared.js'; import { getProjectRoot } from '../../utils/project-root.js'; diff --git a/apps/cli/src/commands/autopilot/status.command.ts b/apps/cli/src/commands/autopilot/status.command.ts index 123bc377..5c50c23d 100644 --- a/apps/cli/src/commands/autopilot/status.command.ts +++ b/apps/cli/src/commands/autopilot/status.command.ts @@ -2,13 +2,13 @@ * @fileoverview Status Command - Show workflow progress */ -import { Command } from 'commander'; import { WorkflowOrchestrator } from '@tm/core'; +import { Command } from 'commander'; import { AutopilotBaseOptions, + OutputFormatter, hasWorkflowState, - loadWorkflowState, - OutputFormatter + loadWorkflowState } from './shared.js'; import { getProjectRoot } from '../../utils/project-root.js'; diff --git a/apps/cli/src/commands/briefs.command.ts b/apps/cli/src/commands/briefs.command.ts new file mode 100644 index 00000000..524deef6 --- /dev/null +++ b/apps/cli/src/commands/briefs.command.ts @@ -0,0 +1,382 @@ +/** + * @fileoverview Briefs Command - Friendly alias for tag management in API storage + * Provides brief-specific commands that only work with API storage + */ + +import { + type LogLevel, + type TagInfo, + tryAddTagViaRemote, + tryListTagsViaRemote +} from '@tm/bridge'; +import type { TmCore } from '@tm/core'; +import { AuthManager, createTmCore } from '@tm/core'; +import { Command } from 'commander'; +import { checkAuthentication } from '../utils/auth-helpers.js'; +import { + selectBriefFromInput, + selectBriefInteractive +} from '../utils/brief-selection.js'; +import * as ui from '../utils/ui.js'; + +/** + * Result type from briefs command + */ +export interface BriefsResult { + success: boolean; + action: 'list' | 'select' | 'create'; + briefs?: TagInfo[]; + currentBrief?: string | null; + message?: string; +} + +/** + * BriefsCommand - Manage briefs for API storage (friendly alias) + * Only works when using API storage (tryhamster.com) + */ +export class BriefsCommand extends Command { + private tmCore?: TmCore; + private authManager: AuthManager; + private lastResult?: BriefsResult; + + constructor(name?: string) { + super(name || 'briefs'); + + // Initialize auth manager + this.authManager = AuthManager.getInstance(); + + // Configure the command + this.description('Manage briefs (API storage only)'); + this.alias('brief'); + + // Add subcommands + this.addListCommand(); + this.addSelectCommand(); + this.addCreateCommand(); + + // Accept optional positional argument for brief URL/ID + this.argument('[briefOrUrl]', 'Brief ID or Hamster brief URL'); + + // Default action: if argument provided, select brief; else list briefs + this.action(async (briefOrUrl?: string) => { + if (briefOrUrl && briefOrUrl.trim().length > 0) { + await this.executeSelectFromUrl(briefOrUrl.trim()); + return; + } + await this.executeList(); + }); + } + + /** + * Check if user is authenticated (required for briefs) + */ + private async checkAuth(): Promise { + return checkAuthentication(this.authManager, { + message: + 'The "briefs" command requires you to be logged in to your Hamster account.', + footer: + 'Working locally instead?\n' + + ' → Use "task-master tags" for local tag management.', + authCommand: 'task-master auth login' + }); + } + + /** + * Add list subcommand + */ + private addListCommand(): void { + this.command('list') + .description('List all briefs (default action)') + .option('--show-metadata', 'Show additional brief metadata') + .addHelpText( + 'after', + ` +Examples: + $ tm briefs # List all briefs (default) + $ tm briefs list # List all briefs (explicit) + $ tm briefs list --show-metadata # List with metadata + +Note: This command only works with API storage (tryhamster.com). +` + ) + .action(async (options) => { + await this.executeList(options); + }); + } + + /** + * Add select subcommand + */ + private addSelectCommand(): void { + this.command('select') + .description('Select a brief to work with') + .argument( + '[briefOrUrl]', + 'Brief ID or Hamster URL (optional, interactive if omitted)' + ) + .addHelpText( + 'after', + ` +Examples: + $ tm brief select # Interactive selection + $ tm brief select abc12345 # Select by ID + $ tm brief select https://app.tryhamster.com/... # Select by URL + +Shortcuts: + $ tm brief # Same as "select" + $ tm brief # List all briefs + +Note: Works exactly like "tm context brief" - reuses the same interactive interface. +` + ) + .action(async (briefOrUrl) => { + await this.executeSelect(briefOrUrl); + }); + } + + /** + * Add create subcommand + */ + private addCreateCommand(): void { + this.command('create') + .description('Create a new brief (redirects to web UI)') + .argument('[name]', 'Brief name (optional)') + .addHelpText( + 'after', + ` +Examples: + $ tm briefs create # Redirect to web UI to create brief + $ tm briefs create my-new-brief # Redirect with suggested name + +Note: Briefs must be created through the Hamster Studio web interface. +` + ) + .action(async (name) => { + await this.executeCreate(name); + }); + } + + /** + * Initialize TmCore if not already initialized + */ + private async initTmCore(): Promise { + if (!this.tmCore) { + this.tmCore = await createTmCore({ + projectPath: process.cwd() + }); + } + } + + /** + * Execute list briefs + */ + private async executeList(options?: { + showMetadata?: boolean; + }): Promise { + try { + // Check authentication + if (!(await this.checkAuth())) { + process.exit(1); + } + + // Use the bridge to list briefs + const remoteResult = await tryListTagsViaRemote({ + projectRoot: process.cwd(), + showMetadata: options?.showMetadata || false, + report: (level: LogLevel, ...args: unknown[]) => { + const message = args[0] as string; + if (level === 'error') ui.displayError(message); + else if (level === 'warn') ui.displayWarning(message); + else if (level === 'info') ui.displayInfo(message); + } + }); + + if (!remoteResult) { + throw new Error('Failed to fetch briefs from API'); + } + + this.setLastResult({ + success: remoteResult.success, + action: 'list', + briefs: remoteResult.tags, + currentBrief: remoteResult.currentTag, + message: remoteResult.message + }); + } catch (error) { + ui.displayError(`Failed to list briefs: ${(error as Error).message}`); + this.setLastResult({ + success: false, + action: 'list', + message: (error as Error).message + }); + process.exit(1); + } + } + + /** + * Execute select brief interactively or by name/ID + */ + private async executeSelect(nameOrId?: string): Promise { + try { + // Check authentication + const hasSession = await this.authManager.hasValidSession(); + if (!hasSession) { + ui.displayError('Not authenticated. Run "tm auth login" first.'); + process.exit(1); + } + + // If name/ID provided, treat it as URL/ID selection + if (nameOrId && nameOrId.trim().length > 0) { + await this.executeSelectFromUrl(nameOrId.trim()); + return; + } + + // Check if org is selected for interactive selection + const context = this.authManager.getContext(); + if (!context?.orgId) { + ui.displayErrorBox( + 'No organization selected. Run "tm context org" first.' + ); + process.exit(1); + } + + // Use shared utility for interactive selection + const result = await selectBriefInteractive( + this.authManager, + context.orgId + ); + + this.setLastResult({ + success: result.success, + action: 'select', + currentBrief: result.briefId, + message: result.message + }); + + if (!result.success) { + process.exit(1); + } + } catch (error) { + ui.displayErrorBox(`Failed to select brief: ${(error as Error).message}`); + this.setLastResult({ + success: false, + action: 'select', + message: (error as Error).message + }); + process.exit(1); + } + } + + /** + * Execute select brief from any input (URL, ID, or name) + * All parsing logic is in tm-core + */ + private async executeSelectFromUrl(input: string): Promise { + try { + // Check authentication + const hasSession = await this.authManager.hasValidSession(); + if (!hasSession) { + ui.displayError('Not authenticated. Run "tm auth login" first.'); + process.exit(1); + } + + // Initialize tmCore to access business logic + await this.initTmCore(); + + // Use shared utility - tm-core handles ALL parsing + const result = await selectBriefFromInput( + this.authManager, + input, + this.tmCore + ); + + this.setLastResult({ + success: result.success, + action: 'select', + currentBrief: result.briefId, + message: result.message + }); + + if (!result.success) { + process.exit(1); + } + } catch (error) { + ui.displayErrorBox(`Failed to select brief: ${(error as Error).message}`); + this.setLastResult({ + success: false, + action: 'select', + message: (error as Error).message + }); + process.exit(1); + } + } + + /** + * Execute create brief (redirect to web UI) + */ + private async executeCreate(name?: string): Promise { + try { + // Check authentication + if (!(await this.checkAuth())) { + process.exit(1); + } + + // Use the bridge to redirect to web UI + const remoteResult = await tryAddTagViaRemote({ + tagName: name || 'new-brief', + projectRoot: process.cwd(), + report: (level: LogLevel, ...args: unknown[]) => { + const message = args[0] as string; + if (level === 'error') ui.displayError(message); + else if (level === 'warn') ui.displayWarning(message); + else if (level === 'info') ui.displayInfo(message); + } + }); + + if (!remoteResult) { + throw new Error('Failed to get brief creation URL'); + } + + this.setLastResult({ + success: remoteResult.success, + action: 'create', + message: remoteResult.message + }); + + if (!remoteResult.success) { + process.exit(1); + } + } catch (error) { + ui.displayErrorBox(`Failed to create brief: ${(error as Error).message}`); + this.setLastResult({ + success: false, + action: 'create', + message: (error as Error).message + }); + process.exit(1); + } + } + + /** + * Set the last result for programmatic access + */ + private setLastResult(result: BriefsResult): void { + this.lastResult = result; + } + + /** + * Get the last result (for programmatic usage) + */ + getLastResult(): BriefsResult | undefined { + return this.lastResult; + } + + /** + * Register this command on an existing program + */ + static register(program: Command, name?: string): BriefsCommand { + const briefsCommand = new BriefsCommand(name); + program.addCommand(briefsCommand); + return briefsCommand; + } +} diff --git a/apps/cli/src/commands/context.command.ts b/apps/cli/src/commands/context.command.ts index 92498748..316b0a8e 100644 --- a/apps/cli/src/commands/context.command.ts +++ b/apps/cli/src/commands/context.command.ts @@ -6,11 +6,20 @@ import { Command } from 'commander'; import chalk from 'chalk'; import inquirer from 'inquirer'; -import search from '@inquirer/search'; -import ora, { Ora } from 'ora'; -import { AuthManager, type UserContext } from '@tm/core'; +import ora from 'ora'; +import { + AuthManager, + createTmCore, + type UserContext, + type TmCore +} from '@tm/core'; import * as ui from '../utils/ui.js'; -import { displayError } from '../utils/error-handler.js'; +import { checkAuthentication } from '../utils/auth-helpers.js'; +import { getBriefStatusWithColor } from '../ui/formatters/status-formatters.js'; +import { + selectBriefInteractive, + selectBriefFromInput +} from '../utils/brief-selection.js'; /** * Result type from context command @@ -28,6 +37,7 @@ export interface ContextResult { */ export class ContextCommand extends Command { private authManager: AuthManager; + private tmCore?: TmCore; private lastResult?: ContextResult; constructor(name?: string) { @@ -116,7 +126,8 @@ export class ContextCommand extends Command { const result = await this.displayContext(); this.setLastResult(result); } catch (error: any) { - displayError(error); + ui.displayError(`Failed to show context: ${(error as Error).message}`); + process.exit(1); } } @@ -125,11 +136,12 @@ export class ContextCommand extends Command { */ private async displayContext(): Promise { // Check authentication first - const hasSession = await this.authManager.hasValidSession(); - if (!hasSession) { - console.log(chalk.yellow('✗ Not authenticated')); - console.log(chalk.gray('\n Run "tm auth login" to authenticate first')); + const isAuthenticated = await checkAuthentication(this.authManager, { + message: + 'The "context" command requires you to be logged in to your Hamster account.' + }); + if (!isAuthenticated) { return { success: false, action: 'show', @@ -155,7 +167,7 @@ export class ContextCommand extends Command { if (context.briefName || context.briefId) { console.log(chalk.green('\n✓ Brief')); if (context.briefName && context.briefId) { - const shortId = context.briefId.slice(0, 8); + const shortId = context.briefId.slice(-8); console.log( chalk.white(` ${context.briefName} `) + chalk.gray(`(${shortId})`) ); @@ -164,6 +176,26 @@ export class ContextCommand extends Command { } else if (context.briefId) { console.log(chalk.gray(` ID: ${context.briefId}`)); } + + // Show brief status if available + if (context.briefStatus) { + const statusDisplay = getBriefStatusWithColor(context.briefStatus); + console.log(chalk.gray(` Status: `) + statusDisplay); + } + + // Show brief updated date if available + if (context.briefUpdatedAt) { + const updatedDate = new Date( + context.briefUpdatedAt + ).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + console.log(chalk.gray(` Updated: ${updatedDate}`)); + } } if (context.updatedAt) { @@ -201,9 +233,7 @@ export class ContextCommand extends Command { private async executeSelectOrg(): Promise { try { // Check authentication - const hasSession = await this.authManager.hasValidSession(); - if (!hasSession) { - ui.displayError('Not authenticated. Run "tm auth login" first.'); + if (!(await checkAuthentication(this.authManager))) { process.exit(1); } @@ -214,7 +244,10 @@ export class ContextCommand extends Command { process.exit(1); } } catch (error: any) { - displayError(error); + ui.displayError( + `Failed to select organization: ${(error as Error).message}` + ); + process.exit(1); } } @@ -281,9 +314,7 @@ export class ContextCommand extends Command { private async executeSelectBrief(): Promise { try { // Check authentication - const hasSession = await this.authManager.hasValidSession(); - if (!hasSession) { - ui.displayError('Not authenticated. Run "tm auth login" first.'); + if (!(await checkAuthentication(this.authManager))) { process.exit(1); } @@ -296,116 +327,25 @@ export class ContextCommand extends Command { process.exit(1); } - const result = await this.selectBrief(context.orgId); - this.setLastResult(result); + // Use shared utility for interactive selection + const result = await selectBriefInteractive( + this.authManager, + context.orgId + ); + + this.setLastResult({ + success: result.success, + action: 'select-brief', + context: this.authManager.getContext() || undefined, + message: result.message + }); if (!result.success) { process.exit(1); } } catch (error: any) { - displayError(error); - } - } - - /** - * Select a brief within the current organization - */ - private async selectBrief(orgId: string): Promise { - const spinner = ora('Fetching briefs...').start(); - - try { - // Fetch briefs from API - const briefs = await this.authManager.getBriefs(orgId); - spinner.stop(); - - if (briefs.length === 0) { - ui.displayWarning('No briefs available in this organization'); - return { - success: false, - action: 'select-brief', - message: 'No briefs available' - }; - } - - // Prompt for selection with search - const selectedBrief = await search<(typeof briefs)[0] | null>({ - message: 'Search for a brief:', - source: async (input) => { - const searchTerm = input?.toLowerCase() || ''; - - // Static option for no brief - const noBriefOption = { - name: '(No brief - organization level)', - value: null as any, - description: 'Clear brief selection' - }; - - // Filter and map brief options - const briefOptions = briefs - .filter((brief) => { - if (!searchTerm) return true; - - const title = brief.document?.title || ''; - const shortId = brief.id.slice(0, 8); - - // Search by title first, then by UUID - return ( - title.toLowerCase().includes(searchTerm) || - brief.id.toLowerCase().includes(searchTerm) || - shortId.toLowerCase().includes(searchTerm) - ); - }) - .map((brief) => { - const title = - brief.document?.title || `Brief ${brief.id.slice(0, 8)}`; - const shortId = brief.id.slice(0, 8); - return { - name: `${title} ${chalk.gray(`(${shortId})`)}`, - value: brief - }; - }); - - return [noBriefOption, ...briefOptions]; - } - }); - - if (selectedBrief) { - // Update context with brief - const briefName = - selectedBrief.document?.title || - `Brief ${selectedBrief.id.slice(0, 8)}`; - await this.authManager.updateContext({ - briefId: selectedBrief.id, - briefName: briefName - }); - - ui.displaySuccess(`Selected brief: ${briefName}`); - - return { - success: true, - action: 'select-brief', - context: this.authManager.getContext() || undefined, - message: `Selected brief: ${selectedBrief.document?.title}` - }; - } else { - // Clear brief selection - await this.authManager.updateContext({ - briefId: undefined, - briefName: undefined - }); - - ui.displaySuccess('Cleared brief selection (organization level)'); - - return { - success: true, - action: 'select-brief', - context: this.authManager.getContext() || undefined, - message: 'Cleared brief selection' - }; - } - } catch (error) { - spinner.fail('Failed to fetch briefs'); - throw error; + ui.displayError(`Failed to select brief: ${(error as Error).message}`); + process.exit(1); } } @@ -415,9 +355,7 @@ export class ContextCommand extends Command { private async executeClear(): Promise { try { // Check authentication - const hasSession = await this.authManager.hasValidSession(); - if (!hasSession) { - ui.displayError('Not authenticated. Run "tm auth login" first.'); + if (!(await checkAuthentication(this.authManager))) { process.exit(1); } @@ -428,7 +366,8 @@ export class ContextCommand extends Command { process.exit(1); } } catch (error: any) { - displayError(error); + ui.displayError(`Failed to clear context: ${(error as Error).message}`); + process.exit(1); } } @@ -462,9 +401,7 @@ export class ContextCommand extends Command { private async executeSet(options: any): Promise { try { // Check authentication - const hasSession = await this.authManager.hasValidSession(); - if (!hasSession) { - ui.displayError('Not authenticated. Run "tm auth login" first.'); + if (!(await checkAuthentication(this.authManager))) { process.exit(1); } @@ -475,150 +412,61 @@ export class ContextCommand extends Command { process.exit(1); } } catch (error: any) { - displayError(error); + ui.displayError(`Failed to set context: ${(error as Error).message}`); + process.exit(1); + } + } + + /** + * Initialize TmCore if not already initialized + */ + private async initTmCore(): Promise { + if (!this.tmCore) { + this.tmCore = await createTmCore({ + projectPath: process.cwd() + }); } } /** * Execute setting context from a brief ID or Hamster URL + * All parsing logic is in tm-core */ - private async executeSetFromBriefInput(briefOrUrl: string): Promise { - let spinner: Ora | undefined; + private async executeSetFromBriefInput(input: string): Promise { try { // Check authentication - const hasSession = await this.authManager.hasValidSession(); - if (!hasSession) { - ui.displayError('Not authenticated. Run "tm auth login" first.'); + if (!(await checkAuthentication(this.authManager))) { process.exit(1); } - spinner = ora('Resolving brief...'); - spinner.start(); + // Initialize tmCore to access business logic + await this.initTmCore(); - // Extract brief ID - const briefId = this.extractBriefId(briefOrUrl); - if (!briefId) { - spinner.fail('Could not extract a brief ID from the provided input'); - ui.displayError( - `Provide a valid brief ID or a Hamster brief URL, e.g. https://${process.env.TM_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN}/home/hamster/briefs/` - ); - process.exit(1); - } - - // Fetch brief and resolve its organization - const brief = await this.authManager.getBrief(briefId); - if (!brief) { - spinner.fail('Brief not found or you do not have access'); - process.exit(1); - } - - // Fetch org to get a friendly name and slug (optional) - let orgName: string | undefined; - let orgSlug: string | undefined; - try { - const org = await this.authManager.getOrganization(brief.accountId); - orgName = org?.name; - orgSlug = org?.slug; - } catch { - // Non-fatal if org lookup fails - } - - // Update context: set org and brief - const briefName = - brief.document?.title || `Brief ${brief.id.slice(0, 8)}`; - await this.authManager.updateContext({ - orgId: brief.accountId, - orgName, - orgSlug, - briefId: brief.id, - briefName - }); - - spinner.succeed('Context set from brief'); - console.log( - chalk.gray( - ` Organization: ${orgName || brief.accountId}\n Brief: ${briefName}` - ) + // Use shared utility - tm-core handles ALL parsing + const result = await selectBriefFromInput( + this.authManager, + input, + this.tmCore ); this.setLastResult({ - success: true, + success: result.success, action: 'set', context: this.authManager.getContext() || undefined, - message: 'Context set from brief' + message: result.message }); + + if (!result.success) { + process.exit(1); + } } catch (error: any) { - try { - if (spinner?.isSpinning) spinner.stop(); - } catch {} - displayError(error); + ui.displayError( + `Failed to set context from brief: ${(error as Error).message}` + ); + process.exit(1); } } - /** - * Extract a brief ID from raw input (ID or Hamster URL) - */ - private extractBriefId(input: string): string | null { - const raw = input?.trim() ?? ''; - if (!raw) return null; - - const parseUrl = (s: string): URL | null => { - try { - return new URL(s); - } catch {} - try { - return new URL(`https://${s}`); - } catch {} - return null; - }; - - const fromParts = (path: string): string | null => { - const parts = path.split('/').filter(Boolean); - const briefsIdx = parts.lastIndexOf('briefs'); - const candidate = - briefsIdx >= 0 && parts.length > briefsIdx + 1 - ? parts[briefsIdx + 1] - : parts[parts.length - 1]; - return candidate?.trim() || null; - }; - - // 1) URL (absolute or scheme‑less) - const url = parseUrl(raw); - if (url) { - const qId = url.searchParams.get('id') || url.searchParams.get('briefId'); - const candidate = (qId || fromParts(url.pathname)) ?? null; - if (candidate) { - // Light sanity check; let API be the final validator - if (this.isLikelyId(candidate) || candidate.length >= 8) - return candidate; - } - } - - // 2) Looks like a path without scheme - if (raw.includes('/')) { - const candidate = fromParts(raw); - if (candidate && (this.isLikelyId(candidate) || candidate.length >= 8)) { - return candidate; - } - } - - // 3) Fallback: raw token - return raw; - } - - /** - * Heuristic to check if a string looks like a brief ID (UUID-like) - */ - private isLikelyId(value: string): boolean { - const uuidRegex = - /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; - const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/i; // ULID - const slugRegex = /^[A-Za-z0-9_-]{16,}$/; // general token - return ( - uuidRegex.test(value) || ulidRegex.test(value) || slugRegex.test(value) - ); - } - /** * Set context directly from options */ @@ -731,8 +579,11 @@ export class ContextCommand extends Command { return { success: false, orgSelected: false, briefSelected: false }; } - // Select brief - const briefResult = await this.selectBrief(orgResult.context.orgId); + // Select brief using shared utility + const briefResult = await selectBriefInteractive( + this.authManager, + orgResult.context.orgId + ); return { success: true, orgSelected: true, diff --git a/apps/cli/src/commands/export.command.ts b/apps/cli/src/commands/export.command.ts index d03ebea4..1e0ef794 100644 --- a/apps/cli/src/commands/export.command.ts +++ b/apps/cli/src/commands/export.command.ts @@ -3,19 +3,19 @@ * Provides functionality to export tasks to Hamster briefs */ -import { Command } from 'commander'; -import chalk from 'chalk'; -import inquirer from 'inquirer'; -import ora, { Ora } from 'ora'; import { AuthManager, - type UserContext, type ExportResult, - createTmCore, - type TmCore + type TmCore, + type UserContext, + createTmCore } from '@tm/core'; -import * as ui from '../utils/ui.js'; +import chalk from 'chalk'; +import { Command } from 'commander'; +import inquirer from 'inquirer'; +import ora, { Ora } from 'ora'; import { displayError } from '../utils/error-handler.js'; +import * as ui from '../utils/ui.js'; import { getProjectRoot } from '../utils/project-root.js'; /** diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index 4ae70dfb..fc1ecfdd 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -3,34 +3,34 @@ * Extends Commander.Command for better integration with the framework */ -import { Command } from 'commander'; -import chalk from 'chalk'; import { - createTmCore, + OUTPUT_FORMATS, + type OutputFormat, + STATUS_ICONS, + TASK_STATUSES, type Task, type TaskStatus, type TmCore, - TASK_STATUSES, - OUTPUT_FORMATS, - STATUS_ICONS, - type OutputFormat + createTmCore } from '@tm/core'; import type { StorageType } from '@tm/core'; -import * as ui from '../utils/ui.js'; -import { displayError } from '../utils/error-handler.js'; -import { displayCommandHeader } from '../utils/display-helpers.js'; -import { getProjectRoot } from '../utils/project-root.js'; +import chalk from 'chalk'; +import { Command } from 'commander'; import { - displayDashboards, - calculateTaskStatistics, - calculateSubtaskStatistics, + type NextTaskInfo, calculateDependencyStatistics, - getPriorityBreakdown, + calculateSubtaskStatistics, + calculateTaskStatistics, + displayDashboards, displayRecommendedNextTask, - getTaskDescription, displaySuggestedNextSteps, - type NextTaskInfo + getPriorityBreakdown, + getTaskDescription } from '../ui/index.js'; +import { displayCommandHeader } from '../utils/display-helpers.js'; +import { displayError } from '../utils/error-handler.js'; +import { getProjectRoot } from '../utils/project-root.js'; +import * as ui from '../utils/ui.js'; /** * Options interface for the list command diff --git a/apps/cli/src/commands/models/custom-providers.ts b/apps/cli/src/commands/models/custom-providers.ts index a94a95c8..ff29a6da 100644 --- a/apps/cli/src/commands/models/custom-providers.ts +++ b/apps/cli/src/commands/models/custom-providers.ts @@ -2,16 +2,16 @@ * @fileoverview Custom provider handlers for model setup */ +import { CUSTOM_PROVIDERS } from '@tm/core'; import chalk from 'chalk'; import inquirer from 'inquirer'; -import { CUSTOM_PROVIDERS } from '@tm/core'; +import { validateOllamaModel, validateOpenRouterModel } from './fetchers.js'; +import { CUSTOM_PROVIDER_IDS } from './types.js'; import type { CustomProviderConfig, CustomProviderId, - CUSTOM_PROVIDER_IDS, ModelRole } from './types.js'; -import { validateOpenRouterModel, validateOllamaModel } from './fetchers.js'; /** * Configuration for all custom providers diff --git a/apps/cli/src/commands/models/fetchers.ts b/apps/cli/src/commands/models/fetchers.ts index 70d15410..3e3e6331 100644 --- a/apps/cli/src/commands/models/fetchers.ts +++ b/apps/cli/src/commands/models/fetchers.ts @@ -2,9 +2,9 @@ * @fileoverview Model fetching utilities for OpenRouter, Ollama, and other providers */ -import https from 'https'; import http from 'http'; -import type { FetchResult, OpenRouterModel, OllamaModel } from './types.js'; +import https from 'https'; +import type { FetchResult, OllamaModel, OpenRouterModel } from './types.js'; /** * Fetch available models from OpenRouter API diff --git a/apps/cli/src/commands/models/prompts.ts b/apps/cli/src/commands/models/prompts.ts index be4cef3f..ecacd017 100644 --- a/apps/cli/src/commands/models/prompts.ts +++ b/apps/cli/src/commands/models/prompts.ts @@ -2,17 +2,17 @@ * @fileoverview Interactive prompt logic for model selection */ -import chalk from 'chalk'; import search, { Separator } from '@inquirer/search'; +import chalk from 'chalk'; import { getAvailableModels } from '../../lib/model-management.js'; -import type { - ModelRole, - ModelInfo, - CurrentModels, - PromptData, - ModelChoice -} from './types.js'; import { getCustomProviderOptions } from './custom-providers.js'; +import type { + CurrentModels, + ModelChoice, + ModelInfo, + ModelRole, + PromptData +} from './types.js'; /** * Build prompt choices for a specific role diff --git a/apps/cli/src/commands/models/setup.ts b/apps/cli/src/commands/models/setup.ts index 64499fa5..2ff68f73 100644 --- a/apps/cli/src/commands/models/setup.ts +++ b/apps/cli/src/commands/models/setup.ts @@ -4,21 +4,21 @@ import chalk from 'chalk'; import { + getConfig, getModelConfiguration, setModel, - getConfig, writeConfig } from '../../lib/model-management.js'; -import type { ModelRole, CurrentModels, CustomProviderId } from './types.js'; +import { + customProviderConfigs, + handleCustomProvider +} from './custom-providers.js'; import { buildPromptChoices, displaySetupIntro, promptForModel } from './prompts.js'; -import { - handleCustomProvider, - customProviderConfigs -} from './custom-providers.js'; +import type { CurrentModels, CustomProviderId, ModelRole } from './types.js'; /** * Check if a value is a custom provider ID diff --git a/apps/cli/src/commands/next.command.ts b/apps/cli/src/commands/next.command.ts index 1c0a1309..b055d583 100644 --- a/apps/cli/src/commands/next.command.ts +++ b/apps/cli/src/commands/next.command.ts @@ -4,14 +4,14 @@ */ import path from 'node:path'; -import { Command } from 'commander'; -import chalk from 'chalk'; -import boxen from 'boxen'; -import { createTmCore, type Task, type TmCore } from '@tm/core'; +import { type Task, type TmCore, createTmCore } from '@tm/core'; import type { StorageType } from '@tm/core'; -import { displayError } from '../utils/error-handler.js'; +import boxen from 'boxen'; +import chalk from 'chalk'; +import { Command } from 'commander'; import { displayTaskDetails } from '../ui/components/task-detail.component.js'; import { displayCommandHeader } from '../utils/display-helpers.js'; +import { displayError } from '../utils/error-handler.js'; import { getProjectRoot } from '../utils/project-root.js'; /** diff --git a/apps/cli/src/commands/set-status.command.ts b/apps/cli/src/commands/set-status.command.ts index a5a0b307..8c1f08d2 100644 --- a/apps/cli/src/commands/set-status.command.ts +++ b/apps/cli/src/commands/set-status.command.ts @@ -3,11 +3,11 @@ * Extends Commander.Command for better integration with the framework */ -import { Command } from 'commander'; -import chalk from 'chalk'; -import boxen from 'boxen'; -import { createTmCore, type TmCore, type TaskStatus } from '@tm/core'; +import { type TaskStatus, type TmCore, createTmCore } from '@tm/core'; import type { StorageType } from '@tm/core'; +import boxen from 'boxen'; +import chalk from 'chalk'; +import { Command } from 'commander'; import { displayError } from '../utils/error-handler.js'; import { getProjectRoot } from '../utils/project-root.js'; diff --git a/apps/cli/src/commands/show.command.ts b/apps/cli/src/commands/show.command.ts index ef2f69f3..bd3332c1 100644 --- a/apps/cli/src/commands/show.command.ts +++ b/apps/cli/src/commands/show.command.ts @@ -3,15 +3,15 @@ * Extends Commander.Command for better integration with the framework */ -import { Command } from 'commander'; -import chalk from 'chalk'; -import boxen from 'boxen'; -import { createTmCore, type Task, type TmCore } from '@tm/core'; +import { type Task, type TmCore, createTmCore } from '@tm/core'; import type { StorageType, Subtask } from '@tm/core'; -import * as ui from '../utils/ui.js'; -import { displayError } from '../utils/error-handler.js'; +import boxen from 'boxen'; +import chalk from 'chalk'; +import { Command } from 'commander'; import { displayTaskDetails } from '../ui/components/task-detail.component.js'; import { displayCommandHeader } from '../utils/display-helpers.js'; +import { displayError } from '../utils/error-handler.js'; +import * as ui from '../utils/ui.js'; import { getProjectRoot } from '../utils/project-root.js'; /** diff --git a/apps/cli/src/commands/start.command.ts b/apps/cli/src/commands/start.command.ts index ae620412..e85065d4 100644 --- a/apps/cli/src/commands/start.command.ts +++ b/apps/cli/src/commands/start.command.ts @@ -4,19 +4,19 @@ * This is a thin presentation layer over @tm/core's TaskExecutionService */ -import { Command } from 'commander'; -import chalk from 'chalk'; -import boxen from 'boxen'; -import ora, { type Ora } from 'ora'; import { spawn } from 'child_process'; import { - createTmCore, + type StartTaskResult as CoreStartTaskResult, type TmCore, - type StartTaskResult as CoreStartTaskResult + createTmCore } from '@tm/core'; +import boxen from 'boxen'; +import chalk from 'chalk'; +import { Command } from 'commander'; +import ora, { type Ora } from 'ora'; import { displayTaskDetails } from '../ui/components/task-detail.component.js'; -import * as ui from '../utils/ui.js'; import { displayError } from '../utils/error-handler.js'; +import * as ui from '../utils/ui.js'; import { getProjectRoot } from '../utils/project-root.js'; /** diff --git a/apps/cli/src/commands/tags.command.ts b/apps/cli/src/commands/tags.command.ts new file mode 100644 index 00000000..24078faf --- /dev/null +++ b/apps/cli/src/commands/tags.command.ts @@ -0,0 +1,545 @@ +/** + * @fileoverview Tags Command - Manage task organization with tags + * Provides tag/brief management with file and API storage support + */ + +import { Command } from 'commander'; +import type { TmCore } from '@tm/core'; +import { createTmCore, getProjectPaths } from '@tm/core'; +import { displayError } from '../utils/index.js'; + +/** + * TODO: TECH DEBT - Architectural Refactor Needed + * + * Current State: + * - This command imports legacy JS functions from scripts/modules/task-manager/tag-management.js + * - These functions contain business logic that violates architecture guidelines (see CLAUDE.md) + * + * Target State: + * - Move all business logic to TagService in @tm/core + * - CLI should only handle presentation (argument parsing, output formatting) + * - Remove dependency on legacy scripts/ directory + * + * Complexity: + * - Legacy functions handle both API and file storage via bridge pattern + * - Need to migrate API integration logic to @tm/core first + * - Affects MCP layer as well (should share same @tm/core APIs) + * + * Priority: Medium (improves testability, maintainability, and code reuse) + */ +import { + createTag as legacyCreateTag, + deleteTag as legacyDeleteTag, + tags as legacyListTags, + useTag as legacyUseTag, + renameTag as legacyRenameTag, + copyTag as legacyCopyTag +} from '../../../../scripts/modules/task-manager/tag-management.js'; + +/** + * Result type from tags command + */ +export interface TagsResult { + success: boolean; + action: 'list' | 'add' | 'use' | 'remove' | 'rename' | 'copy'; + tags?: any[]; + currentTag?: string | null; + message?: string; +} + +/** + * Legacy function return types + */ +interface LegacyListTagsResult { + tags: any[]; + currentTag: string | null; + totalTags: number; +} + +interface LegacyUseTagResult { + currentTag: string; +} + +interface LegacyCreateTagOptions { + description?: string; + copyFromTag?: string; + fromBranch?: boolean; +} + +/** + * TagsCommand - Manage tags/briefs for task organization + */ +export class TagsCommand extends Command { + private tmCore?: TmCore; + private lastResult?: TagsResult; + private throwOnError: boolean = false; + + constructor(name?: string) { + super(name || 'tags'); + + // Configure the command + this.description('Manage tags for task organization'); + + // Add subcommands + this.addListCommand(); + this.addAddCommand(); + this.addUseCommand(); + this.addRemoveCommand(); + this.addRenameCommand(); + this.addCopyCommand(); + + // Default action: list tags + this.action(async () => { + await this.executeList(); + }); + } + + /** + * Add list subcommand + */ + private addListCommand(): void { + this.command('list') + .description('List all tags with statistics (default action)') + .option('--show-metadata', 'Show additional tag metadata') + .addHelpText( + 'after', + ` +Examples: + $ tm tags # List all tags (default) + $ tm tags list # List all tags (explicit) + $ tm tags list --show-metadata # List with metadata +` + ) + .action(async (options) => { + await this.executeList(options); + }); + } + + /** + * Add add subcommand + */ + private addAddCommand(): void { + this.command('add') + .description('Create a new tag') + .argument('', 'Name of the tag to create') + .option('--description ', 'Tag description') + .option('--copy-from ', 'Copy tasks from another tag') + .option('--from-branch', 'Create tag from current git branch name') + .addHelpText( + 'after', + ` +Examples: + $ tm tags add feature-auth # Create new tag + $ tm tags add sprint-2 --copy-from sprint-1 # Create with tasks copied + $ tm tags add --from-branch # Create from current git branch + +Note: When using API storage, this will redirect you to the web UI to create a brief. +` + ) + .action(async (name, options) => { + await this.executeAdd(name, options); + }); + } + + /** + * Add use subcommand + */ + private addUseCommand(): void { + this.command('use') + .description('Switch to a different tag') + .argument('', 'Name or ID of the tag to switch to') + .addHelpText( + 'after', + ` +Examples: + $ tm tags use feature-auth # Switch by name + $ tm tags use abc123 # Switch by ID (last 8 chars) + +Note: For API storage, this switches the active brief in your context. +` + ) + .action(async (name) => { + await this.executeUse(name); + }); + } + + /** + * Add remove subcommand + */ + private addRemoveCommand(): void { + this.command('remove') + .description('Remove a tag') + .argument('', 'Name or ID of the tag to remove') + .option('-y, --yes', 'Skip confirmation prompt') + .addHelpText( + 'after', + ` +Examples: + $ tm tags remove old-feature # Remove tag with confirmation + $ tm tags remove old-feature -y # Remove without confirmation + +Warning: This will delete all tasks in the tag! +` + ) + .action(async (name, options) => { + await this.executeRemove(name, options); + }); + } + + /** + * Add rename subcommand + */ + private addRenameCommand(): void { + this.command('rename') + .description('Rename a tag') + .argument('', 'Current tag name') + .argument('', 'New tag name') + .addHelpText( + 'after', + ` +Examples: + $ tm tags rename old-name new-name +` + ) + .action(async (oldName, newName) => { + await this.executeRename(oldName, newName); + }); + } + + /** + * Add copy subcommand + */ + private addCopyCommand(): void { + this.command('copy') + .description('Copy a tag with all its tasks') + .argument('', 'Source tag name') + .argument('', 'Target tag name') + .option('--description ', 'Description for the new tag') + .addHelpText( + 'after', + ` +Examples: + $ tm tags copy sprint-1 sprint-2 + $ tm tags copy sprint-1 sprint-2 --description "Next sprint tasks" +` + ) + .action(async (source, target, options) => { + await this.executeCopy(source, target, options); + }); + } + + /** + * Initialize TmCore if not already initialized + * Required for bridge functions to work properly + */ + private async initTmCore(): Promise { + if (!this.tmCore) { + this.tmCore = await createTmCore({ + projectPath: process.cwd() + }); + } + } + + /** + * Execute list tags + */ + private async executeList(options?: { + showMetadata?: boolean; + }): Promise { + try { + // Initialize tmCore first (needed by bridge functions) + await this.initTmCore(); + + const { projectRoot, tasksPath } = getProjectPaths(); + + // Use legacy function which handles both API and file storage + const listResult = (await legacyListTags( + tasksPath, + { + showTaskCounts: true, + showMetadata: options?.showMetadata || false + }, + { projectRoot }, + 'text' + )) as LegacyListTagsResult; + + this.setLastResult({ + success: true, + action: 'list', + tags: listResult.tags, + currentTag: listResult.currentTag, + message: `Found ${listResult.totalTags} tag(s)` + }); + } catch (error: any) { + displayError(error); + this.setLastResult({ + success: false, + action: 'list', + message: error.message + }); + this.handleError( + error instanceof Error + ? error + : new Error(error.message || String(error)) + ); + } + } + + /** + * Execute add tag + */ + private async executeAdd( + name: string, + options?: { + description?: string; + copyFrom?: string; + fromBranch?: boolean; + } + ): Promise { + try { + // Initialize tmCore first (needed by bridge functions) + await this.initTmCore(); + + const { projectRoot, tasksPath } = getProjectPaths(); + + // Use legacy function which handles both API and file storage + await legacyCreateTag( + tasksPath, + name, + { + description: options?.description, + copyFromTag: options?.copyFrom, + fromBranch: options?.fromBranch + } as LegacyCreateTagOptions, + { projectRoot }, + 'text' + ); + + this.setLastResult({ + success: true, + action: 'add', + message: `Created tag: ${name}` + }); + } catch (error: any) { + displayError(error); + this.setLastResult({ + success: false, + action: 'add', + message: error.message + }); + this.handleError( + error instanceof Error + ? error + : new Error(error.message || String(error)) + ); + } + } + + /** + * Execute use/switch tag + */ + private async executeUse(name: string): Promise { + try { + // Initialize tmCore first (needed by bridge functions) + await this.initTmCore(); + + const { projectRoot, tasksPath } = getProjectPaths(); + + // Use legacy function which handles both API and file storage + const useResult = (await legacyUseTag( + tasksPath, + name, + {}, + { projectRoot }, + 'text' + )) as LegacyUseTagResult; + + this.setLastResult({ + success: true, + action: 'use', + currentTag: useResult.currentTag, + message: `Switched to tag: ${name}` + }); + } catch (error: any) { + displayError(error); + this.setLastResult({ + success: false, + action: 'use', + message: error.message + }); + this.handleError( + error instanceof Error + ? error + : new Error(error.message || String(error)) + ); + } + } + + /** + * Execute remove tag + */ + private async executeRemove( + name: string, + options?: { yes?: boolean } + ): Promise { + try { + // Initialize tmCore first (needed by bridge functions) + await this.initTmCore(); + + const { projectRoot, tasksPath } = getProjectPaths(); + + // Use legacy function which handles both API and file storage + await legacyDeleteTag( + tasksPath, + name, + { yes: options?.yes || false }, + { projectRoot }, + 'text' + ); + + this.setLastResult({ + success: true, + action: 'remove', + message: `Removed tag: ${name}` + }); + } catch (error: any) { + displayError(error); + this.setLastResult({ + success: false, + action: 'remove', + message: error.message + }); + this.handleError( + error instanceof Error + ? error + : new Error(error.message || String(error)) + ); + } + } + + /** + * Execute rename tag + */ + private async executeRename(oldName: string, newName: string): Promise { + try { + // Initialize tmCore first (needed by bridge functions) + await this.initTmCore(); + + const { projectRoot, tasksPath } = getProjectPaths(); + + // Use legacy function which handles both API and file storage + await legacyRenameTag( + tasksPath, + oldName, + newName, + {}, + { projectRoot }, + 'text' + ); + + this.setLastResult({ + success: true, + action: 'rename', + message: `Renamed tag from "${oldName}" to "${newName}"` + }); + } catch (error: any) { + displayError(error); + this.setLastResult({ + success: false, + action: 'rename', + message: error.message + }); + this.handleError( + error instanceof Error + ? error + : new Error(error.message || String(error)) + ); + } + } + + /** + * Execute copy tag + */ + private async executeCopy( + source: string, + target: string, + options?: { description?: string } + ): Promise { + try { + // Initialize tmCore first (needed by bridge functions) + await this.initTmCore(); + + const { projectRoot, tasksPath } = getProjectPaths(); + + // Use legacy function which handles both API and file storage + await legacyCopyTag( + tasksPath, + source, + target, + { description: options?.description }, + { projectRoot }, + 'text' + ); + + this.setLastResult({ + success: true, + action: 'copy', + message: `Copied tag from "${source}" to "${target}"` + }); + } catch (error: any) { + displayError(error); + this.setLastResult({ + success: false, + action: 'copy', + message: error.message + }); + this.handleError( + error instanceof Error + ? error + : new Error(error.message || String(error)) + ); + } + } + + /** + * Set the last result for programmatic access + */ + private setLastResult(result: TagsResult): void { + this.lastResult = result; + } + + /** + * Get the last result (for programmatic usage) + */ + getLastResult(): TagsResult | undefined { + return this.lastResult; + } + + /** + * Enable throwing errors instead of process.exit for programmatic usage + * @param shouldThrow If true, throws errors; if false, calls process.exit (default) + */ + public setThrowOnError(shouldThrow: boolean): this { + this.throwOnError = shouldThrow; + return this; + } + + /** + * Handle error by either exiting or throwing based on throwOnError flag + */ + private handleError(error: Error): never { + if (this.throwOnError) { + throw error; + } + process.exit(1); + } + + /** + * Register this command on an existing program + */ + static register(program: Command, name?: string): TagsCommand { + const tagsCommand = new TagsCommand(name); + program.addCommand(tagsCommand); + return tagsCommand; + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 69ab38f7..e4ad3a8a 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -12,6 +12,8 @@ export { ContextCommand } from './commands/context.command.js'; export { StartCommand } from './commands/start.command.js'; export { SetStatusCommand } from './commands/set-status.command.js'; export { ExportCommand } from './commands/export.command.js'; +export { TagsCommand } from './commands/tags.command.js'; +export { BriefsCommand } from './commands/briefs.command.js'; // Command Registry export { @@ -21,20 +23,12 @@ export { type CommandMetadata } from './command-registry.js'; -// UI utilities (for other commands to use) -export * as ui from './utils/ui.js'; +// General utilities (error handling, auto-update, etc.) +export * from './utils/index.js'; -// Error handling utilities -export { displayError, isDebugMode } from './utils/error-handler.js'; - -// Auto-update utilities -export { - checkForUpdate, - performAutoUpdate, - displayUpgradeNotification, - compareVersions, - restartWithNewVersion -} from './utils/auto-update.js'; +// UI utilities - exported only via ui namespace to avoid naming conflicts +// Import via: import { ui } from '@tm/cli'; ui.displayBanner(); +export * as ui from './ui/index.js'; export { runInteractiveSetup } from './commands/models/index.js'; diff --git a/apps/cli/src/lib/model-management.ts b/apps/cli/src/lib/model-management.ts index 5ad6e730..429ead24 100644 --- a/apps/cli/src/lib/model-management.ts +++ b/apps/cli/src/lib/model-management.ts @@ -4,10 +4,10 @@ * Will remove once we move models.js and config-manager to new structure */ -// @ts-ignore - JavaScript module without types -import * as modelsJs from '../../../../scripts/modules/task-manager/models.js'; // @ts-ignore - JavaScript module without types import * as configManagerJs from '../../../../scripts/modules/config-manager.js'; +// @ts-ignore - JavaScript module without types +import * as modelsJs from '../../../../scripts/modules/task-manager/models.js'; // ========== Types ========== diff --git a/apps/cli/src/types/tag-management.d.ts b/apps/cli/src/types/tag-management.d.ts new file mode 100644 index 00000000..2954ac45 --- /dev/null +++ b/apps/cli/src/types/tag-management.d.ts @@ -0,0 +1,55 @@ +/** + * Type declarations for legacy tag-management.js + * TODO: Remove when refactored to use @tm/core + */ + +declare module '*/tag-management.js' { + export function createTag( + tasksPath: string, + tagName: string, + options?: any, + context?: any, + outputFormat?: string + ): Promise; + + export function deleteTag( + tasksPath: string, + tagName: string, + options?: any, + context?: any, + outputFormat?: string + ): Promise; + + export function tags( + tasksPath: string, + options?: any, + context?: any, + outputFormat?: string + ): Promise; + + export function useTag( + tasksPath: string, + tagName: string, + options?: any, + context?: any, + outputFormat?: string + ): Promise; + + export function renameTag( + tasksPath: string, + oldName: string, + newName: string, + options?: any, + context?: any, + outputFormat?: string + ): Promise; + + export function copyTag( + tasksPath: string, + sourceName: string, + targetName: string, + options?: any, + context?: any, + outputFormat?: string + ): Promise; +} diff --git a/apps/cli/src/ui/components/cardBox.component.ts b/apps/cli/src/ui/components/cardBox.component.ts new file mode 100644 index 00000000..6521e999 --- /dev/null +++ b/apps/cli/src/ui/components/cardBox.component.ts @@ -0,0 +1,60 @@ +import boxen from 'boxen'; +import chalk from 'chalk'; + +/** + * Configuration for the card box component + */ +export interface CardBoxConfig { + /** Header text displayed in yellow bold */ + header: string; + /** Body paragraphs displayed in white */ + body: string[]; + /** Call to action section with label and URL */ + callToAction: { + label: string; + action: string; + }; + /** Footer text displayed in gray (usage instructions) */ + footer?: string; +} + +/** + * Creates a formatted boxen card with header, body, call-to-action, and optional footer. + * A reusable component for displaying informational messages in a styled box. + * + * @param config - Configuration for the box sections + * @returns Formatted string ready for console.log + */ +export function displayCardBox(config: CardBoxConfig): string { + const { header, body, callToAction, footer } = config; + + // Build the content sections + const sections: string[] = [ + // Header + chalk.yellow.bold(header), + + // Body paragraphs + ...body.map((paragraph) => chalk.white(paragraph)), + + // Call to action + chalk.cyan(callToAction.label) + + '\n' + + chalk.blue.underline(callToAction.action) + ]; + + // Add footer if provided + if (footer) { + sections.push(chalk.gray(footer)); + } + + // Join sections with double newlines + const content = sections.join('\n\n'); + + // Wrap in boxen + return boxen(content, { + padding: 1, + borderColor: 'yellow', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + }); +} diff --git a/apps/cli/src/ui/components/dashboard.component.ts b/apps/cli/src/ui/components/dashboard.component.ts index e8c883d6..8b475ab8 100644 --- a/apps/cli/src/ui/components/dashboard.component.ts +++ b/apps/cli/src/ui/components/dashboard.component.ts @@ -3,9 +3,9 @@ * Displays project statistics and dependency information */ -import chalk from 'chalk'; -import boxen from 'boxen'; import type { Task, TaskPriority } from '@tm/core'; +import boxen from 'boxen'; +import chalk from 'chalk'; import { getComplexityWithColor } from '../../utils/ui.js'; /** diff --git a/apps/cli/src/ui/components/index.ts b/apps/cli/src/ui/components/index.ts index b5efbcda..4f26a623 100644 --- a/apps/cli/src/ui/components/index.ts +++ b/apps/cli/src/ui/components/index.ts @@ -2,8 +2,9 @@ * @fileoverview UI components exports */ -export * from './header.component.js'; +export * from './cardBox.component.js'; export * from './dashboard.component.js'; +export * from './header.component.js'; export * from './next-task.component.js'; export * from './suggested-steps.component.js'; export * from './task-detail.component.js'; diff --git a/apps/cli/src/ui/components/next-task.component.ts b/apps/cli/src/ui/components/next-task.component.ts index d1dfe4ed..126467cf 100644 --- a/apps/cli/src/ui/components/next-task.component.ts +++ b/apps/cli/src/ui/components/next-task.component.ts @@ -3,10 +3,10 @@ * Displays detailed information about the recommended next task */ -import chalk from 'chalk'; -import boxen from 'boxen'; import type { Task } from '@tm/core'; -import { getComplexityWithColor, getBoxWidth } from '../../utils/ui.js'; +import boxen from 'boxen'; +import chalk from 'chalk'; +import { getBoxWidth, getComplexityWithColor } from '../../utils/ui.js'; /** * Next task display options diff --git a/apps/cli/src/ui/components/suggested-steps.component.ts b/apps/cli/src/ui/components/suggested-steps.component.ts index c3b6d254..f428068a 100644 --- a/apps/cli/src/ui/components/suggested-steps.component.ts +++ b/apps/cli/src/ui/components/suggested-steps.component.ts @@ -3,8 +3,8 @@ * Displays helpful command suggestions at the end of the list */ -import chalk from 'chalk'; import boxen from 'boxen'; +import chalk from 'chalk'; import { getBoxWidth } from '../../utils/ui.js'; /** diff --git a/apps/cli/src/ui/components/task-detail.component.ts b/apps/cli/src/ui/components/task-detail.component.ts index 4710e932..4582fad2 100644 --- a/apps/cli/src/ui/components/task-detail.component.ts +++ b/apps/cli/src/ui/components/task-detail.component.ts @@ -3,16 +3,16 @@ * Displays detailed task information in a structured format */ -import chalk from 'chalk'; -import boxen from 'boxen'; -import Table from 'cli-table3'; -import { marked, MarkedExtension } from 'marked'; -import { markedTerminal } from 'marked-terminal'; import type { Subtask, Task } from '@tm/core'; +import boxen from 'boxen'; +import chalk from 'chalk'; +import Table from 'cli-table3'; +import { MarkedExtension, marked } from 'marked'; +import { markedTerminal } from 'marked-terminal'; import { - getStatusWithColor, + getComplexityWithColor, getPriorityWithColor, - getComplexityWithColor + getStatusWithColor } from '../../utils/ui.js'; // Configure marked to use terminal renderer with subtle colors diff --git a/apps/cli/src/ui/display/messages.ts b/apps/cli/src/ui/display/messages.ts new file mode 100644 index 00000000..cffd1674 --- /dev/null +++ b/apps/cli/src/ui/display/messages.ts @@ -0,0 +1,102 @@ +/** + * @fileoverview Display message utilities + * Provides formatted message boxes for errors, success, warnings, info, and banners + */ + +import boxen from 'boxen'; +import chalk from 'chalk'; +import { getBoxWidth } from '../layout/helpers.js'; + +/** + * Display a fancy banner + */ +export function displayBanner(title: string = 'Task Master'): void { + console.log( + boxen(chalk.white.bold(title), { + padding: 1, + margin: { top: 1, bottom: 1 }, + borderStyle: 'round', + borderColor: 'blue', + textAlignment: 'center' + }) + ); +} + +/** + * Display an error message in a boxed format (matches scripts/modules/ui.js style) + * Note: For general CLI error handling, use displayError from utils/error-handler.ts + * This function is for displaying formatted error messages as part of UI output. + */ +export function displayErrorBox(message: string, details?: string): void { + const boxWidth = getBoxWidth(); + + console.error( + boxen( + chalk.red.bold('X Error: ') + + chalk.white(message) + + (details ? '\n\n' + chalk.gray(details) : ''), + { + padding: 1, + borderStyle: 'round', + borderColor: 'red', + width: boxWidth + } + ) + ); +} + +/** + * Alias for displayErrorBox + */ +export const displayError = displayErrorBox; + +/** + * Display a success message + */ +export function displaySuccess(message: string): void { + const boxWidth = getBoxWidth(); + + console.log( + boxen( + chalk.green.bold(String.fromCharCode(8730) + ' ') + chalk.white(message), + { + padding: 1, + borderStyle: 'round', + borderColor: 'green', + width: boxWidth + } + ) + ); +} + +/** + * Display a warning message + */ +export function displayWarning(message: string): void { + const boxWidth = getBoxWidth(); + + console.log( + boxen(chalk.yellow.bold('⚠️ ') + chalk.white(message), { + padding: 1, + borderStyle: 'round', + borderColor: 'yellow', + width: boxWidth + }) + ); +} + +/** + * Display info message + */ +export function displayInfo(message: string): void { + const boxWidth = getBoxWidth(); + + console.log( + boxen(chalk.blue.bold('i ') + chalk.white(message), { + padding: 1, + borderStyle: 'round', + borderColor: 'blue', + width: boxWidth + }) + ); +} diff --git a/apps/cli/src/ui/display/tables.ts b/apps/cli/src/ui/display/tables.ts new file mode 100644 index 00000000..982232a4 --- /dev/null +++ b/apps/cli/src/ui/display/tables.ts @@ -0,0 +1,145 @@ +/** + * @fileoverview Table display utilities + * Provides table creation and formatting for tasks + */ + +import type { Subtask, Task, TaskPriority } from '@tm/core'; +import chalk from 'chalk'; +import Table from 'cli-table3'; +import { getComplexityWithColor } from '../formatters/complexity-formatters.js'; +import { getPriorityWithColor } from '../formatters/priority-formatters.js'; +import { getStatusWithColor } from '../formatters/status-formatters.js'; +import { getBoxWidth, truncate } from '../layout/helpers.js'; + +/** + * Default priority for tasks/subtasks when not specified + */ +const DEFAULT_PRIORITY: TaskPriority = 'medium'; + +/** + * Create a task table for display + */ +export function createTaskTable( + tasks: (Task | Subtask)[], + options?: { + showSubtasks?: boolean; + showComplexity?: boolean; + showDependencies?: boolean; + } +): string { + const { + showSubtasks = false, + showComplexity = false, + showDependencies = true + } = options || {}; + + // Calculate dynamic column widths based on terminal width + const tableWidth = getBoxWidth(0.9, 100); + // Adjust column widths to better match the original layout + const baseColWidths = showComplexity + ? [ + Math.floor(tableWidth * 0.1), + Math.floor(tableWidth * 0.4), + Math.floor(tableWidth * 0.15), + Math.floor(tableWidth * 0.1), + Math.floor(tableWidth * 0.2), + Math.floor(tableWidth * 0.1) + ] // ID, Title, Status, Priority, Dependencies, Complexity + : [ + Math.floor(tableWidth * 0.08), + Math.floor(tableWidth * 0.4), + Math.floor(tableWidth * 0.18), + Math.floor(tableWidth * 0.12), + Math.floor(tableWidth * 0.2) + ]; // ID, Title, Status, Priority, Dependencies + + const headers = [ + chalk.blue.bold('ID'), + chalk.blue.bold('Title'), + chalk.blue.bold('Status'), + chalk.blue.bold('Priority') + ]; + const colWidths = baseColWidths.slice(0, 4); + + if (showDependencies) { + headers.push(chalk.blue.bold('Dependencies')); + colWidths.push(baseColWidths[4]); + } + + if (showComplexity) { + headers.push(chalk.blue.bold('Complexity')); + colWidths.push(baseColWidths[5] || 12); + } + + const table = new Table({ + head: headers, + style: { head: [], border: [] }, + colWidths, + wordWrap: true + }); + + tasks.forEach((task) => { + const row: string[] = [ + chalk.cyan(task.id.toString()), + truncate(task.title, colWidths[1] - 3), + getStatusWithColor(task.status, true), // Use table version + getPriorityWithColor(task.priority) + ]; + + if (showDependencies) { + // For table display, show simple format without status icons + if (!task.dependencies || task.dependencies.length === 0) { + row.push(chalk.gray('None')); + } else { + row.push( + chalk.cyan(task.dependencies.map((d) => String(d)).join(', ')) + ); + } + } + + if (showComplexity) { + // Show complexity score from report if available + if (typeof task.complexity === 'number') { + row.push(getComplexityWithColor(task.complexity)); + } else { + row.push(chalk.gray('N/A')); + } + } + + table.push(row); + + // Add subtasks if requested + if (showSubtasks && task.subtasks && task.subtasks.length > 0) { + task.subtasks.forEach((subtask) => { + const subRow: string[] = [ + chalk.gray(` └─ ${subtask.id}`), + chalk.gray(truncate(subtask.title, colWidths[1] - 6)), + chalk.gray(getStatusWithColor(subtask.status, true)), + chalk.gray(subtask.priority || DEFAULT_PRIORITY) + ]; + + if (showDependencies) { + subRow.push( + chalk.gray( + subtask.dependencies && subtask.dependencies.length > 0 + ? subtask.dependencies.map((dep) => String(dep)).join(', ') + : 'None' + ) + ); + } + + if (showComplexity) { + const complexityDisplay = + typeof subtask.complexity === 'number' + ? getComplexityWithColor(subtask.complexity) + : '--'; + subRow.push(chalk.gray(complexityDisplay)); + } + + table.push(subRow); + }); + } + }); + + return table.toString(); +} diff --git a/apps/cli/src/ui/formatters/complexity-formatters.ts b/apps/cli/src/ui/formatters/complexity-formatters.ts new file mode 100644 index 00000000..a837c3a1 --- /dev/null +++ b/apps/cli/src/ui/formatters/complexity-formatters.ts @@ -0,0 +1,49 @@ +/** + * @fileoverview Complexity formatting utilities + * Provides colored complexity displays with labels and scores + */ + +import chalk from 'chalk'; + +/** + * Get complexity color and label based on score thresholds + */ +function getComplexityLevel(score: number): { + color: (text: string) => string; + label: string; +} { + if (score >= 7) { + return { color: chalk.hex('#CC0000'), label: 'High' }; + } else if (score >= 4) { + return { color: chalk.hex('#FF8800'), label: 'Medium' }; + } else { + return { color: chalk.green, label: 'Low' }; + } +} + +/** + * Get colored complexity display with dot indicator (simple format) + */ +export function getComplexityWithColor(complexity: number | string): string { + const score = + typeof complexity === 'string' ? Number(complexity.trim()) : complexity; + + if (isNaN(score)) { + return chalk.gray('N/A'); + } + + const { color } = getComplexityLevel(score); + return color(`● ${score}`); +} + +/** + * Get colored complexity display with /10 format (for dashboards) + */ +export function getComplexityWithScore(complexity: number | undefined): string { + if (typeof complexity !== 'number') { + return chalk.gray('N/A'); + } + + const { color, label } = getComplexityLevel(complexity); + return color(`${complexity}/10 (${label})`); +} diff --git a/apps/cli/src/ui/formatters/dependency-formatters.ts b/apps/cli/src/ui/formatters/dependency-formatters.ts new file mode 100644 index 00000000..2e0b7a71 --- /dev/null +++ b/apps/cli/src/ui/formatters/dependency-formatters.ts @@ -0,0 +1,39 @@ +/** + * @fileoverview Dependency formatting utilities + * Provides formatted dependency displays with status indicators + */ + +import type { Task } from '@tm/core'; +import chalk from 'chalk'; + +/** + * Format dependencies with their status + */ +export function formatDependenciesWithStatus( + dependencies: string[] | number[], + tasks: Task[] +): string { + if (!dependencies || dependencies.length === 0) { + return chalk.gray('none'); + } + + const taskMap = new Map(tasks.map((t) => [t.id.toString(), t])); + + return dependencies + .map((depId) => { + const task = taskMap.get(depId.toString()); + if (!task) { + return chalk.red(`${depId} (not found)`); + } + + const statusIcon = + task.status === 'done' + ? '✓' + : task.status === 'in-progress' + ? '►' + : '○'; + + return `${depId}${statusIcon}`; + }) + .join(', '); +} diff --git a/apps/cli/src/ui/formatters/priority-formatters.ts b/apps/cli/src/ui/formatters/priority-formatters.ts new file mode 100644 index 00000000..7683b9b5 --- /dev/null +++ b/apps/cli/src/ui/formatters/priority-formatters.ts @@ -0,0 +1,25 @@ +/** + * @fileoverview Priority formatting utilities + * Provides colored priority displays for tasks + */ + +import type { TaskPriority } from '@tm/core'; +import chalk from 'chalk'; + +/** + * Module-level priority color map to avoid recreating on every call + */ +const PRIORITY_COLORS: Record string> = { + critical: chalk.red.bold, + high: chalk.red, + medium: chalk.yellow, + low: chalk.gray +}; + +/** + * Get colored priority display + */ +export function getPriorityWithColor(priority: TaskPriority): string { + const colorFn = PRIORITY_COLORS[priority] || chalk.white; + return colorFn(priority); +} diff --git a/apps/cli/src/ui/formatters/status-formatters.spec.ts b/apps/cli/src/ui/formatters/status-formatters.spec.ts new file mode 100644 index 00000000..cafa5373 --- /dev/null +++ b/apps/cli/src/ui/formatters/status-formatters.spec.ts @@ -0,0 +1,139 @@ +/** + * Status formatter tests + * Tests for apps/cli/src/utils/formatters/status-formatters.ts + */ + +import { describe, expect, it } from 'vitest'; +import { + capitalizeStatus, + getBriefStatusColor, + getBriefStatusIcon, + getBriefStatusWithColor +} from './status-formatters.js'; + +describe('Status Formatters', () => { + describe('getBriefStatusWithColor', () => { + it('should format draft status with gray color and circle icon', () => { + const result = getBriefStatusWithColor('draft', true); + expect(result).toContain('Draft'); + expect(result).toContain('○'); + }); + + it('should format refining status with yellow color and half-circle icon', () => { + const result = getBriefStatusWithColor('refining', true); + expect(result).toContain('Refining'); + expect(result).toContain('◐'); + }); + + it('should format aligned status with cyan color and target icon', () => { + const result = getBriefStatusWithColor('aligned', true); + expect(result).toContain('Aligned'); + expect(result).toContain('◎'); + }); + + it('should format delivering status with orange color and play icon', () => { + const result = getBriefStatusWithColor('delivering', true); + expect(result).toContain('Delivering'); + expect(result).toContain('▶'); + }); + + it('should format delivered status with blue color and diamond icon', () => { + const result = getBriefStatusWithColor('delivered', true); + expect(result).toContain('Delivered'); + expect(result).toContain('◆'); + }); + + it('should format done status with green color and checkmark icon', () => { + const result = getBriefStatusWithColor('done', true); + expect(result).toContain('Done'); + expect(result).toContain('✓'); + }); + + it('should format archived status with gray color and square icon', () => { + const result = getBriefStatusWithColor('archived', true); + expect(result).toContain('Archived'); + expect(result).toContain('■'); + }); + + it('should handle unknown status with red color and question mark', () => { + const result = getBriefStatusWithColor('unknown-status', true); + expect(result).toContain('Unknown-status'); + expect(result).toContain('?'); + }); + + it('should handle undefined status with gray color', () => { + const result = getBriefStatusWithColor(undefined, true); + expect(result).toContain('Unknown'); + expect(result).toContain('○'); + }); + + it('should use same icon for table and non-table display', () => { + const tableResult = getBriefStatusWithColor('done', true); + const nonTableResult = getBriefStatusWithColor('done', false); + expect(tableResult).toBe(nonTableResult); + }); + + it('should handle case-insensitive status names', () => { + const lowerResult = getBriefStatusWithColor('draft', true); + const upperResult = getBriefStatusWithColor('DRAFT', true); + const mixedResult = getBriefStatusWithColor('DrAfT', true); + expect(lowerResult).toContain('Draft'); + expect(upperResult).toContain('Draft'); + expect(mixedResult).toContain('Draft'); + }); + }); + + describe('getBriefStatusIcon', () => { + it('should return correct icon for status', () => { + expect(getBriefStatusIcon('draft')).toBe('○'); + expect(getBriefStatusIcon('done')).toBe('✓'); + expect(getBriefStatusIcon('delivering')).toBe('▶'); + }); + + it('should return default icon for unknown status', () => { + expect(getBriefStatusIcon('unknown-status')).toBe('?'); + }); + + it('should return default icon for undefined', () => { + expect(getBriefStatusIcon(undefined)).toBe('○'); + }); + + it('should return same icon for table and non-table', () => { + expect(getBriefStatusIcon('done', true)).toBe( + getBriefStatusIcon('done', false) + ); + }); + }); + + describe('getBriefStatusColor', () => { + it('should return a color function', () => { + const colorFn = getBriefStatusColor('draft'); + expect(typeof colorFn).toBe('function'); + const result = colorFn('test'); + expect(typeof result).toBe('string'); + }); + + it('should return gray color for undefined', () => { + const colorFn = getBriefStatusColor(undefined); + expect(typeof colorFn).toBe('function'); + }); + }); + + describe('capitalizeStatus', () => { + it('should capitalize first letter and lowercase rest', () => { + expect(capitalizeStatus('draft')).toBe('Draft'); + expect(capitalizeStatus('DRAFT')).toBe('Draft'); + expect(capitalizeStatus('DrAfT')).toBe('Draft'); + expect(capitalizeStatus('in-progress')).toBe('In-progress'); + }); + + it('should handle single character', () => { + expect(capitalizeStatus('a')).toBe('A'); + expect(capitalizeStatus('A')).toBe('A'); + }); + + it('should handle empty string', () => { + expect(capitalizeStatus('')).toBe(''); + }); + }); +}); diff --git a/apps/cli/src/ui/formatters/status-formatters.ts b/apps/cli/src/ui/formatters/status-formatters.ts new file mode 100644 index 00000000..c4bacd62 --- /dev/null +++ b/apps/cli/src/ui/formatters/status-formatters.ts @@ -0,0 +1,179 @@ +/** + * @fileoverview Status formatting utilities + * Provides colored status displays with ASCII icons for tasks and briefs + */ + +import type { TaskStatus } from '@tm/core'; +import chalk from 'chalk'; + +/** + * Module-level task status configuration to avoid recreating on every call + */ +const TASK_STATUS_CONFIG: Record< + TaskStatus, + { color: (text: string) => string; icon: string; tableIcon: string } +> = { + done: { + color: chalk.green, + icon: '✓', + tableIcon: '✓' + }, + pending: { + color: chalk.yellow, + icon: '○', + tableIcon: '○' + }, + 'in-progress': { + color: chalk.hex('#FFA500'), + icon: '▶', + tableIcon: '▶' + }, + deferred: { + color: chalk.gray, + icon: 'x', + tableIcon: 'x' + }, + review: { + color: chalk.magenta, + icon: '?', + tableIcon: '?' + }, + cancelled: { + color: chalk.gray, + icon: 'x', + tableIcon: 'x' + }, + blocked: { + color: chalk.red, + icon: '!', + tableIcon: '!' + }, + completed: { + color: chalk.green, + icon: '✓', + tableIcon: '✓' + } +}; + +/** + * Get colored status display with ASCII icons (matches scripts/modules/ui.js style) + */ +export function getStatusWithColor( + status: TaskStatus, + forTable: boolean = false +): string { + const config = TASK_STATUS_CONFIG[status] || { + color: chalk.red, + icon: 'X', + tableIcon: 'X' + }; + + const icon = forTable ? config.tableIcon : config.icon; + return config.color(`${icon} ${status}`); +} + +/** + * Brief status configuration + */ +const BRIEF_STATUS_CONFIG: Record< + string, + { color: (text: string) => string; icon: string; tableIcon: string } +> = { + draft: { + color: chalk.gray, + icon: '○', + tableIcon: '○' + }, + refining: { + color: chalk.yellow, + icon: '◐', + tableIcon: '◐' + }, + aligned: { + color: chalk.cyan, + icon: '◎', + tableIcon: '◎' + }, + delivering: { + color: chalk.hex('#FFA500'), // orange + icon: '▶', + tableIcon: '▶' + }, + delivered: { + color: chalk.blue, + icon: '◆', + tableIcon: '◆' + }, + done: { + color: chalk.green, + icon: '✓', + tableIcon: '✓' + }, + archived: { + color: chalk.gray, + icon: '■', + tableIcon: '■' + } +}; + +/** + * Get the configuration for a brief status + */ +function getBriefStatusConfig(status: string) { + // Normalize to lowercase for lookup + const normalizedStatus = status.toLowerCase(); + return ( + BRIEF_STATUS_CONFIG[normalizedStatus] || { + color: chalk.red, + icon: '?', + tableIcon: '?' + } + ); +} + +/** + * Get the icon for a brief status + */ +export function getBriefStatusIcon( + status: string | undefined, + forTable: boolean = false +): string { + if (!status) return '○'; + const config = getBriefStatusConfig(status); + return forTable ? config.tableIcon : config.icon; +} + +/** + * Get the color function for a brief status + */ +export function getBriefStatusColor( + status: string | undefined +): (text: string) => string { + if (!status) return chalk.gray; + return getBriefStatusConfig(status).color; +} + +/** + * Capitalize the first letter of a status + */ +export function capitalizeStatus(status: string): string { + return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase(); +} + +/** + * Get colored brief/tag status display with ASCII icons + * Brief statuses: draft, refining, aligned, delivering, delivered, done, archived + */ +export function getBriefStatusWithColor( + status: string | undefined, + forTable: boolean = false +): string { + if (!status) { + return chalk.gray('○ Unknown'); + } + + const config = getBriefStatusConfig(status); + const icon = forTable ? config.tableIcon : config.icon; + const displayStatus = capitalizeStatus(status); + return config.color(`${icon} ${displayStatus}`); +} diff --git a/apps/cli/src/ui/index.ts b/apps/cli/src/ui/index.ts index f735fa4b..d4d49142 100644 --- a/apps/cli/src/ui/index.ts +++ b/apps/cli/src/ui/index.ts @@ -1,9 +1,50 @@ /** * @fileoverview Main UI exports + * Organized UI system with components, formatters, display primitives, and layout helpers */ -// Export all components +// High-level UI components export * from './components/index.js'; -// Re-export existing UI utilities -export * from '../utils/ui.js'; +// Status formatters +export { + getStatusWithColor, + getBriefStatusWithColor, + getBriefStatusIcon, + getBriefStatusColor, + capitalizeStatus +} from './formatters/status-formatters.js'; + +// Priority formatters +export { getPriorityWithColor } from './formatters/priority-formatters.js'; + +// Complexity formatters +export { + getComplexityWithColor, + getComplexityWithScore +} from './formatters/complexity-formatters.js'; + +// Dependency formatters +export { formatDependenciesWithStatus } from './formatters/dependency-formatters.js'; + +// Layout helpers +export { + getBoxWidth, + truncate, + createProgressBar +} from './layout/helpers.js'; + +// Display messages +// Note: displayError alias is available via namespace (ui.displayError) for backward compat +// but not exported at package level to avoid conflicts with utils/error-handler.ts +export { + displayBanner, + displayErrorBox, + displayError, // Backward compatibility alias + displaySuccess, + displayWarning, + displayInfo +} from './display/messages.js'; + +// Display tables +export { createTaskTable } from './display/tables.js'; diff --git a/apps/cli/src/ui/layout/helpers.spec.ts b/apps/cli/src/ui/layout/helpers.spec.ts new file mode 100644 index 00000000..2ffa021a --- /dev/null +++ b/apps/cli/src/ui/layout/helpers.spec.ts @@ -0,0 +1,158 @@ +/** + * Layout helper utilities tests + * Tests for apps/cli/src/utils/layout/helpers.ts + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MockInstance } from 'vitest'; +import { getBoxWidth } from './helpers.js'; + +describe('Layout Helpers', () => { + describe('getBoxWidth', () => { + let columnsSpy: MockInstance; + let originalDescriptor: PropertyDescriptor | undefined; + + beforeEach(() => { + // Store original descriptor if it exists + originalDescriptor = Object.getOwnPropertyDescriptor( + process.stdout, + 'columns' + ); + + // If columns doesn't exist or isn't a getter, define it as one + if (!originalDescriptor || !originalDescriptor.get) { + const currentValue = process.stdout.columns || 80; + Object.defineProperty(process.stdout, 'columns', { + get() { + return currentValue; + }, + configurable: true + }); + } + + // Now spy on the getter + columnsSpy = vi.spyOn(process.stdout, 'columns', 'get'); + }); + + afterEach(() => { + // Restore the spy + columnsSpy.mockRestore(); + + // Restore original descriptor or delete the property + if (originalDescriptor) { + Object.defineProperty(process.stdout, 'columns', originalDescriptor); + } else { + delete (process.stdout as any).columns; + } + }); + + it('should calculate width as percentage of terminal width', () => { + columnsSpy.mockReturnValue(100); + const width = getBoxWidth(0.9, 40); + expect(width).toBe(90); + }); + + it('should use default percentage of 0.9 when not specified', () => { + columnsSpy.mockReturnValue(100); + const width = getBoxWidth(); + expect(width).toBe(90); + }); + + it('should use default minimum width of 40 when not specified', () => { + columnsSpy.mockReturnValue(30); + const width = getBoxWidth(); + expect(width).toBe(40); // Should enforce minimum + }); + + it('should enforce minimum width when terminal is too narrow', () => { + columnsSpy.mockReturnValue(50); + const width = getBoxWidth(0.9, 60); + expect(width).toBe(60); // Should use minWidth instead of 45 + }); + + it('should handle undefined process.stdout.columns', () => { + columnsSpy.mockReturnValue(undefined); + const width = getBoxWidth(0.9, 40); + // Should fall back to 80 columns: Math.floor(80 * 0.9) = 72 + expect(width).toBe(72); + }); + + it('should handle custom percentage values', () => { + columnsSpy.mockReturnValue(100); + expect(getBoxWidth(0.95, 40)).toBe(95); + expect(getBoxWidth(0.8, 40)).toBe(80); + expect(getBoxWidth(0.5, 40)).toBe(50); + }); + + it('should handle custom minimum width values', () => { + columnsSpy.mockReturnValue(60); + expect(getBoxWidth(0.9, 70)).toBe(70); // 60 * 0.9 = 54, but min is 70 + expect(getBoxWidth(0.9, 50)).toBe(54); // 60 * 0.9 = 54, min is 50 + }); + + it('should floor the calculated width', () => { + columnsSpy.mockReturnValue(99); + const width = getBoxWidth(0.9, 40); + // 99 * 0.9 = 89.1, should floor to 89 + expect(width).toBe(89); + }); + + it('should match warning box width calculation', () => { + // Test the specific case from displayWarning() + columnsSpy.mockReturnValue(80); + const width = getBoxWidth(0.9, 40); + expect(width).toBe(72); + }); + + it('should match table width calculation', () => { + // Test the specific case from createTaskTable() + columnsSpy.mockReturnValue(111); + const width = getBoxWidth(0.9, 100); + // 111 * 0.9 = 99.9, floor to 99, but max(99, 100) = 100 + expect(width).toBe(100); + }); + + it('should match recommended task box width calculation', () => { + // Test the specific case from displayRecommendedNextTask() + columnsSpy.mockReturnValue(120); + const width = getBoxWidth(0.97, 40); + // 120 * 0.97 = 116.4, floor to 116 + expect(width).toBe(116); + }); + + it('should handle edge case of zero terminal width', () => { + columnsSpy.mockReturnValue(0); + const width = getBoxWidth(0.9, 40); + // When columns is 0, it uses fallback of 80: Math.floor(80 * 0.9) = 72 + expect(width).toBe(72); + }); + + it('should handle very large terminal widths', () => { + columnsSpy.mockReturnValue(1000); + const width = getBoxWidth(0.9, 40); + expect(width).toBe(900); + }); + + it('should handle very small percentages', () => { + columnsSpy.mockReturnValue(100); + const width = getBoxWidth(0.1, 5); + // 100 * 0.1 = 10, which is greater than min 5 + expect(width).toBe(10); + }); + + it('should handle percentage of 1.0 (100%)', () => { + columnsSpy.mockReturnValue(80); + const width = getBoxWidth(1.0, 40); + expect(width).toBe(80); + }); + + it('should consistently return same value for same inputs', () => { + columnsSpy.mockReturnValue(100); + const width1 = getBoxWidth(0.9, 40); + const width2 = getBoxWidth(0.9, 40); + const width3 = getBoxWidth(0.9, 40); + expect(width1).toBe(width2); + expect(width2).toBe(width3); + }); + }); +}); diff --git a/apps/cli/src/ui/layout/helpers.ts b/apps/cli/src/ui/layout/helpers.ts new file mode 100644 index 00000000..96742104 --- /dev/null +++ b/apps/cli/src/ui/layout/helpers.ts @@ -0,0 +1,51 @@ +/** + * @fileoverview Layout helper utilities + * Provides utilities for calculating dimensions, truncating text, and creating visual elements + */ + +import chalk from 'chalk'; + +/** + * Calculate box width as percentage of terminal width + * @param percentage - Percentage of terminal width to use (default: 0.9) + * @param minWidth - Minimum width to enforce (default: 40) + * @returns Calculated box width + */ +export function getBoxWidth( + percentage: number = 0.9, + minWidth: number = 40 +): number { + const terminalWidth = process.stdout.columns || 80; + return Math.max(Math.floor(terminalWidth * percentage), minWidth); +} + +/** + * Truncate text to specified length + */ +export function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + return text.substring(0, maxLength - 3) + '...'; +} + +/** + * Create a progress bar + */ +export function createProgressBar( + completed: number, + total: number, + width: number = 30 +): string { + if (total === 0) { + return chalk.gray('No tasks'); + } + + const percentage = Math.round((completed / total) * 100); + const filled = Math.round((completed / total) * width); + const empty = width - filled; + + const bar = chalk.green('█').repeat(filled) + chalk.gray('░').repeat(empty); + + return `${bar} ${chalk.cyan(`${percentage}%`)} (${completed}/${total})`; +} diff --git a/apps/cli/src/utils/auth-helpers.ts b/apps/cli/src/utils/auth-helpers.ts new file mode 100644 index 00000000..c536e707 --- /dev/null +++ b/apps/cli/src/utils/auth-helpers.ts @@ -0,0 +1,68 @@ +/** + * @fileoverview Authentication helpers for CLI commands + */ + +import type { AuthManager } from '@tm/core'; +import { displayCardBox } from '../ui/components/cardBox.component.js'; + +/** + * Options for authentication check + */ +export interface CheckAuthOptions { + /** Custom message describing what requires authentication (defaults to generic message) */ + message?: string; + /** Optional footer text (e.g., alternative local commands) */ + footer?: string; + /** Command to run to authenticate (defaults to "tm auth login") */ + authCommand?: string; +} + +/** + * Check if user is authenticated and display a friendly card box if not. + * Used by commands that require Hamster authentication (briefs, context, etc.) + * + * @param authManager - AuthManager instance + * @param options - Optional customization for the authentication prompt + * @returns true if authenticated, false if not + * + * @example + * ```typescript + * const isAuthenticated = await checkAuthentication(authManager, { + * message: 'The "briefs" command requires you to be logged in to your Hamster account.', + * footer: 'Working locally instead?\n → Use "tm tags" for local tag management.' + * }); + * + * if (!isAuthenticated) { + * process.exit(1); + * } + * ``` + */ +export async function checkAuthentication( + authManager: AuthManager, + options: CheckAuthOptions = {} +): Promise { + const hasSession = await authManager.hasValidSession(); + + if (!hasSession) { + const { + message = 'This command requires you to be logged in to your Hamster account.', + footer, + authCommand = 'tm auth login' + } = options; + + console.log( + displayCardBox({ + header: '[!] Not logged in to Hamster', + body: [message], + callToAction: { + label: 'To get started:', + action: authCommand + }, + footer + }) + ); + return false; + } + + return true; +} diff --git a/apps/cli/src/utils/auto-update.ts b/apps/cli/src/utils/auto-update.ts index ca87cf5a..9e661944 100644 --- a/apps/cli/src/utils/auto-update.ts +++ b/apps/cli/src/utils/auto-update.ts @@ -4,9 +4,9 @@ import { spawn } from 'child_process'; import https from 'https'; +import boxen from 'boxen'; import chalk from 'chalk'; import ora from 'ora'; -import boxen from 'boxen'; import process from 'process'; export interface UpdateInfo { diff --git a/apps/cli/src/utils/brief-selection.ts b/apps/cli/src/utils/brief-selection.ts new file mode 100644 index 00000000..42d2aa85 --- /dev/null +++ b/apps/cli/src/utils/brief-selection.ts @@ -0,0 +1,284 @@ +/** + * @fileoverview Shared brief selection utilities + * Reusable functions for selecting briefs interactively or via URL/ID + */ + +import chalk from 'chalk'; +import search from '@inquirer/search'; +import ora, { Ora } from 'ora'; +import { AuthManager } from '@tm/core'; +import * as ui from './ui.js'; +import { getBriefStatusWithColor } from '../ui/formatters/status-formatters.js'; + +export interface BriefSelectionResult { + success: boolean; + briefId?: string; + briefName?: string; + orgId?: string; + orgName?: string; + message?: string; +} + +/** + * Select a brief interactively using search + */ +export async function selectBriefInteractive( + authManager: AuthManager, + orgId: string +): Promise { + const spinner = ora('Fetching briefs...').start(); + + try { + // Fetch briefs from API + const briefs = await authManager.getBriefs(orgId); + spinner.stop(); + + if (briefs.length === 0) { + ui.displayWarning('No briefs available in this organization'); + return { + success: false, + message: 'No briefs available' + }; + } + + // Prompt for selection with search + const selectedBrief = await search<(typeof briefs)[0] | null>({ + message: 'Search for a brief:', + source: async (input) => { + const searchTerm = input?.toLowerCase() || ''; + + // Static option for no brief + const noBriefOption = { + name: '(No brief - organization level)', + value: null as any, + description: 'Clear brief selection' + }; + + // Filter briefs based on search term + const filteredBriefs = briefs.filter((brief) => { + if (!searchTerm) return true; + + const title = brief.document?.title || ''; + const shortId = brief.id.slice(0, 8); + const lastChars = brief.id.slice(-8); + + // Search by title, full UUID, first 8 chars, or last 8 chars + return ( + title.toLowerCase().includes(searchTerm) || + brief.id.toLowerCase().includes(searchTerm) || + shortId.toLowerCase().includes(searchTerm) || + lastChars.toLowerCase().includes(searchTerm) + ); + }); + + // Group briefs by status + const briefsByStatus = filteredBriefs.reduce( + (acc, brief) => { + const status = brief.status || 'unknown'; + if (!acc[status]) { + acc[status] = []; + } + acc[status].push(brief); + return acc; + }, + {} as Record + ); + + // Define status order (most active first) + const statusOrder = [ + 'delivering', + 'aligned', + 'refining', + 'draft', + 'delivered', + 'done', + 'archived' + ]; + + // Build grouped options + const groupedOptions: any[] = []; + + for (const status of statusOrder) { + const statusBriefs = briefsByStatus[status]; + if (!statusBriefs || statusBriefs.length === 0) continue; + + // Add status header as separator + const statusHeader = getBriefStatusWithColor(status); + groupedOptions.push({ + type: 'separator', + separator: `\n${statusHeader}` + }); + + // Add briefs under this status + statusBriefs.forEach((brief) => { + const title = + brief.document?.title || `Brief ${brief.id.slice(-8)}`; + const shortId = brief.id.slice(-8); + const description = brief.document?.description || ''; + const taskCountDisplay = + brief.taskCount !== undefined && brief.taskCount > 0 + ? chalk.gray( + ` (${brief.taskCount} ${brief.taskCount === 1 ? 'task' : 'tasks'})` + ) + : ''; + + groupedOptions.push({ + name: ` ${title}${taskCountDisplay} ${chalk.gray(`(${shortId})`)}`, + value: brief, + description: description + ? chalk.gray(` ${description.slice(0, 80)}`) + : undefined + }); + }); + } + + // Handle any briefs with statuses not in our order + const unorderedStatuses = Object.keys(briefsByStatus).filter( + (s) => !statusOrder.includes(s) + ); + for (const status of unorderedStatuses) { + const statusBriefs = briefsByStatus[status]; + if (!statusBriefs || statusBriefs.length === 0) continue; + + const statusHeader = getBriefStatusWithColor(status); + groupedOptions.push({ + type: 'separator', + separator: `\n${statusHeader}` + }); + + statusBriefs.forEach((brief) => { + const title = + brief.document?.title || `Brief ${brief.id.slice(-8)}`; + const shortId = brief.id.slice(-8); + const description = brief.document?.description || ''; + const taskCountDisplay = + brief.taskCount !== undefined && brief.taskCount > 0 + ? chalk.gray( + ` (${brief.taskCount} ${brief.taskCount === 1 ? 'task' : 'tasks'})` + ) + : ''; + + groupedOptions.push({ + name: ` ${title}${taskCountDisplay} ${chalk.gray(`(${shortId})`)}`, + value: brief, + description: description + ? chalk.gray(` ${description.slice(0, 80)}`) + : undefined + }); + }); + } + + return [noBriefOption, ...groupedOptions]; + } + }); + + if (selectedBrief) { + // Update context with brief + const briefName = + selectedBrief.document?.title || + `Brief ${selectedBrief.id.slice(0, 8)}`; + await authManager.updateContext({ + briefId: selectedBrief.id, + briefName: briefName, + briefStatus: selectedBrief.status, + briefUpdatedAt: selectedBrief.updatedAt + }); + + ui.displaySuccess(`Selected brief: ${briefName}`); + + return { + success: true, + briefId: selectedBrief.id, + briefName, + message: `Selected brief: ${briefName}` + }; + } else { + // Clear brief selection + await authManager.updateContext({ + briefId: undefined, + briefName: undefined, + briefStatus: undefined, + briefUpdatedAt: undefined + }); + + ui.displaySuccess('Cleared brief selection (organization level)'); + + return { + success: true, + message: 'Cleared brief selection' + }; + } + } catch (error) { + spinner.fail('Failed to fetch briefs'); + throw error; + } +} + +/** + * Select a brief from any input format (URL, ID, name) using tm-core + * Presentation layer - handles display and context updates only + * + * All business logic (URL parsing, ID matching, name resolution) is in tm-core + */ +export async function selectBriefFromInput( + authManager: AuthManager, + input: string, + tmCore: any +): Promise { + let spinner: Ora | undefined; + try { + spinner = ora('Resolving brief...'); + spinner.start(); + + // Let tm-core handle ALL business logic: + // - URL parsing + // - ID extraction + // - UUID matching (full or last 8 chars) + // - Name matching + const brief = await tmCore.tasks.resolveBrief(input); + + // Fetch org to get a friendly name and slug (optional) + let orgName: string | undefined; + let orgSlug: string | undefined; + try { + const org = await authManager.getOrganization(brief.accountId); + orgName = org?.name; + orgSlug = org?.slug; + } catch { + // Non-fatal if org lookup fails + } + + // Update context: set org and brief + const briefName = brief.document?.title || `Brief ${brief.id.slice(0, 8)}`; + await authManager.updateContext({ + orgId: brief.accountId, + orgName, + orgSlug, + briefId: brief.id, + briefName, + briefStatus: brief.status, + briefUpdatedAt: brief.updatedAt + }); + + spinner.succeed('Context set from brief'); + console.log( + chalk.gray( + ` Organization: ${orgName || brief.accountId}\n Brief: ${briefName}` + ) + ); + + return { + success: true, + briefId: brief.id, + briefName, + orgId: brief.accountId, + orgName, + message: 'Context set from brief' + }; + } catch (error) { + try { + if (spinner?.isSpinning) spinner.stop(); + } catch {} + throw error; + } +} diff --git a/apps/cli/src/utils/index.ts b/apps/cli/src/utils/index.ts new file mode 100644 index 00000000..890ba5c5 --- /dev/null +++ b/apps/cli/src/utils/index.ts @@ -0,0 +1,28 @@ +/** + * @fileoverview CLI utilities main export + * General-purpose utilities (non-UI) + * + * Note: UI-related utilities have been moved to src/ui/ + * For backward compatibility, use src/utils/ui.ts which re-exports from src/ui/ + */ + +// Authentication helpers +export { + checkAuthentication, + type CheckAuthOptions +} from './auth-helpers.js'; + +// Error handling utilities +export { displayError, isDebugMode } from './error-handler.js'; + +// Auto-update utilities +export { + checkForUpdate, + performAutoUpdate, + displayUpgradeNotification, + compareVersions, + restartWithNewVersion +} from './auto-update.js'; + +// Display helpers (command-specific helpers) +export { displayCommandHeader } from './display-helpers.js'; diff --git a/apps/cli/src/utils/ui.spec.ts b/apps/cli/src/utils/ui.spec.ts index 2c553f8c..3bea74c9 100644 --- a/apps/cli/src/utils/ui.spec.ts +++ b/apps/cli/src/utils/ui.spec.ts @@ -1,158 +1,39 @@ /** - * CLI UI utilities tests + * CLI UI utilities tests (Backward compatibility tests) * Tests for apps/cli/src/utils/ui.ts + * + * This file ensures backward compatibility with the old ui.ts module. + * The actual implementation has been moved to src/ui/ organized modules. + * See: + * - ui/layout/helpers.spec.ts + * - ui/formatters/status-formatters.spec.ts */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import type { MockInstance } from 'vitest'; -import { getBoxWidth } from './ui.js'; +import { describe, expect, it } from 'vitest'; +import { getBoxWidth, getBriefStatusWithColor } from './ui.js'; -describe('CLI UI Utilities', () => { - describe('getBoxWidth', () => { - let columnsSpy: MockInstance; - let originalDescriptor: PropertyDescriptor | undefined; - - beforeEach(() => { - // Store original descriptor if it exists - originalDescriptor = Object.getOwnPropertyDescriptor( - process.stdout, - 'columns' - ); - - // If columns doesn't exist or isn't a getter, define it as one - if (!originalDescriptor || !originalDescriptor.get) { - const currentValue = process.stdout.columns || 80; - Object.defineProperty(process.stdout, 'columns', { - get() { - return currentValue; - }, - configurable: true - }); - } - - // Now spy on the getter - columnsSpy = vi.spyOn(process.stdout, 'columns', 'get'); +describe('CLI UI Utilities (Backward Compatibility)', () => { + describe('Re-exports work correctly from ui/', () => { + it('should re-export getBoxWidth', () => { + expect(typeof getBoxWidth).toBe('function'); }); - afterEach(() => { - // Restore the spy - columnsSpy.mockRestore(); - - // Restore original descriptor or delete the property - if (originalDescriptor) { - Object.defineProperty(process.stdout, 'columns', originalDescriptor); - } else { - delete (process.stdout as any).columns; - } + it('should re-export getBriefStatusWithColor', () => { + expect(typeof getBriefStatusWithColor).toBe('function'); }); - it('should calculate width as percentage of terminal width', () => { - columnsSpy.mockReturnValue(100); + it('should maintain functional behavior for getBoxWidth', () => { + // Simple smoke test - detailed tests are in ui/layout/helpers.spec.ts const width = getBoxWidth(0.9, 40); - expect(width).toBe(90); + expect(typeof width).toBe('number'); + expect(width).toBeGreaterThanOrEqual(40); }); - it('should use default percentage of 0.9 when not specified', () => { - columnsSpy.mockReturnValue(100); - const width = getBoxWidth(); - expect(width).toBe(90); - }); - - it('should use default minimum width of 40 when not specified', () => { - columnsSpy.mockReturnValue(30); - const width = getBoxWidth(); - expect(width).toBe(40); // Should enforce minimum - }); - - it('should enforce minimum width when terminal is too narrow', () => { - columnsSpy.mockReturnValue(50); - const width = getBoxWidth(0.9, 60); - expect(width).toBe(60); // Should use minWidth instead of 45 - }); - - it('should handle undefined process.stdout.columns', () => { - columnsSpy.mockReturnValue(undefined); - const width = getBoxWidth(0.9, 40); - // Should fall back to 80 columns: Math.floor(80 * 0.9) = 72 - expect(width).toBe(72); - }); - - it('should handle custom percentage values', () => { - columnsSpy.mockReturnValue(100); - expect(getBoxWidth(0.95, 40)).toBe(95); - expect(getBoxWidth(0.8, 40)).toBe(80); - expect(getBoxWidth(0.5, 40)).toBe(50); - }); - - it('should handle custom minimum width values', () => { - columnsSpy.mockReturnValue(60); - expect(getBoxWidth(0.9, 70)).toBe(70); // 60 * 0.9 = 54, but min is 70 - expect(getBoxWidth(0.9, 50)).toBe(54); // 60 * 0.9 = 54, min is 50 - }); - - it('should floor the calculated width', () => { - columnsSpy.mockReturnValue(99); - const width = getBoxWidth(0.9, 40); - // 99 * 0.9 = 89.1, should floor to 89 - expect(width).toBe(89); - }); - - it('should match warning box width calculation', () => { - // Test the specific case from displayWarning() - columnsSpy.mockReturnValue(80); - const width = getBoxWidth(0.9, 40); - expect(width).toBe(72); - }); - - it('should match table width calculation', () => { - // Test the specific case from createTaskTable() - columnsSpy.mockReturnValue(111); - const width = getBoxWidth(0.9, 100); - // 111 * 0.9 = 99.9, floor to 99, but max(99, 100) = 100 - expect(width).toBe(100); - }); - - it('should match recommended task box width calculation', () => { - // Test the specific case from displayRecommendedNextTask() - columnsSpy.mockReturnValue(120); - const width = getBoxWidth(0.97, 40); - // 120 * 0.97 = 116.4, floor to 116 - expect(width).toBe(116); - }); - - it('should handle edge case of zero terminal width', () => { - columnsSpy.mockReturnValue(0); - const width = getBoxWidth(0.9, 40); - // When columns is 0, it uses fallback of 80: Math.floor(80 * 0.9) = 72 - expect(width).toBe(72); - }); - - it('should handle very large terminal widths', () => { - columnsSpy.mockReturnValue(1000); - const width = getBoxWidth(0.9, 40); - expect(width).toBe(900); - }); - - it('should handle very small percentages', () => { - columnsSpy.mockReturnValue(100); - const width = getBoxWidth(0.1, 5); - // 100 * 0.1 = 10, which is greater than min 5 - expect(width).toBe(10); - }); - - it('should handle percentage of 1.0 (100%)', () => { - columnsSpy.mockReturnValue(80); - const width = getBoxWidth(1.0, 40); - expect(width).toBe(80); - }); - - it('should consistently return same value for same inputs', () => { - columnsSpy.mockReturnValue(100); - const width1 = getBoxWidth(0.9, 40); - const width2 = getBoxWidth(0.9, 40); - const width3 = getBoxWidth(0.9, 40); - expect(width1).toBe(width2); - expect(width2).toBe(width3); + it('should maintain functional behavior for getBriefStatusWithColor', () => { + // Simple smoke test - detailed tests are in ui/formatters/status-formatters.spec.ts + const result = getBriefStatusWithColor('done', true); + expect(result).toContain('Done'); + expect(result).toContain('✓'); }); }); }); diff --git a/apps/cli/src/utils/ui.ts b/apps/cli/src/utils/ui.ts index 5f479fec..d0e190a9 100644 --- a/apps/cli/src/utils/ui.ts +++ b/apps/cli/src/utils/ui.ts @@ -1,419 +1,15 @@ /** - * @fileoverview UI utilities for Task Master CLI - * Provides formatting, display, and visual components for the command line interface + * @fileoverview UI utilities for Task Master CLI (Re-export module) + * + * @deprecated: This file is kept for backward compatibility. + * All functionality has been moved to organized modules under src/ui/: + * - ui/formatters/ (status, priority, complexity, dependencies) + * - ui/display/ (messages, tables) + * - ui/layout/ (helpers) + * - ui/components/ (high-level UI components) + * + * Please import from '../ui/index.js' or specific modules for new code. */ -import chalk from 'chalk'; -import boxen from 'boxen'; -import Table from 'cli-table3'; -import type { Task, TaskStatus, TaskPriority, Subtask } from '@tm/core'; - -/** - * Get colored status display with ASCII icons (matches scripts/modules/ui.js style) - */ -export function getStatusWithColor( - status: TaskStatus, - forTable: boolean = false -): string { - const statusConfig = { - done: { - color: chalk.green, - icon: '✓', - tableIcon: '✓' - }, - pending: { - color: chalk.yellow, - icon: '○', - tableIcon: '○' - }, - 'in-progress': { - color: chalk.hex('#FFA500'), - icon: '▶', - tableIcon: '▶' - }, - deferred: { - color: chalk.gray, - icon: 'x', - tableIcon: 'x' - }, - review: { - color: chalk.magenta, - icon: '?', - tableIcon: '?' - }, - cancelled: { - color: chalk.gray, - icon: 'x', - tableIcon: 'x' - }, - blocked: { - color: chalk.red, - icon: '!', - tableIcon: '!' - }, - completed: { - color: chalk.green, - icon: '✓', - tableIcon: '✓' - } - }; - - const config = statusConfig[status] || { - color: chalk.red, - icon: 'X', - tableIcon: 'X' - }; - - const icon = forTable ? config.tableIcon : config.icon; - return config.color(`${icon} ${status}`); -} - -/** - * Get colored priority display - */ -export function getPriorityWithColor(priority: TaskPriority): string { - const priorityColors: Record string> = { - critical: chalk.red.bold, - high: chalk.red, - medium: chalk.yellow, - low: chalk.gray - }; - - const colorFn = priorityColors[priority] || chalk.white; - return colorFn(priority); -} - -/** - * Get complexity color and label based on score thresholds - */ -function getComplexityLevel(score: number): { - color: (text: string) => string; - label: string; -} { - if (score >= 7) { - return { color: chalk.hex('#CC0000'), label: 'High' }; - } else if (score >= 4) { - return { color: chalk.hex('#FF8800'), label: 'Medium' }; - } else { - return { color: chalk.green, label: 'Low' }; - } -} - -/** - * Get colored complexity display with dot indicator (simple format) - */ -export function getComplexityWithColor(complexity: number | string): string { - const score = - typeof complexity === 'string' ? parseInt(complexity, 10) : complexity; - - if (isNaN(score)) { - return chalk.gray('N/A'); - } - - const { color } = getComplexityLevel(score); - return color(`● ${score}`); -} - -/** - * Get colored complexity display with /10 format (for dashboards) - */ -export function getComplexityWithScore(complexity: number | undefined): string { - if (typeof complexity !== 'number') { - return chalk.gray('N/A'); - } - - const { color, label } = getComplexityLevel(complexity); - return color(`${complexity}/10 (${label})`); -} - -/** - * Calculate box width as percentage of terminal width - * @param percentage - Percentage of terminal width to use (default: 0.9) - * @param minWidth - Minimum width to enforce (default: 40) - * @returns Calculated box width - */ -export function getBoxWidth( - percentage: number = 0.9, - minWidth: number = 40 -): number { - const terminalWidth = process.stdout.columns || 80; - return Math.max(Math.floor(terminalWidth * percentage), minWidth); -} - -/** - * Truncate text to specified length - */ -export function truncate(text: string, maxLength: number): string { - if (text.length <= maxLength) { - return text; - } - return text.substring(0, maxLength - 3) + '...'; -} - -/** - * Create a progress bar - */ -export function createProgressBar( - completed: number, - total: number, - width: number = 30 -): string { - if (total === 0) { - return chalk.gray('No tasks'); - } - - const percentage = Math.round((completed / total) * 100); - const filled = Math.round((completed / total) * width); - const empty = width - filled; - - const bar = chalk.green('█').repeat(filled) + chalk.gray('░').repeat(empty); - - return `${bar} ${chalk.cyan(`${percentage}%`)} (${completed}/${total})`; -} - -/** - * Display a fancy banner - */ -export function displayBanner(title: string = 'Task Master'): void { - console.log( - boxen(chalk.white.bold(title), { - padding: 1, - margin: { top: 1, bottom: 1 }, - borderStyle: 'round', - borderColor: 'blue', - textAlignment: 'center' - }) - ); -} - -/** - * Display an error message (matches scripts/modules/ui.js style) - */ -export function displayError(message: string, details?: string): void { - const boxWidth = getBoxWidth(); - - console.error( - boxen( - chalk.red.bold('X Error: ') + - chalk.white(message) + - (details ? '\n\n' + chalk.gray(details) : ''), - { - padding: 1, - borderStyle: 'round', - borderColor: 'red', - width: boxWidth - } - ) - ); -} - -/** - * Display a success message - */ -export function displaySuccess(message: string): void { - const boxWidth = getBoxWidth(); - - console.log( - boxen( - chalk.green.bold(String.fromCharCode(8730) + ' ') + chalk.white(message), - { - padding: 1, - borderStyle: 'round', - borderColor: 'green', - width: boxWidth - } - ) - ); -} - -/** - * Display a warning message - */ -export function displayWarning(message: string): void { - const boxWidth = getBoxWidth(); - - console.log( - boxen(chalk.yellow.bold('⚠️ ') + chalk.white(message), { - padding: 1, - borderStyle: 'round', - borderColor: 'yellow', - width: boxWidth - }) - ); -} - -/** - * Display info message - */ -export function displayInfo(message: string): void { - const boxWidth = getBoxWidth(); - - console.log( - boxen(chalk.blue.bold('i ') + chalk.white(message), { - padding: 1, - borderStyle: 'round', - borderColor: 'blue', - width: boxWidth - }) - ); -} - -/** - * Format dependencies with their status - */ -export function formatDependenciesWithStatus( - dependencies: string[] | number[], - tasks: Task[] -): string { - if (!dependencies || dependencies.length === 0) { - return chalk.gray('none'); - } - - const taskMap = new Map(tasks.map((t) => [t.id.toString(), t])); - - return dependencies - .map((depId) => { - const task = taskMap.get(depId.toString()); - if (!task) { - return chalk.red(`${depId} (not found)`); - } - - const statusIcon = - task.status === 'done' - ? '✓' - : task.status === 'in-progress' - ? '►' - : '○'; - - return `${depId}${statusIcon}`; - }) - .join(', '); -} - -/** - * Create a task table for display - */ -export function createTaskTable( - tasks: (Task | Subtask)[], - options?: { - showSubtasks?: boolean; - showComplexity?: boolean; - showDependencies?: boolean; - } -): string { - const { - showSubtasks = false, - showComplexity = false, - showDependencies = true - } = options || {}; - - // Calculate dynamic column widths based on terminal width - const tableWidth = getBoxWidth(0.9, 100); - // Adjust column widths to better match the original layout - const baseColWidths = showComplexity - ? [ - Math.floor(tableWidth * 0.1), - Math.floor(tableWidth * 0.4), - Math.floor(tableWidth * 0.15), - Math.floor(tableWidth * 0.1), - Math.floor(tableWidth * 0.2), - Math.floor(tableWidth * 0.1) - ] // ID, Title, Status, Priority, Dependencies, Complexity - : [ - Math.floor(tableWidth * 0.08), - Math.floor(tableWidth * 0.4), - Math.floor(tableWidth * 0.18), - Math.floor(tableWidth * 0.12), - Math.floor(tableWidth * 0.2) - ]; // ID, Title, Status, Priority, Dependencies - - const headers = [ - chalk.blue.bold('ID'), - chalk.blue.bold('Title'), - chalk.blue.bold('Status'), - chalk.blue.bold('Priority') - ]; - const colWidths = baseColWidths.slice(0, 4); - - if (showDependencies) { - headers.push(chalk.blue.bold('Dependencies')); - colWidths.push(baseColWidths[4]); - } - - if (showComplexity) { - headers.push(chalk.blue.bold('Complexity')); - colWidths.push(baseColWidths[5] || 12); - } - - const table = new Table({ - head: headers, - style: { head: [], border: [] }, - colWidths, - wordWrap: true - }); - - tasks.forEach((task) => { - const row: string[] = [ - chalk.cyan(task.id.toString()), - truncate(task.title, colWidths[1] - 3), - getStatusWithColor(task.status, true), // Use table version - getPriorityWithColor(task.priority) - ]; - - if (showDependencies) { - // For table display, show simple format without status icons - if (!task.dependencies || task.dependencies.length === 0) { - row.push(chalk.gray('None')); - } else { - row.push( - chalk.cyan(task.dependencies.map((d) => String(d)).join(', ')) - ); - } - } - - if (showComplexity) { - // Show complexity score from report if available - if (typeof task.complexity === 'number') { - row.push(getComplexityWithColor(task.complexity)); - } else { - row.push(chalk.gray('N/A')); - } - } - - table.push(row); - - // Add subtasks if requested - if (showSubtasks && task.subtasks && task.subtasks.length > 0) { - task.subtasks.forEach((subtask) => { - const subRow: string[] = [ - chalk.gray(` └─ ${subtask.id}`), - chalk.gray(truncate(subtask.title, colWidths[1] - 6)), - chalk.gray(getStatusWithColor(subtask.status, true)), - chalk.gray(subtask.priority || 'medium') - ]; - - if (showDependencies) { - subRow.push( - chalk.gray( - subtask.dependencies && subtask.dependencies.length > 0 - ? subtask.dependencies.map((dep) => String(dep)).join(', ') - : 'None' - ) - ); - } - - if (showComplexity) { - const complexityDisplay = - typeof subtask.complexity === 'number' - ? getComplexityWithColor(subtask.complexity) - : '--'; - subRow.push(chalk.gray(complexityDisplay)); - } - - table.push(subRow); - }); - } - }); - - return table.toString(); -} +// Re-export everything from the new organized UI structure +export * from '../ui/index.js'; diff --git a/package-lock.json b/package-lock.json index e52cf8b7..9f970274 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28447,6 +28447,7 @@ "@tm/core": "*", "boxen": "^8.0.1", "chalk": "5.6.2", + "cli-table3": "0.6.5", "ora": "^8.1.1" }, "devDependencies": { diff --git a/packages/tm-bridge/package.json b/packages/tm-bridge/package.json index 9d8a948c..7aee605a 100644 --- a/packages/tm-bridge/package.json +++ b/packages/tm-bridge/package.json @@ -19,7 +19,8 @@ "@tm/core": "*", "chalk": "5.6.2", "boxen": "^8.0.1", - "ora": "^8.1.1" + "ora": "^8.1.1", + "cli-table3": "^0.6.5" }, "devDependencies": { "@types/node": "^22.10.5", diff --git a/packages/tm-bridge/src/add-tag-bridge.ts b/packages/tm-bridge/src/add-tag-bridge.ts new file mode 100644 index 00000000..92b4598c --- /dev/null +++ b/packages/tm-bridge/src/add-tag-bridge.ts @@ -0,0 +1,99 @@ +import { ui } from '@tm/cli'; +import type { BaseBridgeParams } from './bridge-types.js'; +import { checkStorageType } from './bridge-utils.js'; + +/** + * Parameters for the add-tag bridge function + */ +export interface AddTagBridgeParams extends BaseBridgeParams { + /** Tag name to create */ + tagName: string; +} + +/** + * Result returned when API storage redirects to web UI + */ +export interface RemoteAddTagResult { + success: boolean; + message: string; + redirectUrl: string; +} + +/** + * Shared bridge function for add-tag command. + * Checks if using API storage and redirects to web UI if so. + * + * For API storage, tags are called "briefs" and must be created + * through the Hamster web interface. + * + * @param params - Bridge parameters + * @returns Result object if API storage handled it, null if should fall through to file storage + */ +export async function tryAddTagViaRemote( + params: AddTagBridgeParams +): Promise { + const { + tagName, + projectRoot, + isMCP = false, + outputFormat = 'text', + report + } = params; + + // Check storage type using shared utility + const { isApiStorage, tmCore } = await checkStorageType( + projectRoot, + report, + 'falling back to file-based tag creation' + ); + + if (!isApiStorage || !tmCore) { + // Not API storage - signal caller to fall through to file-based logic + return null; + } + + // Get the brief creation URL from tmCore + const redirectUrl = tmCore.auth.getBriefCreationUrl(); + + if (!redirectUrl) { + report( + 'error', + 'Could not generate brief creation URL. Please ensure you have selected an organization using "tm context org"' + ); + return { + success: false, + message: + 'Failed to generate brief creation URL. Please ensure an organization is selected.', + redirectUrl: '' + }; + } + + // Show CLI output if not MCP + if (!isMCP && outputFormat === 'text') { + console.log( + ui.displayCardBox({ + header: '# Create a Brief in Hamster Studio', + body: [ + 'Your tags are separate task lists. When connected to Hamster,\ntask lists are attached to briefs.', + 'Create a new brief and its task list will automatically be\navailable when generated.' + ], + callToAction: { + label: 'Visit:', + action: redirectUrl + }, + footer: + 'To access tasks for a specific brief, use:\n' + + ' • tm briefs select \n' + + ' • tm briefs select \n' + + ' • tm briefs select (interactive)' + }) + ); + } + + // Return success result with redirect URL + return { + success: true, + message: `API storage detected. Please create tag "${tagName}" at: ${redirectUrl}`, + redirectUrl + }; +} diff --git a/packages/tm-bridge/src/bridge-types.ts b/packages/tm-bridge/src/bridge-types.ts new file mode 100644 index 00000000..f3f719b5 --- /dev/null +++ b/packages/tm-bridge/src/bridge-types.ts @@ -0,0 +1,46 @@ +/** + * Shared types and interfaces for bridge functions + */ + +/** + * Log levels used by bridge report functions + */ +export type LogLevel = 'info' | 'warn' | 'error' | 'debug' | 'success'; + +/** + * Report function signature used by all bridges + */ +export type ReportFunction = (level: LogLevel, ...args: unknown[]) => void; + +/** + * Output format for bridge results + */ +export type OutputFormat = 'text' | 'json'; + +/** + * Common parameters shared by all bridge functions + */ +export interface BaseBridgeParams { + /** Project root directory */ + projectRoot: string; + /** Whether called from MCP context (default: false) */ + isMCP?: boolean; + /** Output format (default: 'text') */ + outputFormat?: OutputFormat; + /** Logging function */ + report: ReportFunction; + /** Optional tag for task organization */ + tag?: string; +} + +/** + * Result from checking if API storage should handle an operation + */ +export interface StorageCheckResult { + /** Whether API storage is being used */ + isApiStorage: boolean; + /** TmCore instance if initialization succeeded */ + tmCore?: import('@tm/core').TmCore; + /** Error message if initialization failed */ + error?: string; +} diff --git a/packages/tm-bridge/src/bridge-utils.ts b/packages/tm-bridge/src/bridge-utils.ts new file mode 100644 index 00000000..e4317f45 --- /dev/null +++ b/packages/tm-bridge/src/bridge-utils.ts @@ -0,0 +1,69 @@ +/** + * Shared utility functions for bridge operations + */ + +import { type TmCore, createTmCore } from '@tm/core'; +import type { ReportFunction, StorageCheckResult } from './bridge-types.js'; + +/** + * Initialize TmCore and check if API storage is being used. + * + * This function encapsulates the common pattern used by all bridge functions: + * 1. Try to create TmCore instance + * 2. Check the storage type + * 3. Return results or handle errors gracefully + * + * @param projectRoot - Project root directory + * @param report - Logging function + * @param fallbackMessage - Message to log if TmCore initialization fails + * @returns Storage check result with TmCore instance if successful + * + * @example + * const { isApiStorage, tmCore } = await checkStorageType( + * projectRoot, + * report, + * 'falling back to file-based operation' + * ); + * + * if (!isApiStorage) { + * // Continue with file-based logic + * return null; + * } + */ +export async function checkStorageType( + projectRoot: string, + report: ReportFunction, + fallbackMessage = 'falling back to file-based operation' +): Promise { + let tmCore: TmCore; + + try { + tmCore = await createTmCore({ + projectPath: projectRoot || process.cwd() + }); + } catch (tmCoreError) { + const errorMessage = + tmCoreError instanceof Error ? tmCoreError.message : String(tmCoreError); + report('warn', `TmCore check failed, ${fallbackMessage}: ${errorMessage}`); + + return { + isApiStorage: false, + error: errorMessage + }; + } + + // Check if we're using API storage (use resolved storage type, not config) + const storageType = tmCore.tasks.getStorageType(); + + if (storageType !== 'api') { + return { + isApiStorage: false, + tmCore + }; + } + + return { + isApiStorage: true, + tmCore + }; +} diff --git a/packages/tm-bridge/src/expand-bridge.ts b/packages/tm-bridge/src/expand-bridge.ts index 9599a5bf..29440453 100644 --- a/packages/tm-bridge/src/expand-bridge.ts +++ b/packages/tm-bridge/src/expand-bridge.ts @@ -1,12 +1,13 @@ -import chalk from 'chalk'; import boxen from 'boxen'; +import chalk from 'chalk'; import ora from 'ora'; -import { createTmCore, type TmCore } from '@tm/core'; +import type { BaseBridgeParams } from './bridge-types.js'; +import { checkStorageType } from './bridge-utils.js'; /** * Parameters for the expand bridge function */ -export interface ExpandBridgeParams { +export interface ExpandBridgeParams extends BaseBridgeParams { /** Task ID (can be numeric "1" or alphanumeric "TAS-49") */ taskId: string | number; /** Number of subtasks to generate (optional) */ @@ -17,16 +18,6 @@ export interface ExpandBridgeParams { additionalContext?: string; /** Force regeneration even if subtasks exist */ force?: boolean; - /** Project root directory */ - projectRoot: string; - /** Optional tag for task organization */ - tag?: string; - /** Whether called from MCP context (default: false) */ - isMCP?: boolean; - /** Output format (default: 'text') */ - outputFormat?: 'text' | 'json'; - /** Logging function */ - report: (level: string, ...args: unknown[]) => void; } /** @@ -63,32 +54,15 @@ export async function tryExpandViaRemote( report } = params; - let tmCore: TmCore; + // Check storage type using shared utility + const { isApiStorage, tmCore } = await checkStorageType( + projectRoot, + report, + 'falling back to file-based expansion' + ); - try { - tmCore = await createTmCore({ - projectPath: projectRoot || process.cwd() - }); - } catch (tmCoreError) { - const errorMessage = - tmCoreError instanceof Error ? tmCoreError.message : String(tmCoreError); - report( - 'warn', - `TmCore check failed, falling back to file-based expansion: ${errorMessage}` - ); - // Return null to signal fall-through to file storage logic - return null; - } - - // Check if we're using API storage (use resolved storage type, not config) - const storageType = tmCore.tasks.getStorageType(); - - if (storageType !== 'api') { + if (!isApiStorage || !tmCore) { // Not API storage - signal caller to fall through to file-based logic - report( - 'debug', - `Using file storage - processing expansion locally for task ${taskId}` - ); return null; } diff --git a/packages/tm-bridge/src/index.ts b/packages/tm-bridge/src/index.ts index a848a8e1..3887b0b5 100644 --- a/packages/tm-bridge/src/index.ts +++ b/packages/tm-bridge/src/index.ts @@ -9,6 +9,18 @@ * DELETE THIS PACKAGE when legacy scripts are removed. */ +// Shared types and utilities +export type { + LogLevel, + ReportFunction, + OutputFormat, + BaseBridgeParams, + StorageCheckResult +} from './bridge-types.js'; + +export { checkStorageType } from './bridge-utils.js'; + +// Bridge functions export { tryUpdateViaRemote, type UpdateBridgeParams, @@ -20,3 +32,22 @@ export { type ExpandBridgeParams, type RemoteExpandResult } from './expand-bridge.js'; + +export { + tryListTagsViaRemote, + type TagsBridgeParams, + type RemoteTagsResult, + type TagInfo +} from './tags-bridge.js'; + +export { + tryUseTagViaRemote, + type UseTagBridgeParams, + type RemoteUseTagResult +} from './use-tag-bridge.js'; + +export { + tryAddTagViaRemote, + type AddTagBridgeParams, + type RemoteAddTagResult +} from './add-tag-bridge.js'; diff --git a/packages/tm-bridge/src/tags-bridge.ts b/packages/tm-bridge/src/tags-bridge.ts new file mode 100644 index 00000000..41ad9083 --- /dev/null +++ b/packages/tm-bridge/src/tags-bridge.ts @@ -0,0 +1,160 @@ +import { ui } from '@tm/cli'; +import boxen from 'boxen'; +import chalk from 'chalk'; +import Table from 'cli-table3'; +import type { TagInfo } from '@tm/core'; +import type { BaseBridgeParams } from './bridge-types.js'; +import { checkStorageType } from './bridge-utils.js'; + +// Re-export for convenience +export type { TagInfo }; + +/** + * Parameters for the tags bridge function + */ +export interface TagsBridgeParams extends BaseBridgeParams { + /** Whether to show metadata (default: false) */ + showMetadata?: boolean; +} + +/** + * Result returned when API storage handles the tags listing + */ +export interface RemoteTagsResult { + success: boolean; + tags: TagInfo[]; + currentTag: string | null; + totalTags: number; + message: string; +} + +/** + * Shared bridge function for list-tags command. + * Checks if using API storage and delegates to remote service if so. + * + * For API storage, tags are called "briefs" and task counts are fetched + * from the remote database. + * + * @param params - Bridge parameters + * @returns Result object if API storage handled it, null if should fall through to file storage + */ +export async function tryListTagsViaRemote( + params: TagsBridgeParams +): Promise { + const { projectRoot, isMCP = false, outputFormat = 'text', report } = params; + + // Check storage type using shared utility + const { isApiStorage, tmCore } = await checkStorageType( + projectRoot, + report, + 'falling back to file-based tags' + ); + + if (!isApiStorage || !tmCore) { + // Not API storage - signal caller to fall through to file-based logic + return null; + } + + try { + // Get tags with statistics from tm-core + // Tags are already sorted by status and updatedAt from brief-service + const tagsResult = await tmCore.tasks.getTagsWithStats(); + + // Sort tags: current tag first, then preserve status/updatedAt ordering from service + tagsResult.tags.sort((a, b) => { + // Always keep current tag at the top + if (a.isCurrent) return -1; + if (b.isCurrent) return 1; + // For non-current tags, preserve the status/updatedAt ordering already applied + return 0; + }); + + if (outputFormat === 'text' && !isMCP) { + // Display results in a table format + if (tagsResult.tags.length === 0) { + console.log( + boxen(chalk.yellow('No tags found'), { + padding: 1, + borderColor: 'yellow', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + }) + ); + } else { + // Create table headers (with temporary Updated column) + const headers = [ + chalk.cyan.bold('Tag Name'), + chalk.cyan.bold('Status'), + chalk.cyan.bold('Updated'), + chalk.cyan.bold('Tasks'), + chalk.cyan.bold('Completed') + ]; + + // Calculate dynamic column widths based on terminal width + const terminalWidth = Math.max( + (process.stdout.columns as number) || 120, + 80 + ); + const usableWidth = Math.floor(terminalWidth * 0.95); + + // Column order: Tag Name, Status, Updated, Tasks, Completed + const widths = [0.35, 0.25, 0.2, 0.1, 0.1]; + const colWidths = widths.map((w, i) => + Math.max(Math.floor(usableWidth * w), i === 0 ? 20 : 8) + ); + + const table = new Table({ + head: headers, + colWidths: colWidths, + wordWrap: true + }); + + // Add rows + tagsResult.tags.forEach((tag) => { + const row = []; + + // Tag name with current indicator and short ID (last 8 chars) + const shortId = tag.briefId ? tag.briefId.slice(-8) : 'unknown'; + const tagDisplay = tag.isCurrent + ? `${chalk.green('●')} ${chalk.green.bold(tag.name)} ${chalk.gray(`(current - ${shortId})`)}` + : ` ${tag.name} ${chalk.gray(`(${shortId})`)}`; + row.push(tagDisplay); + + row.push(ui.getBriefStatusWithColor(tag.status, true)); + + // Updated date (temporary for validation) + const updatedDate = tag.updatedAt + ? new Date(tag.updatedAt).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + : chalk.gray('N/A'); + row.push(chalk.gray(updatedDate)); + + // Task counts + row.push(chalk.white(tag.taskCount.toString())); + row.push(chalk.green(tag.completedTasks.toString())); + + table.push(row); + }); + + console.log(table.toString()); + } + } + + // Return success result - signals that we handled it + return { + success: true, + tags: tagsResult.tags, + currentTag: tagsResult.currentTag, + totalTags: tagsResult.totalTags, + message: `Found ${tagsResult.totalTags} tag(s)` + }; + } catch (error) { + // tm-core already formatted the error properly, just re-throw + throw error; + } +} diff --git a/packages/tm-bridge/src/update-bridge.ts b/packages/tm-bridge/src/update-bridge.ts index 3ae499f7..5c555f53 100644 --- a/packages/tm-bridge/src/update-bridge.ts +++ b/packages/tm-bridge/src/update-bridge.ts @@ -1,28 +1,19 @@ -import chalk from 'chalk'; import boxen from 'boxen'; +import chalk from 'chalk'; import ora from 'ora'; -import { createTmCore, type TmCore } from '@tm/core'; +import type { BaseBridgeParams } from './bridge-types.js'; +import { checkStorageType } from './bridge-utils.js'; /** * Parameters for the update bridge function */ -export interface UpdateBridgeParams { +export interface UpdateBridgeParams extends BaseBridgeParams { /** Task ID (can be numeric "1", alphanumeric "TAS-49", or dotted "1.2" or "TAS-49.1") */ taskId: string | number; /** Update prompt for AI */ prompt: string; - /** Project root directory */ - projectRoot: string; - /** Optional tag for task organization */ - tag?: string; /** Whether to append or full update (default: false) */ appendMode?: boolean; - /** Whether called from MCP context (default: false) */ - isMCP?: boolean; - /** Output format (default: 'text') */ - outputFormat?: 'text' | 'json'; - /** Logging function */ - report: (level: string, ...args: unknown[]) => void; } /** @@ -61,32 +52,15 @@ export async function tryUpdateViaRemote( report } = params; - let tmCore: TmCore; + // Check storage type using shared utility + const { isApiStorage, tmCore } = await checkStorageType( + projectRoot, + report, + 'falling back to file-based update' + ); - try { - tmCore = await createTmCore({ - projectPath: projectRoot || process.cwd() - }); - } catch (tmCoreError) { - const errorMessage = - tmCoreError instanceof Error ? tmCoreError.message : String(tmCoreError); - report( - 'warn', - `TmCore check failed, falling back to file-based update: ${errorMessage}` - ); - // Return null to signal fall-through to file storage logic - return null; - } - - // Check if we're using API storage (use resolved storage type, not config) - const storageType = tmCore.tasks.getStorageType(); - - if (storageType !== 'api') { + if (!isApiStorage || !tmCore) { // Not API storage - signal caller to fall through to file-based logic - report( - 'info', - `Using file storage - processing update locally for task ${taskId}` - ); return null; } diff --git a/packages/tm-bridge/src/use-tag-bridge.ts b/packages/tm-bridge/src/use-tag-bridge.ts new file mode 100644 index 00000000..10d15333 --- /dev/null +++ b/packages/tm-bridge/src/use-tag-bridge.ts @@ -0,0 +1,145 @@ +import boxen from 'boxen'; +import chalk from 'chalk'; +import ora from 'ora'; +import type { BaseBridgeParams } from './bridge-types.js'; +import { checkStorageType } from './bridge-utils.js'; + +/** + * Parameters for the use-tag bridge function + */ +export interface UseTagBridgeParams extends BaseBridgeParams { + /** Tag name to switch to */ + tagName: string; +} + +/** + * Result returned when API storage handles the tag switch + */ +export interface RemoteUseTagResult { + success: boolean; + previousTag: string | null; + currentTag: string; + switched: boolean; + taskCount: number; + message: string; +} + +/** + * Shared bridge function for use-tag command. + * Checks if using API storage and delegates to remote service if so. + * + * For API storage, tags are called "briefs" and switching tags means + * changing the current brief context. + * + * @param params - Bridge parameters + * @returns Result object if API storage handled it, null if should fall through to file storage + */ +export async function tryUseTagViaRemote( + params: UseTagBridgeParams +): Promise { + const { + tagName, + projectRoot, + isMCP = false, + outputFormat = 'text', + report + } = params; + + // Check storage type using shared utility + const { isApiStorage, tmCore } = await checkStorageType( + projectRoot, + report, + 'falling back to file-based tag switching' + ); + + if (!isApiStorage || !tmCore) { + // Not API storage - signal caller to fall through to file-based logic + return null; + } + + // API STORAGE PATH: Switch brief in Hamster + report('info', `Switching to tag (brief) "${tagName}" in Hamster`); + + // Show CLI output if not MCP + if (!isMCP && outputFormat === 'text') { + console.log( + boxen(chalk.blue.bold(`Switching Tag in Hamster`), { + padding: 1, + borderColor: 'blue', + borderStyle: 'round', + margin: { top: 1, bottom: 1 } + }) + ); + } + + const spinner = + !isMCP && outputFormat === 'text' + ? ora({ text: `Switching to tag "${tagName}"...`, color: 'cyan' }).start() + : null; + + try { + // Get current context before switching + const previousContext = tmCore.auth.getContext(); + const previousTag = previousContext?.briefName || null; + + // Switch to the new tag/brief + // This will look up the brief by name and update the context + await tmCore.tasks.switchTag(tagName); + + // Get updated context after switching + const newContext = tmCore.auth.getContext(); + const currentTag = newContext?.briefName || tagName; + + // Get task count for the new tag + const tasks = await tmCore.tasks.list(); + const taskCount = tasks.tasks.length; + + if (spinner) { + spinner.succeed(`Switched to tag "${currentTag}"`); + } + + if (outputFormat === 'text' && !isMCP) { + // Display success message + const briefId = newContext?.briefId + ? newContext.briefId.slice(-8) + : 'unknown'; + console.log( + boxen( + chalk.green.bold('✓ Tag Switched Successfully') + + '\n\n' + + (previousTag + ? chalk.white(`Previous Tag: ${chalk.cyan(previousTag)}\n`) + : '') + + chalk.white(`Current Tag: ${chalk.green.bold(currentTag)}`) + + '\n' + + chalk.gray(`Brief ID: ${briefId}`) + + '\n' + + chalk.white(`Available Tasks: ${chalk.yellow(taskCount)}`), + { + padding: 1, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1, bottom: 0 } + } + ) + ); + } + + // Return success result - signals that we handled it + return { + success: true, + previousTag, + currentTag, + switched: true, + taskCount, + message: `Successfully switched to tag "${currentTag}"` + }; + } catch (error) { + if (spinner) { + spinner.fail('Failed to switch tag'); + } + + // tm-core already formatted the error properly, just re-throw + throw error; + } +} diff --git a/packages/tm-core/src/common/constants/index.ts b/packages/tm-core/src/common/constants/index.ts index e72bcf00..aa976fb6 100644 --- a/packages/tm-core/src/common/constants/index.ts +++ b/packages/tm-core/src/common/constants/index.ts @@ -4,11 +4,25 @@ */ import type { - TaskStatus, + TaskComplexity, TaskPriority, - TaskComplexity + TaskStatus } from '../types/index.js'; +// Import from root package.json (monorepo root) for version info +import packageJson from '../../../../../package.json' with { type: 'json' }; + +/** + * Task Master version from root package.json + * Centralized to avoid fragile relative paths throughout the codebase + */ +export const TASKMASTER_VERSION = packageJson.version || 'unknown'; + +/** + * Package name from root package.json + */ +export const PACKAGE_NAME = packageJson.name || 'task-master-ai'; + /** * Valid task status values */ diff --git a/packages/tm-core/src/common/interfaces/configuration.interface.ts b/packages/tm-core/src/common/interfaces/configuration.interface.ts index 17dd8988..d73d89da 100644 --- a/packages/tm-core/src/common/interfaces/configuration.interface.ts +++ b/packages/tm-core/src/common/interfaces/configuration.interface.ts @@ -4,9 +4,9 @@ */ import type { + StorageType, TaskComplexity, - TaskPriority, - StorageType + TaskPriority } from '../types/index.js'; /** diff --git a/packages/tm-core/src/common/interfaces/storage.interface.ts b/packages/tm-core/src/common/interfaces/storage.interface.ts index d3c228ac..80f543f9 100644 --- a/packages/tm-core/src/common/interfaces/storage.interface.ts +++ b/packages/tm-core/src/common/interfaces/storage.interface.ts @@ -3,8 +3,8 @@ * This file defines the contract for all storage implementations */ -import type { Task, TaskMetadata, TaskStatus } from '../types/index.js'; import type { ExpandTaskResult } from '../../modules/integration/services/task-expansion.service.js'; +import type { Task, TaskMetadata, TaskStatus } from '../types/index.js'; /** * Options for loading tasks from storage @@ -164,6 +164,19 @@ export interface IStorage { */ getAllTags(): Promise; + /** + * Create a new tag + * @param tagName - Name of the tag to create + * @param options - Creation options + * @param options.copyFrom - Tag to copy tasks from (optional) + * @param options.description - Tag description (optional) + * @returns Promise that resolves when creation is complete + */ + createTag( + tagName: string, + options?: { copyFrom?: string; description?: string } + ): Promise; + /** * Delete all tasks and metadata for a specific tag * @param tag - Tag to delete @@ -216,6 +229,57 @@ export interface IStorage { * @returns The brief name if using API storage with a selected brief, null otherwise */ getCurrentBriefName(): string | null; + + /** + * Get all tags with detailed statistics including task counts + * @returns Promise that resolves to tags with statistics + */ + getTagsWithStats(): Promise; +} + +/** + * Tag information with detailed statistics + */ +export interface TagInfo { + /** Tag/Brief name */ + name: string; + /** Whether this is the current/active tag */ + isCurrent: boolean; + /** Total number of tasks in this tag */ + taskCount: number; + /** Number of completed tasks */ + completedTasks: number; + /** Breakdown of tasks by status */ + statusBreakdown: Record; + /** Subtask counts if available */ + subtaskCounts?: { + totalSubtasks: number; + subtasksByStatus: Record; + }; + /** Tag creation date */ + created?: string; + + /** Tag last modified date */ + updatedAt?: string; + + /** Tag description */ + description?: string; + /** Brief/Tag status (for API storage briefs) */ + status?: string; + /** Brief ID/UUID (for API storage) */ + briefId?: string; +} + +/** + * Result returned by getTagsWithStats + */ +export interface TagsWithStatsResult { + /** List of tags with statistics */ + tags: TagInfo[]; + /** Current active tag name */ + currentTag: string | null; + /** Total number of tags */ + totalTags: number; } /** @@ -303,6 +367,10 @@ export abstract class BaseStorage implements IStorage { abstract loadMetadata(tag?: string): Promise; abstract saveMetadata(metadata: TaskMetadata, tag?: string): Promise; abstract getAllTags(): Promise; + abstract createTag( + tagName: string, + options?: { copyFrom?: string; description?: string } + ): Promise; abstract deleteTag(tag: string): Promise; abstract renameTag(oldTag: string, newTag: string): Promise; abstract copyTag(sourceTag: string, targetTag: string): Promise; @@ -311,6 +379,7 @@ export abstract class BaseStorage implements IStorage { abstract getStats(): Promise; abstract getStorageType(): 'file' | 'api'; abstract getCurrentBriefName(): string | null; + abstract getTagsWithStats(): Promise; /** * Utility method to generate backup filename * @param originalPath - Original file path diff --git a/packages/tm-core/src/common/logger/logger.spec.ts b/packages/tm-core/src/common/logger/logger.spec.ts index 43cfb304..3b7e64d1 100644 --- a/packages/tm-core/src/common/logger/logger.spec.ts +++ b/packages/tm-core/src/common/logger/logger.spec.ts @@ -2,8 +2,8 @@ * @fileoverview Tests for MCP logging integration */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { Logger, LogLevel, type LogCallback } from './logger.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type LogCallback, LogLevel, Logger } from './logger.js'; describe('Logger - MCP Integration', () => { // Store original environment diff --git a/packages/tm-core/src/common/mappers/TaskMapper.test.ts b/packages/tm-core/src/common/mappers/TaskMapper.test.ts index e033197e..9bdfcc6f 100644 --- a/packages/tm-core/src/common/mappers/TaskMapper.test.ts +++ b/packages/tm-core/src/common/mappers/TaskMapper.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi } from 'vitest'; -import { TaskMapper } from './TaskMapper.js'; +import { describe, expect, it, vi } from 'vitest'; import type { Tables } from '../types/database.types.js'; +import { TaskMapper } from './TaskMapper.js'; type TaskRow = Tables<'tasks'>; diff --git a/packages/tm-core/src/common/mappers/TaskMapper.ts b/packages/tm-core/src/common/mappers/TaskMapper.ts index 70f11619..303dc364 100644 --- a/packages/tm-core/src/common/mappers/TaskMapper.ts +++ b/packages/tm-core/src/common/mappers/TaskMapper.ts @@ -1,5 +1,5 @@ -import { Task, Subtask } from '../types/index.js'; import { Database, Tables } from '../types/database.types.js'; +import { Subtask, Task } from '../types/index.js'; type TaskRow = Tables<'tasks'>; diff --git a/packages/tm-core/src/common/types/index.ts b/packages/tm-core/src/common/types/index.ts index b9848ee1..43d00ed0 100644 --- a/packages/tm-core/src/common/types/index.ts +++ b/packages/tm-core/src/common/types/index.ts @@ -105,6 +105,8 @@ export interface TaskMetadata { projectName?: string; description?: string; tags?: string[]; + created?: string; + updated?: string; } /** diff --git a/packages/tm-core/src/common/utils/index.ts b/packages/tm-core/src/common/utils/index.ts index 3c6bf1a5..42b683a4 100644 --- a/packages/tm-core/src/common/utils/index.ts +++ b/packages/tm-core/src/common/utils/index.ts @@ -53,6 +53,9 @@ export { normalizeProjectRoot } from './project-root-finder.js'; +// Export path construction utilities +export { getProjectPaths } from './path-helpers.js'; + // Additional utility exports /** diff --git a/packages/tm-core/src/common/utils/path-helpers.ts b/packages/tm-core/src/common/utils/path-helpers.ts new file mode 100644 index 00000000..d3c5749f --- /dev/null +++ b/packages/tm-core/src/common/utils/path-helpers.ts @@ -0,0 +1,40 @@ +/** + * @fileoverview Path construction utilities for Task Master + * Provides standardized paths for Task Master project structure + */ + +import { join, resolve } from 'node:path'; +import { TASKMASTER_TASKS_FILE } from '../constants/paths.js'; +import { findProjectRoot } from './project-root-finder.js'; + +/** + * Get standard Task Master project paths + * Automatically detects project root using smart detection + * + * @param projectPath - Optional explicit project path (if not provided, auto-detects) + * @returns Object with projectRoot and tasksPath + * + * @example + * ```typescript + * // Auto-detect project root + * const { projectRoot, tasksPath } = getProjectPaths(); + * + * // Or specify explicit path + * const { projectRoot, tasksPath } = getProjectPaths('./my-project'); + * // projectRoot: '/absolute/path/to/my-project' + * // tasksPath: '/absolute/path/to/my-project/.taskmaster/tasks/tasks.json' + * ``` + */ +export function getProjectPaths(projectPath?: string): { + projectRoot: string; + tasksPath: string; +} { + const projectRoot = projectPath + ? resolve(process.cwd(), projectPath) + : findProjectRoot(); + + return { + projectRoot, + tasksPath: join(projectRoot, TASKMASTER_TASKS_FILE) + }; +} diff --git a/packages/tm-core/src/common/utils/path-normalizer.spec.ts b/packages/tm-core/src/common/utils/path-normalizer.spec.ts index 5ba815d6..1dadda46 100644 --- a/packages/tm-core/src/common/utils/path-normalizer.spec.ts +++ b/packages/tm-core/src/common/utils/path-normalizer.spec.ts @@ -1,8 +1,8 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { - normalizeProjectPath, denormalizeProjectPath, - isValidNormalizedPath + isValidNormalizedPath, + normalizeProjectPath } from './path-normalizer.js'; describe('Path Normalizer (base64url encoding)', () => { diff --git a/packages/tm-core/src/common/utils/run-id-generator.spec.ts b/packages/tm-core/src/common/utils/run-id-generator.spec.ts index 9dd6ca32..434092ed 100644 --- a/packages/tm-core/src/common/utils/run-id-generator.spec.ts +++ b/packages/tm-core/src/common/utils/run-id-generator.spec.ts @@ -1,9 +1,9 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { + compareRunIds, generateRunId, isValidRunId, - parseRunId, - compareRunIds + parseRunId } from './run-id-generator.js'; describe('Run ID Generator', () => { diff --git a/packages/tm-core/src/index.ts b/packages/tm-core/src/index.ts index d0a706b4..639aa4fc 100644 --- a/packages/tm-core/src/index.ts +++ b/packages/tm-core/src/index.ts @@ -36,6 +36,12 @@ export type * from './common/types/index.js'; // Common interfaces export type * from './common/interfaces/index.js'; +// Storage interfaces - TagInfo and TagsWithStatsResult +export type { + TagInfo, + TagsWithStatsResult +} from './common/interfaces/storage.interface.js'; + // Constants export * from './common/constants/index.js'; @@ -75,6 +81,10 @@ export type { } from './modules/auth/types.js'; export { AuthenticationError } from './modules/auth/types.js'; +// Brief types +export type { Brief } from './modules/briefs/types.js'; +export type { TagWithStats } from './modules/briefs/services/brief-service.js'; + // Workflow types export type { StartWorkflowOptions, @@ -112,6 +122,10 @@ export type { // Auth - Advanced export { AuthManager } from './modules/auth/managers/auth-manager.js'; +// Briefs - Advanced +export { BriefsDomain } from './modules/briefs/briefs-domain.js'; +export { BriefService } from './modules/briefs/services/brief-service.js'; + // Workflow - Advanced export { WorkflowOrchestrator } from './modules/workflow/orchestrators/workflow-orchestrator.js'; export { WorkflowStateManager } from './modules/workflow/managers/workflow-state-manager.js'; diff --git a/packages/tm-core/src/modules/ai/providers/base-provider.ts b/packages/tm-core/src/modules/ai/providers/base-provider.ts index 5db0477b..fa78b3c5 100644 --- a/packages/tm-core/src/modules/ai/providers/base-provider.ts +++ b/packages/tm-core/src/modules/ai/providers/base-provider.ts @@ -8,12 +8,12 @@ import { TaskMasterError } from '../../../common/errors/task-master-error.js'; import type { + AIModel, AIOptions, AIResponse, IAIProvider, - ProviderUsageStats, ProviderInfo, - AIModel + ProviderUsageStats } from '../interfaces/ai-provider.interface.js'; // Constants for retry logic diff --git a/packages/tm-core/src/modules/auth/auth-domain.spec.ts b/packages/tm-core/src/modules/auth/auth-domain.spec.ts new file mode 100644 index 00000000..f3b8adfd --- /dev/null +++ b/packages/tm-core/src/modules/auth/auth-domain.spec.ts @@ -0,0 +1,111 @@ +/** + * Auth Domain tests + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { AuthDomain } from './auth-domain.js'; + +describe('AuthDomain', () => { + let authDomain: AuthDomain; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + // Save original environment + originalEnv = { ...process.env }; + authDomain = new AuthDomain(); + }); + + afterEach(() => { + // Restore original environment + process.env = originalEnv; + vi.clearAllMocks(); + }); + + describe('getBriefCreationUrl', () => { + it('should return null if no base domain is configured', () => { + // Clear environment variables + delete process.env.TM_BASE_DOMAIN; + delete process.env.TM_PUBLIC_BASE_DOMAIN; + + // Create fresh instance with cleared env + const domain = new AuthDomain(); + const url = domain.getBriefCreationUrl(); + + expect(url).toBeNull(); + }); + + it('should return null if org slug is not available in context', () => { + // Set base domain but context will have no orgSlug + process.env.TM_BASE_DOMAIN = 'localhost:8080'; + + const domain = new AuthDomain(); + // Mock getContext to return null (no context set) + vi.spyOn(domain, 'getContext').mockReturnValue(null); + + const url = domain.getBriefCreationUrl(); + + expect(url).toBeNull(); + }); + + it('should construct URL with http protocol for localhost', () => { + process.env.TM_BASE_DOMAIN = 'localhost:8080'; + + // Mock getContext to return a context with orgSlug + const domain = new AuthDomain(); + vi.spyOn(domain, 'getContext').mockReturnValue({ + orgSlug: 'test-org', + updatedAt: new Date().toISOString() + }); + + const url = domain.getBriefCreationUrl(); + + expect(url).toBe('http://localhost:8080/home/test-org/briefs/create'); + }); + + it('should construct URL with https protocol for production domain', () => { + process.env.TM_BASE_DOMAIN = 'tryhamster.com'; + + const domain = new AuthDomain(); + vi.spyOn(domain, 'getContext').mockReturnValue({ + orgSlug: 'acme-corp', + updatedAt: new Date().toISOString() + }); + + const url = domain.getBriefCreationUrl(); + + expect(url).toBe('https://tryhamster.com/home/acme-corp/briefs/create'); + }); + + it('should use existing protocol if base domain includes it', () => { + process.env.TM_BASE_DOMAIN = 'https://staging.hamster.dev'; + + const domain = new AuthDomain(); + vi.spyOn(domain, 'getContext').mockReturnValue({ + orgSlug: 'staging-org', + updatedAt: new Date().toISOString() + }); + + const url = domain.getBriefCreationUrl(); + + expect(url).toBe( + 'https://staging.hamster.dev/home/staging-org/briefs/create' + ); + }); + + it('should prefer TM_BASE_DOMAIN over TM_PUBLIC_BASE_DOMAIN', () => { + process.env.TM_BASE_DOMAIN = 'localhost:8080'; + process.env.TM_PUBLIC_BASE_DOMAIN = 'tryhamster.com'; + + const domain = new AuthDomain(); + vi.spyOn(domain, 'getContext').mockReturnValue({ + orgSlug: 'my-org', + updatedAt: new Date().toISOString() + }); + + const url = domain.getBriefCreationUrl(); + + // Should use TM_BASE_DOMAIN (localhost), not TM_PUBLIC_BASE_DOMAIN + expect(url).toBe('http://localhost:8080/home/my-org/briefs/create'); + }); + }); +}); diff --git a/packages/tm-core/src/modules/auth/auth-domain.ts b/packages/tm-core/src/modules/auth/auth-domain.ts index 10f38dc3..74f0bb8f 100644 --- a/packages/tm-core/src/modules/auth/auth-domain.ts +++ b/packages/tm-core/src/modules/auth/auth-domain.ts @@ -4,18 +4,18 @@ */ import path from 'node:path'; +import type { StorageType } from '../../common/types/index.js'; +import type { Brief } from '../briefs/types.js'; import { AuthManager } from './managers/auth-manager.js'; +import type { + Organization, + RemoteTask +} from './services/organization.service.js'; import type { AuthCredentials, OAuthFlowOptions, UserContext } from './types.js'; -import type { - Organization, - Brief, - RemoteTask -} from './services/organization.service.js'; -import type { StorageType } from '../../common/types/index.js'; /** * Display information for storage context @@ -210,6 +210,21 @@ export class AuthDomain { }; } + /** + * Get the URL for creating a new brief in the web UI + * Returns null if not using API storage or if org slug is not available + */ + getBriefCreationUrl(): string | null { + const context = this.getContext(); + const baseUrl = this.getWebAppUrl(); + + if (!baseUrl || !context?.orgSlug) { + return null; + } + + return `${baseUrl}/home/${context.orgSlug}/briefs/create`; + } + /** * Get web app base URL from environment configuration * @private diff --git a/packages/tm-core/src/modules/auth/config.ts b/packages/tm-core/src/modules/auth/config.ts index 0a780c2e..5eda0c05 100644 --- a/packages/tm-core/src/modules/auth/config.ts +++ b/packages/tm-core/src/modules/auth/config.ts @@ -6,34 +6,54 @@ import os from 'os'; import path from 'path'; import { AuthConfig } from './types.js'; -// Single base domain for all URLs -// Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*) -// Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsdown's env option -// Runtime: process.env.TM_BASE_DOMAIN can override for staging/development -// Default: https://tryhamster.com for production -const BASE_DOMAIN = - process.env.TM_BASE_DOMAIN || // Runtime override (for staging/tux) - process.env.TM_PUBLIC_BASE_DOMAIN; // Build-time (baked into compiled code) +/** + * Get the base domain from environment variables + * Evaluated lazily to allow dotenv to load first + * Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*) + * Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsdown's env option + * Runtime: process.env.TM_BASE_DOMAIN can override for staging/development + * Default: https://tryhamster.com for production + */ +function getBaseDomain(): string { + return ( + process.env.TM_BASE_DOMAIN || // Runtime override (for staging/tux) + process.env.TM_PUBLIC_BASE_DOMAIN || // Build-time (baked into compiled code) + 'https://tryhamster.com' // Fallback default + ); +} /** - * Default authentication configuration + * Get default authentication configuration * All URL configuration is derived from the single BASE_DOMAIN + * Evaluated lazily to allow dotenv to load environment variables first */ -export const DEFAULT_AUTH_CONFIG: AuthConfig = { - // Base domain for all services - baseUrl: BASE_DOMAIN!, +function getDefaultAuthConfig(): AuthConfig { + return { + // Base domain for all services + baseUrl: getBaseDomain(), - // Configuration directory and file paths - configDir: path.join(os.homedir(), '.taskmaster'), - configFile: path.join(os.homedir(), '.taskmaster', 'auth.json') -}; + // Configuration directory and file paths + configDir: path.join(os.homedir(), '.taskmaster'), + configFile: path.join(os.homedir(), '.taskmaster', 'auth.json') + }; +} /** * Get merged configuration with optional overrides */ export function getAuthConfig(overrides?: Partial): AuthConfig { return { - ...DEFAULT_AUTH_CONFIG, + ...getDefaultAuthConfig(), ...overrides }; } + +/** + * Default authentication configuration (exported for backward compatibility) + * Note: This is now a getter property that evaluates lazily + */ +export const DEFAULT_AUTH_CONFIG: AuthConfig = new Proxy({} as AuthConfig, { + get(_target, prop) { + return getDefaultAuthConfig()[prop as keyof AuthConfig]; + } +}); diff --git a/packages/tm-core/src/modules/auth/index.ts b/packages/tm-core/src/modules/auth/index.ts index 0396a494..665b7e3d 100644 --- a/packages/tm-core/src/modules/auth/index.ts +++ b/packages/tm-core/src/modules/auth/index.ts @@ -9,7 +9,6 @@ export { OAuthService } from './services/oauth-service.js'; export { SupabaseSessionStorage } from './services/supabase-session-storage.js'; export type { Organization, - Brief, RemoteTask } from './services/organization.service.js'; diff --git a/packages/tm-core/src/modules/auth/managers/auth-manager.spec.ts b/packages/tm-core/src/modules/auth/managers/auth-manager.spec.ts index d18b2d86..16bfe7a4 100644 --- a/packages/tm-core/src/modules/auth/managers/auth-manager.spec.ts +++ b/packages/tm-core/src/modules/auth/managers/auth-manager.spec.ts @@ -2,7 +2,7 @@ * Tests for AuthManager singleton behavior */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mock the logger to verify warnings (must be hoisted before SUT import) const mockLogger = { diff --git a/packages/tm-core/src/modules/auth/managers/auth-manager.ts b/packages/tm-core/src/modules/auth/managers/auth-manager.ts index b4b55bf6..29f739f7 100644 --- a/packages/tm-core/src/modules/auth/managers/auth-manager.ts +++ b/packages/tm-core/src/modules/auth/managers/auth-manager.ts @@ -2,31 +2,31 @@ * Authentication manager for Task Master CLI */ -import { - AuthCredentials, - OAuthFlowOptions, - AuthenticationError, - AuthConfig, - UserContext, - UserContextWithBrief -} from '../types.js'; -import { ContextStore } from '../services/context-store.js'; -import { OAuthService } from '../services/oauth-service.js'; -import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js'; -import { - OrganizationService, - type Organization, - type Brief, - type RemoteTask -} from '../services/organization.service.js'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { ERROR_CODES, TaskMasterError } from '../../../common/errors/task-master-error.js'; import { getLogger } from '../../../common/logger/index.js'; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; +import type { Brief } from '../../briefs/types.js'; +import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js'; +import { ContextStore } from '../services/context-store.js'; +import { OAuthService } from '../services/oauth-service.js'; +import { + type Organization, + OrganizationService, + type RemoteTask +} from '../services/organization.service.js'; +import { + AuthConfig, + AuthCredentials, + AuthenticationError, + OAuthFlowOptions, + UserContext, + UserContextWithBrief +} from '../types.js'; /** * Authentication manager class diff --git a/packages/tm-core/src/modules/auth/services/context-store.ts b/packages/tm-core/src/modules/auth/services/context-store.ts index 1a1ce69d..5b5f1ab1 100644 --- a/packages/tm-core/src/modules/auth/services/context-store.ts +++ b/packages/tm-core/src/modules/auth/services/context-store.ts @@ -11,8 +11,8 @@ import fs from 'fs'; import path from 'path'; -import { UserContext, AuthenticationError } from '../types.js'; import { getLogger } from '../../../common/logger/index.js'; +import { AuthenticationError, UserContext } from '../types.js'; const DEFAULT_CONTEXT_FILE = path.join( process.env.HOME || process.env.USERPROFILE || '~', diff --git a/packages/tm-core/src/modules/auth/services/oauth-service.ts b/packages/tm-core/src/modules/auth/services/oauth-service.ts index 81f197c1..b289c7cc 100644 --- a/packages/tm-core/src/modules/auth/services/oauth-service.ts +++ b/packages/tm-core/src/modules/auth/services/oauth-service.ts @@ -2,23 +2,23 @@ * OAuth 2.0 Authorization Code Flow service */ -import http from 'http'; -import { URL } from 'url'; import crypto from 'crypto'; +import http from 'http'; import os from 'os'; -import { - AuthCredentials, - AuthenticationError, - OAuthFlowOptions, - AuthConfig, - CliData -} from '../types.js'; -import { ContextStore } from '../services/context-store.js'; +import { URL } from 'url'; +import { Session } from '@supabase/supabase-js'; +import { TASKMASTER_VERSION } from '../../../common/constants/index.js'; +import { getLogger } from '../../../common/logger/index.js'; import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js'; import { getAuthConfig } from '../config.js'; -import { getLogger } from '../../../common/logger/index.js'; -import packageJson from '../../../../../../package.json' with { type: 'json' }; -import { Session } from '@supabase/supabase-js'; +import { ContextStore } from '../services/context-store.js'; +import { + AuthConfig, + AuthCredentials, + AuthenticationError, + CliData, + OAuthFlowOptions +} from '../types.js'; export class OAuthService { private logger = getLogger('OAuthService'); @@ -417,10 +417,10 @@ export class OAuthService { } /** - * Get CLI version from package.json if available + * Get CLI version from centralized constants */ private getCliVersion(): string { - return packageJson.version || 'unknown'; + return TASKMASTER_VERSION; } /** diff --git a/packages/tm-core/src/modules/auth/services/organization.service.ts b/packages/tm-core/src/modules/auth/services/organization.service.ts index 1868156a..11956833 100644 --- a/packages/tm-core/src/modules/auth/services/organization.service.ts +++ b/packages/tm-core/src/modules/auth/services/organization.service.ts @@ -4,12 +4,13 @@ */ import { SupabaseClient } from '@supabase/supabase-js'; -import { Database } from '../../../common/types/database.types.js'; import { - TaskMasterError, - ERROR_CODES + ERROR_CODES, + TaskMasterError } from '../../../common/errors/task-master-error.js'; import { getLogger } from '../../../common/logger/index.js'; +import { Database } from '../../../common/types/database.types.js'; +import type { Brief } from '../../briefs/types.js'; /** * Organization data structure @@ -20,24 +21,6 @@ export interface Organization { slug: string; } -/** - * Brief data structure - */ -export interface Brief { - id: string; - accountId: string; - documentId: string; - status: string; - createdAt: string; - updatedAt: string; - document?: { - id: string; - title: string; - document_name: string; - description?: string; - }; -} - /** * Task data structure from the remote database */ @@ -181,6 +164,7 @@ export class OrganizationService { status, created_at, updated_at, + tasks(count), document:document_id ( id, document_name, @@ -211,6 +195,9 @@ export class OrganizationService { status: brief.status, createdAt: brief.created_at, updatedAt: brief.updated_at, + taskCount: Array.isArray(brief.tasks) + ? (brief.tasks[0]?.count ?? 0) + : 0, document: brief.document ? { id: brief.document.id, diff --git a/packages/tm-core/src/modules/auth/services/supabase-session-storage.ts b/packages/tm-core/src/modules/auth/services/supabase-session-storage.ts index 5949a8a3..63b5a8a7 100644 --- a/packages/tm-core/src/modules/auth/services/supabase-session-storage.ts +++ b/packages/tm-core/src/modules/auth/services/supabase-session-storage.ts @@ -14,9 +14,9 @@ * - Persistence to ~/.taskmaster/session.json */ -import type { SupportedStorage } from '@supabase/supabase-js'; import fs from 'fs'; import path from 'path'; +import type { SupportedStorage } from '@supabase/supabase-js'; import { getLogger } from '../../../common/logger/index.js'; const DEFAULT_SESSION_FILE = path.join( diff --git a/packages/tm-core/src/modules/auth/types.ts b/packages/tm-core/src/modules/auth/types.ts index 79f3defd..497b0a15 100644 --- a/packages/tm-core/src/modules/auth/types.ts +++ b/packages/tm-core/src/modules/auth/types.ts @@ -19,6 +19,8 @@ export interface UserContext { orgSlug?: string; briefId?: string; briefName?: string; + briefStatus?: string; + briefUpdatedAt?: string; updatedAt: string; } diff --git a/packages/tm-core/src/modules/briefs/briefs-domain.ts b/packages/tm-core/src/modules/briefs/briefs-domain.ts new file mode 100644 index 00000000..68c4158b --- /dev/null +++ b/packages/tm-core/src/modules/briefs/briefs-domain.ts @@ -0,0 +1,182 @@ +/** + * @fileoverview Briefs Domain Facade + * Public API for brief-related operations + */ + +import { + ERROR_CODES, + TaskMasterError +} from '../../common/errors/task-master-error.js'; +import { AuthManager } from '../auth/managers/auth-manager.js'; +import type { TaskRepository } from '../tasks/repositories/task-repository.interface.js'; +import { BriefService, type TagWithStats } from './services/brief-service.js'; + +/** + * Briefs Domain - Unified API for brief operations + * Handles brief switching, matching, and statistics + */ +export class BriefsDomain { + private briefService: BriefService; + private authManager: AuthManager; + + constructor() { + this.briefService = new BriefService(); + this.authManager = AuthManager.getInstance(); + } + + /** + * Resolve a brief by name, ID, URL, or partial ID without updating context + * Returns the full brief object + * + * Supports: + * - Hamster URLs (e.g., https://app.tryhamster.com/home/hamster/briefs/abc123) + * - Full UUID + * - Last 8 characters of UUID + * - Brief name (exact or partial match) + * + * @param input - Raw input: URL, UUID, last 8 chars, or brief name + * @param orgId - Optional organization ID. If not provided, uses current context. + * @returns The resolved brief object + */ + async resolveBrief(input: string, orgId?: string): Promise { + // Extract brief ID/name from input (could be URL, ID, or name) + const briefIdOrName = this.extractBriefIdentifier(input); + + // Get org ID from parameter or context + const resolvedOrgId = orgId || this.authManager.getContext()?.orgId; + + if (!resolvedOrgId) { + throw new TaskMasterError( + 'No organization selected. Run "tm context org" first.', + ERROR_CODES.CONFIG_ERROR + ); + } + + // Fetch all briefs for the org + const briefs = await this.authManager.getBriefs(resolvedOrgId); + + // Find matching brief using service + const matchingBrief = await this.briefService.findBrief( + briefs, + briefIdOrName + ); + + this.briefService.validateBriefFound(matchingBrief, briefIdOrName); + + return matchingBrief; + } + + /** + * Extract brief identifier from raw input + * Handles URLs, paths, and direct IDs + * + * @param input - Raw input string + * @returns Extracted brief identifier + */ + private extractBriefIdentifier(input: string): string { + const raw = input?.trim() ?? ''; + if (!raw) { + throw new TaskMasterError( + 'Brief identifier cannot be empty', + ERROR_CODES.VALIDATION_ERROR + ); + } + + const parseUrl = (s: string): URL | null => { + try { + return new URL(s); + } catch {} + try { + return new URL(`https://${s}`); + } catch {} + return null; + }; + + const fromParts = (path: string): string | null => { + const parts = path.split('/').filter(Boolean); + const briefsIdx = parts.lastIndexOf('briefs'); + const candidate = + briefsIdx >= 0 && parts.length > briefsIdx + 1 + ? parts[briefsIdx + 1] + : parts[parts.length - 1]; + return candidate?.trim() || null; + }; + + // 1) URL (absolute or scheme‑less) + const url = parseUrl(raw); + if (url) { + const qId = url.searchParams.get('id') || url.searchParams.get('briefId'); + const candidate = (qId || fromParts(url.pathname)) ?? null; + if (candidate) { + return candidate; + } + } + + // 2) Looks like a path without scheme + if (raw.includes('/')) { + const candidate = fromParts(raw); + if (candidate) { + return candidate; + } + } + + // 3) Fallback: raw token (UUID, last 8 chars, or name) + return raw; + } + + /** + * Switch to a different brief by name or ID + * Validates context, finds matching brief, and updates auth context + */ + async switchBrief(briefNameOrId: string): Promise { + // Use resolveBrief to find the brief + const matchingBrief = await this.resolveBrief(briefNameOrId); + + // Update context with the found brief + await this.authManager.updateContext({ + briefId: matchingBrief.id, + briefName: + matchingBrief.document?.title || `Brief ${matchingBrief.id.slice(-8)}`, + briefStatus: matchingBrief.status, + briefUpdatedAt: matchingBrief.updatedAt + }); + } + + /** + * Get all briefs with detailed statistics including task counts + * Used for API storage to show brief statistics + */ + async getBriefsWithStats( + repository: TaskRepository, + projectId: string + ): Promise<{ + tags: TagWithStats[]; + currentTag: string | null; + totalTags: number; + }> { + const context = this.authManager.getContext(); + + if (!context?.orgId) { + throw new TaskMasterError( + 'No organization context available', + ERROR_CODES.MISSING_CONFIGURATION, + { + operation: 'getBriefsWithStats', + userMessage: + 'No organization selected. Please authenticate first using: tm auth login' + } + ); + } + + // Get all briefs for the organization (through auth manager) + const briefs = await this.authManager.getBriefs(context.orgId); + + // Use BriefService to calculate stats + return this.briefService.getTagsWithStats( + briefs, + context.briefId, + repository, + projectId + ); + } +} diff --git a/packages/tm-core/src/modules/briefs/index.ts b/packages/tm-core/src/modules/briefs/index.ts new file mode 100644 index 00000000..72cf972d --- /dev/null +++ b/packages/tm-core/src/modules/briefs/index.ts @@ -0,0 +1,7 @@ +/** + * Briefs module exports + */ + +export { BriefsDomain } from './briefs-domain.js'; +export { BriefService, type TagWithStats } from './services/brief-service.js'; +export type { Brief } from './types.js'; diff --git a/packages/tm-core/src/modules/briefs/services/brief-service.ts b/packages/tm-core/src/modules/briefs/services/brief-service.ts new file mode 100644 index 00000000..561e46f2 --- /dev/null +++ b/packages/tm-core/src/modules/briefs/services/brief-service.ts @@ -0,0 +1,225 @@ +/** + * @fileoverview Brief Service + * Handles brief lookup, matching, and statistics + */ + +import { + ERROR_CODES, + TaskMasterError +} from '../../../common/errors/task-master-error.js'; +import { TaskRepository } from '../../tasks/repositories/task-repository.interface.js'; +import type { Brief } from '../types.js'; + +/** + * Tag statistics with detailed breakdown + */ +export interface TagWithStats { + name: string; + isCurrent: boolean; + taskCount: number; + completedTasks: number; + statusBreakdown: Record; + subtaskCounts?: { + totalSubtasks: number; + subtasksByStatus: Record; + }; + created?: string; + description?: string; + status?: string; + briefId?: string; + updatedAt?: string; +} + +/** + * Service for brief-related operations + */ +export class BriefService { + /** + * Find a brief by name or ID with flexible matching + */ + async findBrief( + briefs: Brief[], + nameOrId: string + ): Promise { + return briefs.find((brief) => this.matches(brief, nameOrId)); + } + + /** + * Match a brief against a query string + * Supports: exact name match, partial name match, full ID, last 8 chars of ID + */ + private matches(brief: Brief, query: string): boolean { + const briefName = brief.document?.title || ''; + + // Exact match (case-insensitive) + if (briefName.toLowerCase() === query.toLowerCase()) { + return true; + } + + // Partial match + if (briefName.toLowerCase().includes(query.toLowerCase())) { + return true; + } + + // Match by ID (full or last 8 chars) + if ( + brief.id === query || + brief.id.toLowerCase() === query.toLowerCase() || + brief.id.slice(-8).toLowerCase() === query.toLowerCase() + ) { + return true; + } + + return false; + } + + /** + * Get tags with detailed statistics for all briefs in an organization + * Used for API storage to show brief statistics + */ + async getTagsWithStats( + briefs: Brief[], + currentBriefId: string | undefined, + repository: TaskRepository, + _projectId?: string + ): Promise<{ + tags: TagWithStats[]; + currentTag: string | null; + totalTags: number; + }> { + // For each brief, get task counts by querying tasks + const tagsWithStats = await Promise.all( + briefs.map(async (brief: Brief) => { + try { + // Get all tasks for this brief + const tasks = await repository.getTasks(brief.id, {}); + + // Calculate statistics + const statusBreakdown: Record = {}; + let completedTasks = 0; + + const subtaskCounts = { + totalSubtasks: 0, + subtasksByStatus: {} as Record + }; + + tasks.forEach((task) => { + // Count task status + const status = task.status || 'pending'; + statusBreakdown[status] = (statusBreakdown[status] || 0) + 1; + + if (status === 'done') { + completedTasks++; + } + + // Count subtasks + if (task.subtasks && task.subtasks.length > 0) { + subtaskCounts.totalSubtasks += task.subtasks.length; + + task.subtasks.forEach((subtask) => { + const subStatus = subtask.status || 'pending'; + subtaskCounts.subtasksByStatus[subStatus] = + (subtaskCounts.subtasksByStatus[subStatus] || 0) + 1; + }); + } + }); + + return { + name: + brief.document?.title || + brief.document?.document_name || + brief.id, + isCurrent: currentBriefId === brief.id, + taskCount: tasks.length, + completedTasks, + statusBreakdown, + subtaskCounts: + subtaskCounts.totalSubtasks > 0 ? subtaskCounts : undefined, + created: brief.createdAt, + description: brief.document?.description, + status: brief.status, + briefId: brief.id, + updatedAt: brief.updatedAt + }; + } catch (error) { + // If we can't get tasks for a brief, return it with 0 tasks + console.warn(`Failed to get tasks for brief ${brief.id}:`, error); + return { + name: + brief.document?.title || + brief.document?.document_name || + brief.id, + isCurrent: currentBriefId === brief.id, + taskCount: 0, + completedTasks: 0, + statusBreakdown: {}, + created: brief.createdAt, + description: brief.document?.description, + status: brief.status, + briefId: brief.id, + updatedAt: brief.updatedAt + }; + } + }) + ); + + // Define priority order for brief statuses + const statusPriority: Record = { + delivering: 1, + aligned: 2, + refining: 3, + draft: 4, + delivered: 5, + done: 6, + archived: 7 + }; + + // Sort tags: first by status priority, then by updatedAt (most recent first) within each status + const sortedTags = tagsWithStats.sort((a, b) => { + // Get status priorities (default to 999 for unknown statuses) + const statusA = (a.status || '').toLowerCase(); + const statusB = (b.status || '').toLowerCase(); + const priorityA = statusPriority[statusA] ?? 999; + const priorityB = statusPriority[statusB] ?? 999; + + // Sort by status priority first + if (priorityA !== priorityB) { + return priorityA - priorityB; + } + + // Within same status, sort by updatedAt (most recent first) + const dateA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0; + const dateB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0; + return dateB - dateA; // Descending order (most recent first) + }); + + // Find current brief name + const currentBrief = briefs.find((b) => b.id === currentBriefId); + const currentTag = currentBrief + ? currentBrief.document?.title || + currentBrief.document?.document_name || + null + : null; + + return { + tags: sortedTags, + currentTag, + totalTags: sortedTags.length + }; + } + + /** + * Validate that a brief was found, throw error if not + */ + validateBriefFound( + brief: Brief | undefined, + nameOrId: string + ): asserts brief is Brief { + if (!brief) { + throw new TaskMasterError( + `Brief "${nameOrId}" not found in organization`, + ERROR_CODES.NOT_FOUND + ); + } + } +} diff --git a/packages/tm-core/src/modules/briefs/types.ts b/packages/tm-core/src/modules/briefs/types.ts new file mode 100644 index 00000000..35614e7e --- /dev/null +++ b/packages/tm-core/src/modules/briefs/types.ts @@ -0,0 +1,23 @@ +/** + * @fileoverview Briefs module types + */ + +/** + * Brief data structure + * Represents a project brief containing tasks and requirements + */ +export interface Brief { + id: string; + accountId: string; + documentId: string; + status: string; + createdAt: string; + updatedAt: string; + taskCount?: number; + document?: { + id: string; + title: string; + document_name: string; + description?: string; + }; +} diff --git a/packages/tm-core/src/modules/config/config-domain.ts b/packages/tm-core/src/modules/config/config-domain.ts index 74931718..c3b9c244 100644 --- a/packages/tm-core/src/modules/config/config-domain.ts +++ b/packages/tm-core/src/modules/config/config-domain.ts @@ -3,11 +3,11 @@ * Public API for configuration management */ -import type { ConfigManager } from './managers/config-manager.js'; import type { PartialConfiguration, RuntimeStorageConfig } from '../../common/interfaces/configuration.interface.js'; +import type { ConfigManager } from './managers/config-manager.js'; /** * Config Domain - Unified API for configuration operations diff --git a/packages/tm-core/src/modules/config/managers/config-manager.spec.ts b/packages/tm-core/src/modules/config/managers/config-manager.spec.ts index d12ee03e..c88d7382 100644 --- a/packages/tm-core/src/modules/config/managers/config-manager.spec.ts +++ b/packages/tm-core/src/modules/config/managers/config-manager.spec.ts @@ -3,14 +3,14 @@ * Tests the orchestration of all configuration services */ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { ConfigManager } from './config-manager.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js'; import { ConfigLoader } from '../services/config-loader.service.js'; import { ConfigMerger } from '../services/config-merger.service.js'; -import { RuntimeStateManager } from '../services/runtime-state-manager.service.js'; import { ConfigPersistence } from '../services/config-persistence.service.js'; import { EnvironmentConfigProvider } from '../services/environment-config-provider.service.js'; +import { RuntimeStateManager } from '../services/runtime-state-manager.service.js'; +import { ConfigManager } from './config-manager.js'; // Mock all services vi.mock('../services/config-loader.service.js'); diff --git a/packages/tm-core/src/modules/config/managers/config-manager.ts b/packages/tm-core/src/modules/config/managers/config-manager.ts index 1cce6157..23486175 100644 --- a/packages/tm-core/src/modules/config/managers/config-manager.ts +++ b/packages/tm-core/src/modules/config/managers/config-manager.ts @@ -13,12 +13,12 @@ import type { import { DEFAULT_CONFIG_VALUES as DEFAULTS } from '../../../common/interfaces/configuration.interface.js'; import { ConfigLoader } from '../services/config-loader.service.js'; import { - ConfigMerger, - CONFIG_PRECEDENCE + CONFIG_PRECEDENCE, + ConfigMerger } from '../services/config-merger.service.js'; -import { RuntimeStateManager } from '../services/runtime-state-manager.service.js'; import { ConfigPersistence } from '../services/config-persistence.service.js'; import { EnvironmentConfigProvider } from '../services/environment-config-provider.service.js'; +import { RuntimeStateManager } from '../services/runtime-state-manager.service.js'; /** * ConfigManager orchestrates all configuration services diff --git a/packages/tm-core/src/modules/config/services/config-loader.service.spec.ts b/packages/tm-core/src/modules/config/services/config-loader.service.spec.ts index bcc4d11c..eaeeb298 100644 --- a/packages/tm-core/src/modules/config/services/config-loader.service.spec.ts +++ b/packages/tm-core/src/modules/config/services/config-loader.service.spec.ts @@ -2,10 +2,10 @@ * @fileoverview Unit tests for ConfigLoader service */ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import fs from 'node:fs/promises'; -import { ConfigLoader } from './config-loader.service.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js'; +import { ConfigLoader } from './config-loader.service.js'; vi.mock('node:fs', () => ({ promises: { diff --git a/packages/tm-core/src/modules/config/services/config-loader.service.ts b/packages/tm-core/src/modules/config/services/config-loader.service.ts index e32c0f23..075392ca 100644 --- a/packages/tm-core/src/modules/config/services/config-loader.service.ts +++ b/packages/tm-core/src/modules/config/services/config-loader.service.ts @@ -5,12 +5,12 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js'; -import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js'; import { ERROR_CODES, TaskMasterError } from '../../../common/errors/task-master-error.js'; +import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js'; +import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js'; /** * ConfigLoader handles loading configuration from files diff --git a/packages/tm-core/src/modules/config/services/config-merger.service.spec.ts b/packages/tm-core/src/modules/config/services/config-merger.service.spec.ts index f7f66a73..6ca13152 100644 --- a/packages/tm-core/src/modules/config/services/config-merger.service.spec.ts +++ b/packages/tm-core/src/modules/config/services/config-merger.service.spec.ts @@ -2,8 +2,8 @@ * @fileoverview Unit tests for ConfigMerger service */ -import { describe, it, expect, beforeEach } from 'vitest'; -import { ConfigMerger, CONFIG_PRECEDENCE } from './config-merger.service.js'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { CONFIG_PRECEDENCE, ConfigMerger } from './config-merger.service.js'; describe('ConfigMerger', () => { let merger: ConfigMerger; diff --git a/packages/tm-core/src/modules/config/services/config-persistence.service.spec.ts b/packages/tm-core/src/modules/config/services/config-persistence.service.spec.ts index 1910e72c..549868df 100644 --- a/packages/tm-core/src/modules/config/services/config-persistence.service.spec.ts +++ b/packages/tm-core/src/modules/config/services/config-persistence.service.spec.ts @@ -2,10 +2,10 @@ * @fileoverview Unit tests for ConfigPersistence service */ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import fs from 'node:fs/promises'; -import { ConfigPersistence } from './config-persistence.service.js'; import type { PartialConfiguration } from '@tm/core/common/interfaces/configuration.interface.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConfigPersistence } from './config-persistence.service.js'; vi.mock('node:fs', () => ({ promises: { diff --git a/packages/tm-core/src/modules/config/services/config-persistence.service.ts b/packages/tm-core/src/modules/config/services/config-persistence.service.ts index 5db86f58..d4f04f1d 100644 --- a/packages/tm-core/src/modules/config/services/config-persistence.service.ts +++ b/packages/tm-core/src/modules/config/services/config-persistence.service.ts @@ -5,11 +5,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js'; import { ERROR_CODES, TaskMasterError } from '../../../common/errors/task-master-error.js'; +import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js'; import { getLogger } from '../../../common/logger/index.js'; /** diff --git a/packages/tm-core/src/modules/config/services/environment-config-provider.service.spec.ts b/packages/tm-core/src/modules/config/services/environment-config-provider.service.spec.ts index 36fa579c..fed2c84a 100644 --- a/packages/tm-core/src/modules/config/services/environment-config-provider.service.spec.ts +++ b/packages/tm-core/src/modules/config/services/environment-config-provider.service.spec.ts @@ -2,7 +2,7 @@ * @fileoverview Unit tests for EnvironmentConfigProvider service */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { EnvironmentConfigProvider } from './environment-config-provider.service.js'; describe('EnvironmentConfigProvider', () => { diff --git a/packages/tm-core/src/modules/config/services/runtime-state-manager.service.spec.ts b/packages/tm-core/src/modules/config/services/runtime-state-manager.service.spec.ts index 9d3a17d7..3571acb3 100644 --- a/packages/tm-core/src/modules/config/services/runtime-state-manager.service.spec.ts +++ b/packages/tm-core/src/modules/config/services/runtime-state-manager.service.spec.ts @@ -2,10 +2,10 @@ * @fileoverview Unit tests for RuntimeStateManager service */ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import fs from 'node:fs/promises'; -import { RuntimeStateManager } from './runtime-state-manager.service.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js'; +import { RuntimeStateManager } from './runtime-state-manager.service.js'; vi.mock('node:fs', () => ({ promises: { diff --git a/packages/tm-core/src/modules/execution/executors/base-executor.ts b/packages/tm-core/src/modules/execution/executors/base-executor.ts index e8aad6a5..486a75d6 100644 --- a/packages/tm-core/src/modules/execution/executors/base-executor.ts +++ b/packages/tm-core/src/modules/execution/executors/base-executor.ts @@ -2,9 +2,9 @@ * Base executor class providing common functionality for all executors */ -import type { Task } from '../../../common/types/index.js'; -import type { ITaskExecutor, ExecutorType, ExecutionResult } from '../types.js'; import { getLogger } from '../../../common/logger/index.js'; +import type { Task } from '../../../common/types/index.js'; +import type { ExecutionResult, ExecutorType, ITaskExecutor } from '../types.js'; export abstract class BaseExecutor implements ITaskExecutor { protected readonly logger = getLogger('BaseExecutor'); diff --git a/packages/tm-core/src/modules/execution/executors/claude-executor.ts b/packages/tm-core/src/modules/execution/executors/claude-executor.ts index 7482e749..5e8b58ad 100644 --- a/packages/tm-core/src/modules/execution/executors/claude-executor.ts +++ b/packages/tm-core/src/modules/execution/executors/claude-executor.ts @@ -4,12 +4,12 @@ import { spawn } from 'child_process'; import type { Task } from '../../../common/types/index.js'; -import type { - ExecutorType, - ExecutionResult, - ClaudeExecutorConfig -} from '../types.js'; import { BaseExecutor } from '../executors/base-executor.js'; +import type { + ClaudeExecutorConfig, + ExecutionResult, + ExecutorType +} from '../types.js'; export class ClaudeExecutor extends BaseExecutor { private claudeConfig: ClaudeExecutorConfig; diff --git a/packages/tm-core/src/modules/execution/executors/executor-factory.ts b/packages/tm-core/src/modules/execution/executors/executor-factory.ts index 34ee51f2..167a2354 100644 --- a/packages/tm-core/src/modules/execution/executors/executor-factory.ts +++ b/packages/tm-core/src/modules/execution/executors/executor-factory.ts @@ -2,9 +2,9 @@ * Factory for creating task executors */ -import type { ITaskExecutor, ExecutorOptions, ExecutorType } from '../types.js'; -import { ClaudeExecutor } from '../executors/claude-executor.js'; import { getLogger } from '../../../common/logger/index.js'; +import { ClaudeExecutor } from '../executors/claude-executor.js'; +import type { ExecutorOptions, ExecutorType, ITaskExecutor } from '../types.js'; export class ExecutorFactory { private static logger = getLogger('ExecutorFactory'); diff --git a/packages/tm-core/src/modules/execution/services/executor-service.ts b/packages/tm-core/src/modules/execution/services/executor-service.ts index 38fc1fae..abc79fb9 100644 --- a/packages/tm-core/src/modules/execution/services/executor-service.ts +++ b/packages/tm-core/src/modules/execution/services/executor-service.ts @@ -2,15 +2,15 @@ * Service for managing task execution */ -import type { Task } from '../../../common/types/index.js'; -import type { - ITaskExecutor, - ExecutorOptions, - ExecutionResult, - ExecutorType -} from '../types.js'; -import { ExecutorFactory } from '../executors/executor-factory.js'; import { getLogger } from '../../../common/logger/index.js'; +import type { Task } from '../../../common/types/index.js'; +import { ExecutorFactory } from '../executors/executor-factory.js'; +import type { + ExecutionResult, + ExecutorOptions, + ExecutorType, + ITaskExecutor +} from '../types.js'; export interface ExecutorServiceOptions { projectRoot: string; diff --git a/packages/tm-core/src/modules/git/adapters/git-adapter.test.ts b/packages/tm-core/src/modules/git/adapters/git-adapter.test.ts index baab5e26..d5f2a763 100644 --- a/packages/tm-core/src/modules/git/adapters/git-adapter.test.ts +++ b/packages/tm-core/src/modules/git/adapters/git-adapter.test.ts @@ -1,14 +1,14 @@ +import os from 'os'; +import path from 'path'; import { - describe, - it, - expect, - beforeEach, afterEach, + beforeEach, + describe, + expect, + it, jest } from '@jest/globals'; import fs from 'fs-extra'; -import path from 'path'; -import os from 'os'; import { GitAdapter } from '../../../../../packages/tm-core/src/git/git-adapter.js'; describe('GitAdapter - Repository Detection and Validation', () => { diff --git a/packages/tm-core/src/modules/git/adapters/git-adapter.ts b/packages/tm-core/src/modules/git/adapters/git-adapter.ts index dd670e89..5bb8e23c 100644 --- a/packages/tm-core/src/modules/git/adapters/git-adapter.ts +++ b/packages/tm-core/src/modules/git/adapters/git-adapter.ts @@ -5,9 +5,9 @@ * @module git-adapter */ -import { simpleGit, type SimpleGit, type StatusResult } from 'simple-git'; -import fs from 'fs-extra'; import path from 'path'; +import fs from 'fs-extra'; +import { type SimpleGit, type StatusResult, simpleGit } from 'simple-git'; /** * GitAdapter class for safe git operations diff --git a/packages/tm-core/src/modules/git/git-domain.ts b/packages/tm-core/src/modules/git/git-domain.ts index b52f3d86..c051bf9d 100644 --- a/packages/tm-core/src/modules/git/git-domain.ts +++ b/packages/tm-core/src/modules/git/git-domain.ts @@ -3,10 +3,10 @@ * Public API for Git operations */ +import type { StatusResult } from 'simple-git'; import { GitAdapter } from './adapters/git-adapter.js'; import { CommitMessageGenerator } from './services/commit-message-generator.js'; import type { CommitMessageOptions } from './services/commit-message-generator.js'; -import type { StatusResult } from 'simple-git'; /** * Git Domain - Unified API for Git operations diff --git a/packages/tm-core/src/modules/git/services/branch-name-generator.spec.ts b/packages/tm-core/src/modules/git/services/branch-name-generator.spec.ts index 81134189..ec58bf8c 100644 --- a/packages/tm-core/src/modules/git/services/branch-name-generator.spec.ts +++ b/packages/tm-core/src/modules/git/services/branch-name-generator.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { generateBranchName, sanitizeBranchName diff --git a/packages/tm-core/src/modules/git/services/commit-message-generator.test.ts b/packages/tm-core/src/modules/git/services/commit-message-generator.test.ts index 9de37a5d..23c2eb8b 100644 --- a/packages/tm-core/src/modules/git/services/commit-message-generator.test.ts +++ b/packages/tm-core/src/modules/git/services/commit-message-generator.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { CommitMessageGenerator } from './commit-message-generator.js'; describe('CommitMessageGenerator', () => { diff --git a/packages/tm-core/src/modules/git/services/commit-message-generator.ts b/packages/tm-core/src/modules/git/services/commit-message-generator.ts index a5de41e9..7429d410 100644 --- a/packages/tm-core/src/modules/git/services/commit-message-generator.ts +++ b/packages/tm-core/src/modules/git/services/commit-message-generator.ts @@ -5,8 +5,8 @@ * that follow conventional commits specification and include task metadata. */ -import { TemplateEngine } from './template-engine.js'; import { ScopeDetector } from './scope-detector.js'; +import { TemplateEngine } from './template-engine.js'; export interface CommitMessageOptions { type: string; diff --git a/packages/tm-core/src/modules/git/services/scope-detector.test.ts b/packages/tm-core/src/modules/git/services/scope-detector.test.ts index 3d225e37..710da6d7 100644 --- a/packages/tm-core/src/modules/git/services/scope-detector.test.ts +++ b/packages/tm-core/src/modules/git/services/scope-detector.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { ScopeDetector } from './scope-detector.js'; describe('ScopeDetector', () => { diff --git a/packages/tm-core/src/modules/git/services/template-engine.test.ts b/packages/tm-core/src/modules/git/services/template-engine.test.ts index 3c9e332d..88d1876c 100644 --- a/packages/tm-core/src/modules/git/services/template-engine.test.ts +++ b/packages/tm-core/src/modules/git/services/template-engine.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { TemplateEngine } from './template-engine.js'; describe('TemplateEngine', () => { diff --git a/packages/tm-core/src/modules/integration/clients/supabase-client.ts b/packages/tm-core/src/modules/integration/clients/supabase-client.ts index ad0cedfb..046f2ea0 100644 --- a/packages/tm-core/src/modules/integration/clients/supabase-client.ts +++ b/packages/tm-core/src/modules/integration/clients/supabase-client.ts @@ -3,14 +3,14 @@ */ import { - createClient, + Session, SupabaseClient as SupabaseJSClient, User, - Session + createClient } from '@supabase/supabase-js'; -import { AuthenticationError } from '../../auth/types.js'; import { getLogger } from '../../../common/logger/index.js'; import { SupabaseSessionStorage } from '../../auth/services/supabase-session-storage.js'; +import { AuthenticationError } from '../../auth/types.js'; export class SupabaseAuthClient { private client: SupabaseJSClient | null = null; diff --git a/packages/tm-core/src/modules/integration/integration-domain.ts b/packages/tm-core/src/modules/integration/integration-domain.ts index d7325a9f..46d4982b 100644 --- a/packages/tm-core/src/modules/integration/integration-domain.ts +++ b/packages/tm-core/src/modules/integration/integration-domain.ts @@ -3,12 +3,12 @@ * Public API for integration with external systems */ -import type { ConfigManager } from '../config/managers/config-manager.js'; import { AuthManager } from '../auth/managers/auth-manager.js'; +import type { ConfigManager } from '../config/managers/config-manager.js'; import { ExportService } from './services/export.service.js'; import type { - ExportTasksOptions, - ExportResult + ExportResult, + ExportTasksOptions } from './services/export.service.js'; /** diff --git a/packages/tm-core/src/modules/integration/services/export.service.ts b/packages/tm-core/src/modules/integration/services/export.service.ts index 4cc96072..f9e4c079 100644 --- a/packages/tm-core/src/modules/integration/services/export.service.ts +++ b/packages/tm-core/src/modules/integration/services/export.service.ts @@ -3,14 +3,14 @@ * Core service for exporting tasks to external systems (e.g., Hamster briefs) */ -import type { Task, TaskStatus } from '../../../common/types/index.js'; -import type { UserContext } from '../../auth/types.js'; -import { ConfigManager } from '../../config/managers/config-manager.js'; -import { AuthManager } from '../../auth/managers/auth-manager.js'; import { ERROR_CODES, TaskMasterError } from '../../../common/errors/task-master-error.js'; +import type { Task, TaskStatus } from '../../../common/types/index.js'; +import { AuthManager } from '../../auth/managers/auth-manager.js'; +import type { UserContext } from '../../auth/types.js'; +import { ConfigManager } from '../../config/managers/config-manager.js'; import { FileStorage } from '../../storage/adapters/file-storage/index.js'; // Type definitions for the bulk API response diff --git a/packages/tm-core/src/modules/integration/services/task-expansion.service.ts b/packages/tm-core/src/modules/integration/services/task-expansion.service.ts index a241e53e..1965db4d 100644 --- a/packages/tm-core/src/modules/integration/services/task-expansion.service.ts +++ b/packages/tm-core/src/modules/integration/services/task-expansion.service.ts @@ -8,10 +8,10 @@ import { ERROR_CODES, TaskMasterError } from '../../../common/errors/task-master-error.js'; -import type { TaskRepository } from '../../tasks/repositories/task-repository.interface.js'; +import { getLogger } from '../../../common/logger/factory.js'; import { AuthManager } from '../../auth/managers/auth-manager.js'; import { ApiClient } from '../../storage/utils/api-client.js'; -import { getLogger } from '../../../common/logger/factory.js'; +import type { TaskRepository } from '../../tasks/repositories/task-repository.interface.js'; /** * Response from the expand task API endpoint (202 Accepted) diff --git a/packages/tm-core/src/modules/integration/services/task-retrieval.service.ts b/packages/tm-core/src/modules/integration/services/task-retrieval.service.ts index 9bd4a793..4fb54874 100644 --- a/packages/tm-core/src/modules/integration/services/task-retrieval.service.ts +++ b/packages/tm-core/src/modules/integration/services/task-retrieval.service.ts @@ -8,11 +8,11 @@ import { ERROR_CODES, TaskMasterError } from '../../../common/errors/task-master-error.js'; -import type { TaskRepository } from '../../tasks/repositories/task-repository.interface.js'; -import { AuthManager } from '../../auth/managers/auth-manager.js'; -import { ApiClient } from '../../storage/utils/api-client.js'; import { getLogger } from '../../../common/logger/factory.js'; import type { Task } from '../../../common/types/index.js'; +import { AuthManager } from '../../auth/managers/auth-manager.js'; +import { ApiClient } from '../../storage/utils/api-client.js'; +import type { TaskRepository } from '../../tasks/repositories/task-repository.interface.js'; /** * Response from the get task API endpoint diff --git a/packages/tm-core/src/modules/reports/managers/complexity-report-manager.ts b/packages/tm-core/src/modules/reports/managers/complexity-report-manager.ts index 02c24ef4..13d7c55d 100644 --- a/packages/tm-core/src/modules/reports/managers/complexity-report-manager.ts +++ b/packages/tm-core/src/modules/reports/managers/complexity-report-manager.ts @@ -5,12 +5,12 @@ import fs from 'node:fs/promises'; import path from 'path'; +import { getLogger } from '../../../common/logger/index.js'; import type { - ComplexityReport, ComplexityAnalysis, + ComplexityReport, TaskComplexityData } from '../types.js'; -import { getLogger } from '../../../common/logger/index.js'; const logger = getLogger('ComplexityReportManager'); diff --git a/packages/tm-core/src/modules/storage/adapters/activity-logger.ts b/packages/tm-core/src/modules/storage/adapters/activity-logger.ts index cb8b5359..6c4c49d5 100644 --- a/packages/tm-core/src/modules/storage/adapters/activity-logger.ts +++ b/packages/tm-core/src/modules/storage/adapters/activity-logger.ts @@ -5,8 +5,8 @@ * @module activity-logger */ -import fs from 'fs-extra'; import path from 'path'; +import fs from 'fs-extra'; /** * Activity log entry structure diff --git a/packages/tm-core/src/modules/storage/adapters/api-storage.ts b/packages/tm-core/src/modules/storage/adapters/api-storage.ts index 576d8032..99500061 100644 --- a/packages/tm-core/src/modules/storage/adapters/api-storage.ts +++ b/packages/tm-core/src/modules/storage/adapters/api-storage.ts @@ -3,33 +3,34 @@ * This provides storage via repository abstraction for flexibility */ -import type { - IStorage, - StorageStats, - UpdateStatusResult, - LoadTasksOptions -} from '../../../common/interfaces/storage.interface.js'; -import type { - Task, - TaskMetadata, - TaskTag, - TaskStatus -} from '../../../common/types/index.js'; +import type { SupabaseClient } from '@supabase/supabase-js'; import { ERROR_CODES, TaskMasterError } from '../../../common/errors/task-master-error.js'; -import { TaskRepository } from '../../tasks/repositories/task-repository.interface.js'; -import { SupabaseRepository } from '../../tasks/repositories/supabase/index.js'; -import { SupabaseClient } from '@supabase/supabase-js'; -import { AuthManager } from '../../auth/managers/auth-manager.js'; -import { ApiClient } from '../utils/api-client.js'; +import type { + IStorage, + LoadTasksOptions, + StorageStats, + UpdateStatusResult +} from '../../../common/interfaces/storage.interface.js'; import { getLogger } from '../../../common/logger/factory.js'; +import type { + Task, + TaskMetadata, + TaskStatus, + TaskTag +} from '../../../common/types/index.js'; +import { AuthManager } from '../../auth/managers/auth-manager.js'; +import { BriefsDomain } from '../../briefs/briefs-domain.js'; import { - ExpandTaskResult, + type ExpandTaskResult, TaskExpansionService } from '../../integration/services/task-expansion.service.js'; import { TaskRetrievalService } from '../../integration/services/task-retrieval.service.js'; +import { SupabaseRepository } from '../../tasks/repositories/supabase/index.js'; +import type { TaskRepository } from '../../tasks/repositories/task-repository.interface.js'; +import { ApiClient } from '../utils/api-client.js'; /** * API storage configuration @@ -159,6 +160,49 @@ export class ApiStorage implements IStorage { return context?.briefName || null; } + /** + * Get all briefs (tags) with detailed statistics including task counts + * In API storage, tags are called "briefs" + * Delegates to BriefsDomain for brief statistics calculation + */ + async getTagsWithStats(): Promise<{ + tags: Array<{ + name: string; + isCurrent: boolean; + taskCount: number; + completedTasks: number; + statusBreakdown: Record; + subtaskCounts?: { + totalSubtasks: number; + subtasksByStatus: Record; + }; + created?: string; + description?: string; + status?: string; + briefId?: string; + }>; + currentTag: string | null; + totalTags: number; + }> { + await this.ensureInitialized(); + + try { + // Delegate to BriefsDomain which owns brief operations + const briefsDomain = new BriefsDomain(); + return await briefsDomain.getBriefsWithStats( + this.repository, + this.projectId + ); + } catch (error) { + throw new TaskMasterError( + 'Failed to get tags with stats from API', + ERROR_CODES.STORAGE_ERROR, + { operation: 'getTagsWithStats' }, + error as Error + ); + } + } + /** * Load tags into cache * In our API-based system, "tags" represent briefs @@ -684,6 +728,21 @@ export class ApiStorage implements IStorage { return this.listTags(); } + /** + * Create a new tag (brief) + * Not supported with API storage - users must create briefs via web interface + */ + async createTag( + tagName: string, + _options?: { copyFrom?: string; description?: string } + ): Promise { + throw new TaskMasterError( + 'Tag creation is not supported with API storage. Please create briefs through Hamster Studio.', + ERROR_CODES.NOT_IMPLEMENTED, + { storageType: 'api', operation: 'createTag', tagName } + ); + } + /** * Delete all tasks for a tag */ @@ -964,7 +1023,7 @@ export class ApiStorage implements IStorage { */ private async retryOperation( operation: () => Promise, - attempt: number = 1 + attempt = 1 ): Promise { try { return await operation(); diff --git a/packages/tm-core/src/modules/storage/adapters/file-storage/file-operations.ts b/packages/tm-core/src/modules/storage/adapters/file-storage/file-operations.ts index 6d5ce03a..5fff7b40 100644 --- a/packages/tm-core/src/modules/storage/adapters/file-storage/file-operations.ts +++ b/packages/tm-core/src/modules/storage/adapters/file-storage/file-operations.ts @@ -2,8 +2,8 @@ * @fileoverview File operations with atomic writes and locking */ -import fs from 'node:fs/promises'; import { constants } from 'node:fs'; +import fs from 'node:fs/promises'; import type { FileStorageData } from './format-handler.js'; /** diff --git a/packages/tm-core/src/modules/storage/adapters/file-storage/file-storage.ts b/packages/tm-core/src/modules/storage/adapters/file-storage/file-storage.ts index 46fee877..e485cc23 100644 --- a/packages/tm-core/src/modules/storage/adapters/file-storage/file-storage.ts +++ b/packages/tm-core/src/modules/storage/adapters/file-storage/file-storage.ts @@ -2,21 +2,26 @@ * @fileoverview Refactored file-based storage implementation for Task Master */ +import path from 'node:path'; +import type { + IStorage, + LoadTasksOptions, + StorageStats, + UpdateStatusResult +} from '../../../../common/interfaces/storage.interface.js'; import type { Task, TaskMetadata, TaskStatus } from '../../../../common/types/index.js'; -import type { - IStorage, - StorageStats, - UpdateStatusResult, - LoadTasksOptions -} from '../../../../common/interfaces/storage.interface.js'; -import { FormatHandler } from './format-handler.js'; -import { FileOperations } from './file-operations.js'; -import { PathResolver } from './path-resolver.js'; +import { + ERROR_CODES, + TaskMasterError +} from '../../../../common/errors/task-master-error.js'; import { ComplexityReportManager } from '../../../reports/managers/complexity-report-manager.js'; +import { FileOperations } from './file-operations.js'; +import { FormatHandler } from './format-handler.js'; +import { PathResolver } from './path-resolver.js'; /** * File-based storage implementation using a single tasks.json file with separated concerns @@ -583,6 +588,94 @@ export class FileStorage implements IStorage { await this.saveTasks(filteredTasks, tag); } + /** + * Create a new tag in the tasks.json file + */ + async createTag( + tagName: string, + options?: { copyFrom?: string; description?: string } + ): Promise { + const filePath = this.pathResolver.getTasksPath(); + + try { + const existingData = await this.fileOps.readJson(filePath); + const format = this.formatHandler.detectFormat(existingData); + + if (format === 'legacy') { + // Legacy format - add new tag key + if (tagName in existingData) { + throw new TaskMasterError( + `Tag ${tagName} already exists`, + ERROR_CODES.VALIDATION_ERROR + ); + } + + // Get tasks to copy if specified + let tasksToCopy = []; + if (options?.copyFrom) { + if ( + options.copyFrom in existingData && + existingData[options.copyFrom].tasks + ) { + tasksToCopy = JSON.parse( + JSON.stringify(existingData[options.copyFrom].tasks) + ); + } + } + + // Create new tag structure + existingData[tagName] = { + tasks: tasksToCopy, + metadata: { + created: new Date().toISOString(), + updatedAt: new Date().toISOString(), + description: + options?.description || + `Tag created on ${new Date().toLocaleDateString()}`, + tags: [tagName] + } + }; + + await this.fileOps.writeJson(filePath, existingData); + } else { + // Standard format - need to convert to legacy format first + const masterTasks = existingData.tasks || []; + const masterMetadata = existingData.metadata || {}; + + // Get tasks to copy (from master in this case) + let tasksToCopy = []; + if (options?.copyFrom === 'master' || !options?.copyFrom) { + tasksToCopy = JSON.parse(JSON.stringify(masterTasks)); + } + + const newData = { + master: { + tasks: masterTasks, + metadata: { ...masterMetadata, tags: ['master'] } + }, + [tagName]: { + tasks: tasksToCopy, + metadata: { + created: new Date().toISOString(), + updatedAt: new Date().toISOString(), + description: + options?.description || + `Tag created on ${new Date().toLocaleDateString()}`, + tags: [tagName] + } + } + }; + + await this.fileOps.writeJson(filePath, newData); + } + } catch (error: any) { + if (error.code === 'ENOENT') { + throw new Error('Tasks file not found - initialize project first'); + } + throw error; + } + } + /** * Delete a tag from the single tasks.json file */ @@ -675,6 +768,120 @@ export class FileStorage implements IStorage { await this.saveTasks(tasks, targetTag); } + /** + * Get all tags with detailed statistics including task counts + * For file storage, reads tags from tasks.json and calculates statistics + */ + async getTagsWithStats(): Promise<{ + tags: Array<{ + name: string; + isCurrent: boolean; + taskCount: number; + completedTasks: number; + statusBreakdown: Record; + subtaskCounts?: { + totalSubtasks: number; + subtasksByStatus: Record; + }; + created?: string; + description?: string; + }>; + currentTag: string | null; + totalTags: number; + }> { + const availableTags = await this.getAllTags(); + + // Get active tag from state.json + const activeTag = await this.getActiveTagFromState(); + + const tagsWithStats = await Promise.all( + availableTags.map(async (tagName) => { + try { + // Load tasks for this tag + const tasks = await this.loadTasks(tagName); + + // Calculate statistics + const statusBreakdown: Record = {}; + let completedTasks = 0; + + const subtaskCounts = { + totalSubtasks: 0, + subtasksByStatus: {} as Record + }; + + tasks.forEach((task) => { + // Count task status + const status = task.status || 'pending'; + statusBreakdown[status] = (statusBreakdown[status] || 0) + 1; + + if (status === 'done') { + completedTasks++; + } + + // Count subtasks + if (task.subtasks && task.subtasks.length > 0) { + subtaskCounts.totalSubtasks += task.subtasks.length; + + task.subtasks.forEach((subtask) => { + const subStatus = subtask.status || 'pending'; + subtaskCounts.subtasksByStatus[subStatus] = + (subtaskCounts.subtasksByStatus[subStatus] || 0) + 1; + }); + } + }); + + // Load metadata to get created date and description + const metadata = await this.loadMetadata(tagName); + + return { + name: tagName, + isCurrent: tagName === activeTag, + taskCount: tasks.length, + completedTasks, + statusBreakdown, + subtaskCounts: + subtaskCounts.totalSubtasks > 0 ? subtaskCounts : undefined, + created: metadata?.created, + description: metadata?.description + }; + } catch (error) { + // If we can't load tasks for a tag, return it with 0 tasks + return { + name: tagName, + isCurrent: tagName === activeTag, + taskCount: 0, + completedTasks: 0, + statusBreakdown: {} + }; + } + }) + ); + + return { + tags: tagsWithStats, + currentTag: activeTag, + totalTags: tagsWithStats.length + }; + } + + /** + * Get the active tag from state.json + * @returns The active tag name or 'master' as default + */ + private async getActiveTagFromState(): Promise { + try { + const statePath = path.join( + this.pathResolver.getBasePath(), + 'state.json' + ); + const stateData = await this.fileOps.readJson(statePath); + return stateData?.currentTag || 'master'; + } catch (error) { + // If state.json doesn't exist or can't be read, default to 'master' + return 'master'; + } + } + /** * Enrich tasks with complexity data from the complexity report * Private helper method called by loadTasks() diff --git a/packages/tm-core/src/modules/storage/services/storage-factory.ts b/packages/tm-core/src/modules/storage/services/storage-factory.ts index 8811c9ee..6ca65005 100644 --- a/packages/tm-core/src/modules/storage/services/storage-factory.ts +++ b/packages/tm-core/src/modules/storage/services/storage-factory.ts @@ -2,21 +2,21 @@ * @fileoverview Storage factory for creating appropriate storage implementations */ -import type { IStorage } from '../../../common/interfaces/storage.interface.js'; +import { + ERROR_CODES, + TaskMasterError +} from '../../../common/errors/task-master-error.js'; import type { IConfiguration, RuntimeStorageConfig, StorageSettings } from '../../../common/interfaces/configuration.interface.js'; -import { FileStorage } from '../adapters/file-storage/index.js'; -import { ApiStorage } from '../adapters/api-storage.js'; -import { - ERROR_CODES, - TaskMasterError -} from '../../../common/errors/task-master-error.js'; -import { AuthManager } from '../../auth/managers/auth-manager.js'; +import type { IStorage } from '../../../common/interfaces/storage.interface.js'; import { getLogger } from '../../../common/logger/index.js'; +import { AuthManager } from '../../auth/managers/auth-manager.js'; import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js'; +import { ApiStorage } from '../adapters/api-storage.js'; +import { FileStorage } from '../adapters/file-storage/index.js'; /** * Factory for creating storage implementations based on configuration @@ -87,8 +87,19 @@ export class StorageFactory { apiEndpoint: config.storage?.apiEndpoint || process.env.TM_BASE_DOMAIN || - process.env.TM_PUBLIC_BASE_DOMAIN + process.env.TM_PUBLIC_BASE_DOMAIN || + 'https://tryhamster.com/api' }; + + // Validate that apiEndpoint is defined + if (!nextStorage.apiEndpoint) { + throw new TaskMasterError( + 'API endpoint could not be determined.', + ERROR_CODES.MISSING_CONFIGURATION, + { storageType: 'api' } + ); + } + config.storage = nextStorage; } } diff --git a/packages/tm-core/src/modules/storage/utils/api-client.ts b/packages/tm-core/src/modules/storage/utils/api-client.ts index 19f8acfe..b25f1af4 100644 --- a/packages/tm-core/src/modules/storage/utils/api-client.ts +++ b/packages/tm-core/src/modules/storage/utils/api-client.ts @@ -4,8 +4,8 @@ */ import { - TaskMasterError, - ERROR_CODES + ERROR_CODES, + TaskMasterError } from '../../../common/errors/task-master-error.js'; import type { AuthManager } from '../../auth/managers/auth-manager.js'; diff --git a/packages/tm-core/src/modules/tasks/services/tag.service.ts b/packages/tm-core/src/modules/tasks/services/tag.service.ts new file mode 100644 index 00000000..07f53937 --- /dev/null +++ b/packages/tm-core/src/modules/tasks/services/tag.service.ts @@ -0,0 +1,307 @@ +/** + * @fileoverview TagService - Business logic for tag management + * Handles tag creation, deletion, renaming, and copying + */ + +import type { IStorage } from '../../../common/interfaces/storage.interface.js'; +import type { TagInfo } from '../../../common/interfaces/storage.interface.js'; +import { TaskMasterError, ERROR_CODES } from '../../../common/errors/task-master-error.js'; + +/** + * Options for creating a new tag + */ +export interface CreateTagOptions { + /** Copy tasks from current tag */ + copyFromCurrent?: boolean; + /** Copy tasks from specific tag */ + copyFromTag?: string; + /** Tag description */ + description?: string; + /** Create from git branch name */ + fromBranch?: boolean; +} + +/** + * Options for deleting a tag + * Note: Confirmation prompts are a CLI presentation concern + * and are not handled by TagService (business logic layer) + */ +export interface DeleteTagOptions { + // Currently no options - interface kept for future extensibility +} + +/** + * Options for copying a tag + */ +export interface CopyTagOptions { + // Currently no options - interface kept for future extensibility +} + +/** + * Reserved tag names that cannot be used + * Only 'master' is reserved as it's the system default tag + * Users can use 'main' or 'default' if desired + */ +const RESERVED_TAG_NAMES = ['master']; + +/** + * Maximum length for tag names (prevents filesystem/UI issues) + */ +const MAX_TAG_NAME_LENGTH = 50; + +/** + * TagService - Handles tag management business logic + * Validates operations and delegates to storage layer + */ +export class TagService { + constructor(private storage: IStorage) {} + + /** + * Validate tag name format and restrictions + * @throws {TaskMasterError} if validation fails + */ + private validateTagName(name: string, context = 'Tag name'): void { + if (!name || typeof name !== 'string') { + throw new TaskMasterError( + `${context} is required and must be a string`, + ERROR_CODES.VALIDATION_ERROR + ); + } + + // Check length + if (name.length > MAX_TAG_NAME_LENGTH) { + throw new TaskMasterError( + `${context} must be ${MAX_TAG_NAME_LENGTH} characters or less`, + ERROR_CODES.VALIDATION_ERROR, + { tagName: name, maxLength: MAX_TAG_NAME_LENGTH } + ); + } + + // Check format: alphanumeric, hyphens, underscores only + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + throw new TaskMasterError( + `${context} can only contain letters, numbers, hyphens, and underscores`, + ERROR_CODES.VALIDATION_ERROR, + { tagName: name } + ); + } + + // Check reserved names + if (RESERVED_TAG_NAMES.includes(name.toLowerCase())) { + throw new TaskMasterError( + `"${name}" is a reserved tag name`, + ERROR_CODES.VALIDATION_ERROR, + { tagName: name, reserved: true } + ); + } + } + + /** + * Check if storage supports tag mutation operations + * @throws {TaskMasterError} if operation not supported + */ + private checkTagMutationSupport(operation: string): void { + const storageType = this.storage.getStorageType(); + + if (storageType === 'api') { + throw new TaskMasterError( + `${operation} is not supported with API storage. Use the web interface at Hamster Studio.`, + ERROR_CODES.NOT_IMPLEMENTED, + { storageType: 'api', operation } + ); + } + } + + /** + * Create a new tag + * For API storage: throws error (client should redirect to web UI) + * For file storage: creates tag with optional task copying + */ + async createTag( + name: string, + options: CreateTagOptions = {} + ): Promise { + // Validate tag name + this.validateTagName(name); + + // Check if tag already exists + const allTags = await this.storage.getAllTags(); + if (allTags.includes(name)) { + throw new TaskMasterError( + `Tag "${name}" already exists`, + ERROR_CODES.VALIDATION_ERROR, + { tagName: name } + ); + } + + // Validate copyFromTag if provided + if (options.copyFromTag && !allTags.includes(options.copyFromTag)) { + throw new TaskMasterError( + `Cannot copy from missing tag "${options.copyFromTag}"`, + ERROR_CODES.NOT_FOUND, + { tagName: options.copyFromTag } + ); + } + + // For API storage, we can't create tags via CLI + // The client (CLI/bridge) should handle redirecting to web UI + this.checkTagMutationSupport('Tag creation'); + + // Determine which tag to copy from + let copyFrom: string | undefined; + if (options.copyFromTag) { + copyFrom = options.copyFromTag; + } else if (options.copyFromCurrent) { + const result = await this.storage.getTagsWithStats(); + copyFrom = result.currentTag || undefined; + } + + // Delegate to storage layer + await this.storage.createTag(name, { + copyFrom, + description: options.description + }); + + // Return tag info + const tagInfo: TagInfo = { + name, + taskCount: 0, + completedTasks: 0, + isCurrent: false, + statusBreakdown: {}, + description: options.description || `Tag created on ${new Date().toLocaleDateString()}` + }; + + return tagInfo; + } + + /** + * Delete an existing tag + * Cannot delete master tag + * For API storage: throws error (client should redirect to web UI) + */ + async deleteTag( + name: string, + _options: DeleteTagOptions = {} + ): Promise { + // Validate tag name + this.validateTagName(name); + + // Cannot delete master tag + if (name === 'master') { + throw new TaskMasterError( + 'Cannot delete the "master" tag', + ERROR_CODES.VALIDATION_ERROR, + { tagName: name, protected: true } + ); + } + + // For API storage, we can't delete tags via CLI + this.checkTagMutationSupport('Tag deletion'); + + // Check if tag exists + const allTags = await this.storage.getAllTags(); + if (!allTags.includes(name)) { + throw new TaskMasterError( + `Tag "${name}" does not exist`, + ERROR_CODES.NOT_FOUND, + { tagName: name } + ); + } + + // Delegate to storage + await this.storage.deleteTag(name); + } + + /** + * Rename an existing tag + * Cannot rename master tag + * For API storage: throws error (client should redirect to web UI) + */ + async renameTag(oldName: string, newName: string): Promise { + // Validate both names + this.validateTagName(oldName, 'Old tag name'); + this.validateTagName(newName, 'New tag name'); + + // Cannot rename master tag + if (oldName === 'master') { + throw new TaskMasterError( + 'Cannot rename the "master" tag', + ERROR_CODES.VALIDATION_ERROR, + { tagName: oldName, protected: true } + ); + } + + // For API storage, we can't rename tags via CLI + this.checkTagMutationSupport('Tag renaming'); + + // Check if old tag exists + const allTags = await this.storage.getAllTags(); + if (!allTags.includes(oldName)) { + throw new TaskMasterError( + `Tag "${oldName}" does not exist`, + ERROR_CODES.NOT_FOUND, + { tagName: oldName } + ); + } + + // Check if new name already exists + if (allTags.includes(newName)) { + throw new TaskMasterError( + `Tag "${newName}" already exists`, + ERROR_CODES.VALIDATION_ERROR, + { tagName: newName } + ); + } + + // Delegate to storage + await this.storage.renameTag(oldName, newName); + } + + /** + * Copy an existing tag to create a new tag with the same tasks + * For API storage: throws error (client should show alternative) + */ + async copyTag( + sourceName: string, + targetName: string, + _options: CopyTagOptions = {} + ): Promise { + // Validate both names + this.validateTagName(sourceName, 'Source tag name'); + this.validateTagName(targetName, 'Target tag name'); + + // For API storage, we can't copy tags via CLI + this.checkTagMutationSupport('Tag copying'); + + // Check if source tag exists + const allTags = await this.storage.getAllTags(); + if (!allTags.includes(sourceName)) { + throw new TaskMasterError( + `Source tag "${sourceName}" does not exist`, + ERROR_CODES.NOT_FOUND, + { tagName: sourceName } + ); + } + + // Check if target name already exists + if (allTags.includes(targetName)) { + throw new TaskMasterError( + `Target tag "${targetName}" already exists`, + ERROR_CODES.VALIDATION_ERROR, + { tagName: targetName } + ); + } + + // Delegate to storage + await this.storage.copyTag(sourceName, targetName); + } + + /** + * Get all tags with statistics + * Works with both file and API storage + */ + async getTagsWithStats() { + return await this.storage.getTagsWithStats(); + } +} diff --git a/packages/tm-core/src/modules/tasks/services/task-service.ts b/packages/tm-core/src/modules/tasks/services/task-service.ts index b4e62340..c08acb18 100644 --- a/packages/tm-core/src/modules/tasks/services/task-service.ts +++ b/packages/tm-core/src/modules/tasks/services/task-service.ts @@ -500,6 +500,14 @@ export class TaskService { return this.storage.getStorageType(); } + /** + * Get the storage instance + * Internal use only - used by other services in the tasks module + */ + getStorage(): IStorage { + return this.storage; + } + /** * Get current active tag */ @@ -758,4 +766,45 @@ export class TaskService { ); } } + + /** + * Get all tags with detailed statistics including task counts + * Delegates to storage layer which handles file vs API implementation + */ + async getTagsWithStats() { + // Ensure we have storage + if (!this.storage) { + throw new TaskMasterError( + 'Storage not initialized', + ERROR_CODES.STORAGE_ERROR + ); + } + + // Auto-initialize if needed + if (!this.initialized) { + await this.initialize(); + } + + try { + return await this.storage.getTagsWithStats(); + } catch (error) { + // If it's a user-facing error (like NO_BRIEF_SELECTED), don't wrap it + if ( + error instanceof TaskMasterError && + error.is(ERROR_CODES.NO_BRIEF_SELECTED) + ) { + throw error; + } + + throw new TaskMasterError( + 'Failed to get tags with stats', + ERROR_CODES.STORAGE_ERROR, + { + operation: 'getTagsWithStats', + resource: 'tags' + }, + error as Error + ); + } + } } diff --git a/packages/tm-core/src/modules/tasks/tasks-domain.ts b/packages/tm-core/src/modules/tasks/tasks-domain.ts index 8bba4ecc..027db554 100644 --- a/packages/tm-core/src/modules/tasks/tasks-domain.ts +++ b/packages/tm-core/src/modules/tasks/tasks-domain.ts @@ -4,10 +4,18 @@ */ import type { ConfigManager } from '../config/managers/config-manager.js'; +import type { AuthDomain } from '../auth/auth-domain.js'; +import { BriefsDomain } from '../briefs/briefs-domain.js'; import { TaskService } from './services/task-service.js'; import { TaskExecutionService } from './services/task-execution-service.js'; import { TaskLoaderService } from './services/task-loader.service.js'; import { PreflightChecker } from './services/preflight-checker.service.js'; +import { TagService } from './services/tag.service.js'; +import type { + CreateTagOptions, + DeleteTagOptions, + CopyTagOptions +} from './services/tag.service.js'; import type { Subtask, Task, TaskStatus } from '../../common/types/index.js'; import type { @@ -32,16 +40,22 @@ export class TasksDomain { private executionService: TaskExecutionService; private loaderService: TaskLoaderService; private preflightChecker: PreflightChecker; + private briefsDomain: BriefsDomain; + private tagService!: TagService; - constructor(configManager: ConfigManager) { + constructor(configManager: ConfigManager, _authDomain?: AuthDomain) { this.taskService = new TaskService(configManager); this.executionService = new TaskExecutionService(this.taskService); this.loaderService = new TaskLoaderService(this.taskService); this.preflightChecker = new PreflightChecker(configManager.getProjectRoot()); + this.briefsDomain = new BriefsDomain(); } async initialize(): Promise { await this.taskService.initialize(); + + // TagService needs storage - get it from TaskService AFTER initialization + this.tagService = new TagService(this.taskService.getStorage()); } // ========== Task Retrieval ========== @@ -183,6 +197,40 @@ export class TasksDomain { return this.taskService.setActiveTag(tag); } + /** + * Resolve a brief by ID, name, or partial match without switching + * Returns the full brief object + * + * Supports: + * - Full UUID + * - Last 8 characters of UUID + * - Brief name (exact or partial match) + * + * Only works with API storage (briefs). + * + * @param briefIdOrName - Brief identifier + * @param orgId - Optional organization ID + * @returns The resolved brief object + */ + async resolveBrief(briefIdOrName: string, orgId?: string): Promise { + return this.briefsDomain.resolveBrief(briefIdOrName, orgId); + } + + /** + * Switch to a different tag/brief context + * For file storage: updates active tag in state + * For API storage: looks up brief by name and updates auth context + */ + async switchTag(tagName: string): Promise { + const storageType = this.taskService.getStorageType(); + + if (storageType === 'file') { + await this.setActiveTag(tagName); + } else { + await this.briefsDomain.switchBrief(tagName); + } + } + // ========== Task Execution ========== /** @@ -266,6 +314,55 @@ export class TasksDomain { return this.preflightChecker.detectDefaultBranch(); } + // ========== Tag Management ========== + + /** + * Create a new tag + * For file storage: creates tag locally with optional task copying + * For API storage: throws error (client should redirect to web UI) + */ + async createTag(name: string, options?: CreateTagOptions) { + return this.tagService.createTag(name, options); + } + + /** + * Delete an existing tag + * Cannot delete master tag + * For file storage: deletes tag locally + * For API storage: throws error (client should redirect to web UI) + */ + async deleteTag(name: string, options?: DeleteTagOptions) { + return this.tagService.deleteTag(name, options); + } + + /** + * Rename an existing tag + * Cannot rename master tag + * For file storage: renames tag locally + * For API storage: throws error (client should redirect to web UI) + */ + async renameTag(oldName: string, newName: string) { + return this.tagService.renameTag(oldName, newName); + } + + /** + * Copy an existing tag to create a new tag with the same tasks + * For file storage: copies tag locally + * For API storage: throws error (client should show alternative) + */ + async copyTag(source: string, target: string, options?: CopyTagOptions) { + return this.tagService.copyTag(source, target, options); + } + + /** + * Get all tags with detailed statistics including task counts + * For API storage, returns briefs with task counts + * For file storage, returns tags from tasks.json with counts + */ + async getTagsWithStats() { + return this.tagService.getTagsWithStats(); + } + // ========== Storage Information ========== /** diff --git a/packages/tm-core/src/modules/workflow/managers/workflow-state-manager.spec.ts b/packages/tm-core/src/modules/workflow/managers/workflow-state-manager.spec.ts index 6c7045b1..b2e9fbf9 100644 --- a/packages/tm-core/src/modules/workflow/managers/workflow-state-manager.spec.ts +++ b/packages/tm-core/src/modules/workflow/managers/workflow-state-manager.spec.ts @@ -2,10 +2,10 @@ * @fileoverview Tests for WorkflowStateManager path sanitization */ -import { describe, it, expect } from 'vitest'; -import { WorkflowStateManager } from './workflow-state-manager.js'; import os from 'node:os'; import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { WorkflowStateManager } from './workflow-state-manager.js'; describe('WorkflowStateManager', () => { describe('getProjectIdentifier', () => { diff --git a/packages/tm-core/src/modules/workflow/managers/workflow-state-manager.ts b/packages/tm-core/src/modules/workflow/managers/workflow-state-manager.ts index 716adf10..2544b54d 100644 --- a/packages/tm-core/src/modules/workflow/managers/workflow-state-manager.ts +++ b/packages/tm-core/src/modules/workflow/managers/workflow-state-manager.ts @@ -7,11 +7,11 @@ */ import fs from 'node:fs/promises'; -import path from 'node:path'; import os from 'node:os'; +import path from 'node:path'; import { Writer } from 'steno'; -import type { WorkflowState } from '../types.js'; import { getLogger } from '../../../common/logger/index.js'; +import type { WorkflowState } from '../types.js'; export interface WorkflowStateBackup { timestamp: string; diff --git a/packages/tm-core/src/modules/workflow/orchestrators/workflow-orchestrator.test.ts b/packages/tm-core/src/modules/workflow/orchestrators/workflow-orchestrator.test.ts index ced1abd5..fe1c28a2 100644 --- a/packages/tm-core/src/modules/workflow/orchestrators/workflow-orchestrator.test.ts +++ b/packages/tm-core/src/modules/workflow/orchestrators/workflow-orchestrator.test.ts @@ -1,13 +1,13 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { WorkflowOrchestrator } from '../orchestrators/workflow-orchestrator.js'; -import type { - WorkflowContext, - WorkflowPhase, - WorkflowEventData, - WorkflowError -} from '../types.js'; import { TestResultValidator } from '../services/test-result-validator.js'; import type { TestResult } from '../services/test-result-validator.types.js'; +import type { + WorkflowContext, + WorkflowError, + WorkflowEventData, + WorkflowPhase +} from '../types.js'; describe('WorkflowOrchestrator - State Machine Structure', () => { let orchestrator: WorkflowOrchestrator; diff --git a/packages/tm-core/src/modules/workflow/orchestrators/workflow-orchestrator.ts b/packages/tm-core/src/modules/workflow/orchestrators/workflow-orchestrator.ts index ce8cc16f..da2790cb 100644 --- a/packages/tm-core/src/modules/workflow/orchestrators/workflow-orchestrator.ts +++ b/packages/tm-core/src/modules/workflow/orchestrators/workflow-orchestrator.ts @@ -1,17 +1,17 @@ +import type { TestResultValidator } from '../services/test-result-validator.js'; import type { - WorkflowPhase, + StateTransition, + SubtaskInfo, TDDPhase, WorkflowContext, + WorkflowError, WorkflowEvent, - WorkflowState, - StateTransition, - WorkflowEventType, WorkflowEventData, WorkflowEventListener, - SubtaskInfo, - WorkflowError + WorkflowEventType, + WorkflowPhase, + WorkflowState } from '../types.js'; -import type { TestResultValidator } from '../services/test-result-validator.js'; /** * Lightweight state machine for TDD workflow orchestration diff --git a/packages/tm-core/src/modules/workflow/services/test-result-validator.test.ts b/packages/tm-core/src/modules/workflow/services/test-result-validator.test.ts index 1667d3ac..9a5fbcd2 100644 --- a/packages/tm-core/src/modules/workflow/services/test-result-validator.test.ts +++ b/packages/tm-core/src/modules/workflow/services/test-result-validator.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { TestResultValidator } from './test-result-validator.js'; import type { + TestPhase, TestResult, - ValidationResult, - TestPhase + ValidationResult } from './test-result-validator.types.js'; describe('TestResultValidator - Input Validation', () => { diff --git a/packages/tm-core/src/modules/workflow/services/test-result-validator.ts b/packages/tm-core/src/modules/workflow/services/test-result-validator.ts index 0248626e..53f7bbf7 100644 --- a/packages/tm-core/src/modules/workflow/services/test-result-validator.ts +++ b/packages/tm-core/src/modules/workflow/services/test-result-validator.ts @@ -1,9 +1,9 @@ import { z } from 'zod'; import type { - TestResult, - ValidationResult, CoverageThresholds, - PhaseValidationOptions + PhaseValidationOptions, + TestResult, + ValidationResult } from './test-result-validator.types.js'; /** diff --git a/packages/tm-core/src/modules/workflow/services/workflow-activity-logger.ts b/packages/tm-core/src/modules/workflow/services/workflow-activity-logger.ts index 711be203..53a6d9e7 100644 --- a/packages/tm-core/src/modules/workflow/services/workflow-activity-logger.ts +++ b/packages/tm-core/src/modules/workflow/services/workflow-activity-logger.ts @@ -5,13 +5,13 @@ * for debugging, auditing, and workflow analysis. */ +import { getLogger } from '../../../common/logger/index.js'; +import { + type ActivityEvent, + logActivity +} from '../../storage/adapters/activity-logger.js'; import type { WorkflowOrchestrator } from '../orchestrators/workflow-orchestrator.js'; import type { WorkflowEventData, WorkflowEventType } from '../types.js'; -import { - logActivity, - type ActivityEvent -} from '../../storage/adapters/activity-logger.js'; -import { getLogger } from '../../../common/logger/index.js'; /** * All workflow event types that should be logged diff --git a/packages/tm-core/src/modules/workflow/services/workflow.service.ts b/packages/tm-core/src/modules/workflow/services/workflow.service.ts index cbab2398..46039314 100644 --- a/packages/tm-core/src/modules/workflow/services/workflow.service.ts +++ b/packages/tm-core/src/modules/workflow/services/workflow.service.ts @@ -3,18 +3,18 @@ * Provides a simplified API for MCP tools while delegating to WorkflowOrchestrator */ -import { WorkflowOrchestrator } from '../orchestrators/workflow-orchestrator.js'; +import { GitAdapter } from '../../git/adapters/git-adapter.js'; import { WorkflowStateManager } from '../managers/workflow-state-manager.js'; -import { WorkflowActivityLogger } from './workflow-activity-logger.js'; +import { WorkflowOrchestrator } from '../orchestrators/workflow-orchestrator.js'; import type { - WorkflowContext, SubtaskInfo, - TestResult, - WorkflowPhase, TDDPhase, + TestResult, + WorkflowContext, + WorkflowPhase, WorkflowState } from '../types.js'; -import { GitAdapter } from '../../git/adapters/git-adapter.js'; +import { WorkflowActivityLogger } from './workflow-activity-logger.js'; /** * Options for starting a new workflow diff --git a/packages/tm-core/src/modules/workflow/workflow-domain.ts b/packages/tm-core/src/modules/workflow/workflow-domain.ts index f2823321..ab7d27d1 100644 --- a/packages/tm-core/src/modules/workflow/workflow-domain.ts +++ b/packages/tm-core/src/modules/workflow/workflow-domain.ts @@ -6,9 +6,9 @@ import type { ConfigManager } from '../config/managers/config-manager.js'; import { WorkflowService } from './services/workflow.service.js'; import type { + NextAction, StartWorkflowOptions, - WorkflowStatus, - NextAction + WorkflowStatus } from './services/workflow.service.js'; import type { TestResult, WorkflowContext } from './types.js'; diff --git a/packages/tm-core/src/subpath-exports.test.ts b/packages/tm-core/src/subpath-exports.test.ts index 71bc0bb2..a72481db 100644 --- a/packages/tm-core/src/subpath-exports.test.ts +++ b/packages/tm-core/src/subpath-exports.test.ts @@ -3,7 +3,7 @@ * This demonstrates how consumers can use granular imports for better tree-shaking */ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; describe('Subpath Exports', () => { it('should allow importing from auth subpath', async () => { diff --git a/packages/tm-core/src/tm-core.ts b/packages/tm-core/src/tm-core.ts index 35ae76ff..65362805 100644 --- a/packages/tm-core/src/tm-core.ts +++ b/packages/tm-core/src/tm-core.ts @@ -4,13 +4,13 @@ */ import path from 'node:path'; -import { ConfigManager } from './modules/config/managers/config-manager.js'; -import { TasksDomain } from './modules/tasks/tasks-domain.js'; import { AuthDomain } from './modules/auth/auth-domain.js'; -import { WorkflowDomain } from './modules/workflow/workflow-domain.js'; -import { GitDomain } from './modules/git/git-domain.js'; import { ConfigDomain } from './modules/config/config-domain.js'; +import { ConfigManager } from './modules/config/managers/config-manager.js'; +import { GitDomain } from './modules/git/git-domain.js'; import { IntegrationDomain } from './modules/integration/integration-domain.js'; +import { TasksDomain } from './modules/tasks/tasks-domain.js'; +import { WorkflowDomain } from './modules/workflow/workflow-domain.js'; import { ERROR_CODES, @@ -18,9 +18,9 @@ import { } from './common/errors/task-master-error.js'; import type { IConfiguration } from './common/interfaces/configuration.interface.js'; import { - createLogger, + type Logger, type LoggerConfig, - type Logger + createLogger } from './common/logger/index.js'; /** @@ -170,8 +170,8 @@ export class TmCore { } // Initialize domain facades - this._tasks = new TasksDomain(this._configManager); this._auth = new AuthDomain(); + this._tasks = new TasksDomain(this._configManager, this._auth); this._workflow = new WorkflowDomain(this._configManager); this._git = new GitDomain(this._projectPath); this._config = new ConfigDomain(this._configManager); diff --git a/packages/tm-core/tests/auth/auth-refresh.test.ts b/packages/tm-core/tests/auth/auth-refresh.test.ts index 0ae4f357..d71ac24b 100644 --- a/packages/tm-core/tests/auth/auth-refresh.test.ts +++ b/packages/tm-core/tests/auth/auth-refresh.test.ts @@ -1,8 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; import type { Session } from '@supabase/supabase-js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthManager } from '../../src/auth/auth-manager'; import { CredentialStore } from '../../src/auth/credential-store'; import type { AuthCredentials } from '../../src/auth/types'; diff --git a/packages/tm-core/tests/integration/auth-token-refresh.test.ts b/packages/tm-core/tests/integration/auth-token-refresh.test.ts index 10ff45a2..dce90112 100644 --- a/packages/tm-core/tests/integration/auth-token-refresh.test.ts +++ b/packages/tm-core/tests/integration/auth-token-refresh.test.ts @@ -5,11 +5,11 @@ * when making API calls through AuthManager. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; import type { Session } from '@supabase/supabase-js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthManager } from '../../src/modules/auth/managers/auth-manager.js'; import { CredentialStore } from '../../src/modules/auth/services/credential-store.js'; import type { AuthCredentials } from '../../src/modules/auth/types.js'; diff --git a/packages/tm-core/tests/integration/storage/activity-logger.test.ts b/packages/tm-core/tests/integration/storage/activity-logger.test.ts index 8a0b0f2d..02e17a10 100644 --- a/packages/tm-core/tests/integration/storage/activity-logger.test.ts +++ b/packages/tm-core/tests/integration/storage/activity-logger.test.ts @@ -1,11 +1,11 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import fs from 'fs-extra'; -import path from 'path'; import os from 'os'; +import path from 'path'; +import fs from 'fs-extra'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + filterActivityLog, logActivity, - readActivityLog, - filterActivityLog + readActivityLog } from '../../../src/storage/activity-logger.js'; describe('Activity Logger', () => { diff --git a/packages/tm-core/tests/unit/executor.test.ts b/packages/tm-core/tests/unit/executor.test.ts index f73f283a..547ceb19 100644 --- a/packages/tm-core/tests/unit/executor.test.ts +++ b/packages/tm-core/tests/unit/executor.test.ts @@ -2,10 +2,10 @@ * Tests for executor functionality */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { - ExecutorFactory, ClaudeExecutor, + ExecutorFactory, type ExecutorOptions } from '../../src/executors/index.js'; diff --git a/scripts/dev.js b/scripts/dev.js index 91701aa0..a472aed1 100755 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -8,9 +8,9 @@ * It imports functionality from the modules directory and provides a CLI. */ -import dotenv from 'dotenv'; -import { findProjectRoot } from '@tm/core'; import { join } from 'node:path'; +import { findProjectRoot } from '@tm/core'; +import dotenv from 'dotenv'; // Store the original working directory // This is needed for commands that take relative paths as arguments diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index 794829b9..d9937986 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -3975,10 +3975,12 @@ Examples: // ===== TAG MANAGEMENT COMMANDS ===== - // add-tag command + // add-tag command (DEPRECATED - use `tm tags add` instead) programInstance .command('add-tag') - .description('Create a new tag context for organizing tasks') + .description( + '[DEPRECATED] Create a new tag context for organizing tasks (use "tm tags add" instead)' + ) .argument( '[tagName]', 'Name of the new tag to create (optional when using --from-branch)' @@ -4002,6 +4004,16 @@ Examples: ) .option('-d, --description ', 'Optional description for the tag') .action(async (tagName, options) => { + // Show deprecation warning + console.warn( + chalk.yellow( + '⚠ Warning: "tm add-tag" is deprecated. Use "tm tags add" instead.' + ) + ); + console.log( + chalk.gray(' This command will be removed in a future version.\n') + ); + try { // Initialize TaskMaster const taskMaster = initTaskMaster({ @@ -4132,10 +4144,12 @@ Examples: process.exit(1); }); - // delete-tag command + // delete-tag command (DEPRECATED - use `tm tags remove` instead) programInstance .command('delete-tag') - .description('Delete an existing tag and all its tasks') + .description( + '[DEPRECATED] Delete an existing tag and all its tasks (use "tm tags remove" instead)' + ) .argument('', 'Name of the tag to delete') .option( '-f, --file ', @@ -4144,6 +4158,16 @@ Examples: ) .option('-y, --yes', 'Skip confirmation prompts') .action(async (tagName, options) => { + // Show deprecation warning + console.warn( + chalk.yellow( + '⚠ Warning: "tm delete-tag" is deprecated. Use "tm tags remove" instead.' + ) + ); + console.log( + chalk.gray(' This command will be removed in a future version.\n') + ); + try { // Initialize TaskMaster const taskMaster = initTaskMaster({ @@ -4188,62 +4212,17 @@ Examples: process.exit(1); }); - // tags command - programInstance - .command('tags') - .description('List all available tags with metadata') - .option( - '-f, --file ', - 'Path to the tasks file', - TASKMASTER_TASKS_FILE - ) - .option('--show-metadata', 'Show detailed metadata for each tag') - .option('--tag ', 'Specify tag context for task operations') - .action(async (options) => { - try { - // Initialize TaskMaster - const taskMaster = initTaskMaster({ - tasksPath: options.file || true, - tag: options.tag - }); - const tasksPath = taskMaster.getTasksPath(); + // tags command - REMOVED + // This command has been replaced by the new CommandRegistry-based TagsCommand + // in apps/cli/src/commands/tags.command.ts + // The old implementation is no longer needed - // Validate tasks file exists - if (!fs.existsSync(tasksPath)) { - console.error( - chalk.red(`Error: Tasks file not found at path: ${tasksPath}`) - ); - process.exit(1); - } - - const listOptions = { - showTaskCounts: true, - showMetadata: options.showMetadata || false - }; - - const context = { - projectRoot: taskMaster.getProjectRoot(), - commandName: 'tags', - outputType: 'cli' - }; - - await tags(taskMaster.getTasksPath(), listOptions, context, 'text'); - } catch (error) { - console.error(chalk.red(`Error listing tags: ${error.message}`)); - showTagsHelp(); - process.exit(1); - } - }) - .on('error', function (err) { - console.error(chalk.red(`Error: ${err.message}`)); - showTagsHelp(); - process.exit(1); - }); - - // use-tag command + // use-tag command (DEPRECATED - use `tm tags use` instead) programInstance .command('use-tag') - .description('Switch to a different tag context') + .description( + '[DEPRECATED] Switch to a different tag context (use "tm tags use" instead)' + ) .argument('', 'Name of the tag to switch to') .option( '-f, --file ', @@ -4251,6 +4230,16 @@ Examples: TASKMASTER_TASKS_FILE ) .action(async (tagName, options) => { + // Show deprecation warning + console.warn( + chalk.yellow( + '⚠ Warning: "tm use-tag" is deprecated. Use "tm tags use" instead.' + ) + ); + console.log( + chalk.gray(' This command will be removed in a future version.\n') + ); + try { // Initialize TaskMaster const taskMaster = initTaskMaster({ @@ -4285,10 +4274,12 @@ Examples: process.exit(1); }); - // rename-tag command + // rename-tag command (DEPRECATED - use `tm tags rename` instead) programInstance .command('rename-tag') - .description('Rename an existing tag') + .description( + '[DEPRECATED] Rename an existing tag (use "tm tags rename" instead)' + ) .argument('', 'Current name of the tag') .argument('', 'New name for the tag') .option( @@ -4297,6 +4288,16 @@ Examples: TASKMASTER_TASKS_FILE ) .action(async (oldName, newName, options) => { + // Show deprecation warning + console.warn( + chalk.yellow( + '⚠ Warning: "tm rename-tag" is deprecated. Use "tm tags rename" instead.' + ) + ); + console.log( + chalk.gray(' This command will be removed in a future version.\n') + ); + try { // Initialize TaskMaster const taskMaster = initTaskMaster({ @@ -4336,10 +4337,12 @@ Examples: process.exit(1); }); - // copy-tag command + // copy-tag command (DEPRECATED - use `tm tags copy` instead) programInstance .command('copy-tag') - .description('Copy an existing tag to create a new tag with the same tasks') + .description( + '[DEPRECATED] Copy an existing tag to create a new tag with the same tasks (use "tm tags copy" instead)' + ) .argument('', 'Name of the source tag to copy from') .argument('', 'Name of the new tag to create') .option( @@ -4349,6 +4352,16 @@ Examples: ) .option('-d, --description ', 'Optional description for the new tag') .action(async (sourceName, targetName, options) => { + // Show deprecation warning + console.warn( + chalk.yellow( + '⚠ Warning: "tm copy-tag" is deprecated. Use "tm tags copy" instead.' + ) + ); + console.log( + chalk.gray(' This command will be removed in a future version.\n') + ); + try { // Initialize TaskMaster const taskMaster = initTaskMaster({ diff --git a/scripts/modules/task-manager/tag-management.js b/scripts/modules/task-manager/tag-management.js index a4abd183..c96b526a 100644 --- a/scripts/modules/task-manager/tag-management.js +++ b/scripts/modules/task-manager/tag-management.js @@ -18,6 +18,11 @@ import { } from '../utils.js'; import { displayBanner, getStatusWithColor } from '../ui.js'; import findNextTask from './find-next-task.js'; +import { + tryListTagsViaRemote, + tryUseTagViaRemote, + tryAddTagViaRemote +} from '@tm/bridge'; /** * Create a new tag context @@ -52,6 +57,28 @@ async function createTag( success: (...args) => log('success', ...args) }; + // Check if API storage should handle this via remote + const remoteResult = await tryAddTagViaRemote({ + tagName, + projectRoot: projectRoot || findProjectRoot(), + isMCP: !!mcpLog, + outputFormat, + report: (level, ...args) => logFn[level](...args) + }); + + // If remote handled it, return the result + if (remoteResult) { + if (!remoteResult.success) { + throw new Error(remoteResult.message || 'Remote tag creation failed'); + } + if (outputFormat === 'json') { + return remoteResult; + } + // For text output, the bridge already displayed the message + return remoteResult; + } + + // Otherwise, continue with file-based logic below try { // Validate tag name if (!tagName || typeof tagName !== 'string') { @@ -531,6 +558,34 @@ async function tags( try { logFn.info('Listing available tags'); + // Try API storage first via bridge + const bridgeResult = await tryListTagsViaRemote({ + projectRoot, + showMetadata, + isMCP: !!mcpLog, + outputFormat, + report: (level, ...args) => { + if (logFn[level]) { + logFn[level](...args); + } else { + logFn.info(...args); + } + } + }); + + // If bridge handled it (API storage), return the result + if (bridgeResult) { + logFn.success(`Found ${bridgeResult.totalTags} tags via API storage`); + return { + tags: bridgeResult.tags, + currentTag: bridgeResult.currentTag, + totalTags: bridgeResult.totalTags + }; + } + + // Fall through to file storage logic + logFn.info('Using file storage for tags'); + // Read current tasks data const data = readJSON(tasksPath, projectRoot); if (!data) { @@ -735,6 +790,32 @@ async function useTag( logFn.info(`Switching to tag: ${tagName}`); + // Try API storage first via bridge + const bridgeResult = await tryUseTagViaRemote({ + tagName, + projectRoot, + isMCP: !!mcpLog, + outputFormat, + report: (level, ...args) => { + if (logFn[level]) { + logFn[level](...args); + } else { + logFn.info(...args); + } + } + }); + + // If bridge handled it (API storage), return the result + if (bridgeResult) { + logFn.success( + `Successfully switched to tag "${tagName}" via API storage` + ); + return bridgeResult; + } + + // Fall through to file storage logic + logFn.info('Using file storage for tag switch'); + // Read current tasks data to verify tag exists const data = readJSON(tasksPath, projectRoot); if (!data) { diff --git a/scripts/modules/task-manager/update-task-by-id.js b/scripts/modules/task-manager/update-task-by-id.js index 440e3022..04b52848 100644 --- a/scripts/modules/task-manager/update-task-by-id.js +++ b/scripts/modules/task-manager/update-task-by-id.js @@ -130,8 +130,10 @@ async function updateTaskById( } const numericTaskId = Number(idStr); const taskIndex = data.tasks.findIndex((task) => task.id === numericTaskId); - if (taskIndex === -1) + if (taskIndex === -1) { + report('error', `Task with ID ${numericTaskId} not found`); throw new Error(`Task with ID ${numericTaskId} not found.`); + } const taskToUpdate = data.tasks[taskIndex]; if (taskToUpdate.status === 'done' || taskToUpdate.status === 'completed') { report( diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..be99e622 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,24 @@ +# SonarQube/SonarCloud Configuration +sonar.projectKey=task-master-ai +sonar.organization=your-org-name +sonar.projectName=Task Master AI +sonar.projectVersion=0.31.2 + +# Source code directories +sonar.sources=apps,packages,scripts,src + +# Test directories (excluded from coverage) +sonar.tests=tests +sonar.test.inclusions=**/*.test.js,**/*.spec.js,**/*.test.ts,**/*.spec.ts + +# Exclude test files from source analysis +sonar.coverage.exclusions=**/*.test.js,**/*.spec.js,**/*.test.ts,**/*.spec.ts,**/tests/**,**/__tests__/**,**/node_modules/**,**/dist/**,**/coverage/** + +# Exclude other non-source files +sonar.exclusions=**/node_modules/**,**/dist/**,**/coverage/**,**/*.config.js,**/*.config.ts + +# JavaScript/TypeScript settings +sonar.javascript.lcov.reportPaths=coverage/lcov.info + +# Encoding +sonar.sourceEncoding=UTF-8 diff --git a/tests/fixtures/sample-tasks.js b/tests/fixtures/sample-tasks.js index c4ad2b1e..4feda773 100644 --- a/tests/fixtures/sample-tasks.js +++ b/tests/fixtures/sample-tasks.js @@ -123,3 +123,120 @@ export const crossLevelDependencyTasks = { } ] }; + +// ============================================================================ +// Tagged Format Fixtures (for tag-aware system tests) +// ============================================================================ + +/** + * Single task in master tag - minimal fixture + * Use: Basic happy path tests + */ +export const taggedOneTask = { + tag: 'master', + tasks: [ + { + id: 1, + title: 'Task 1', + description: 'First task', + status: 'pending', + dependencies: [], + priority: 'medium' + } + ] +}; + +/** + * Task with subtasks in master tag + * Use: Testing subtask operations (expand, update-subtask) + */ +export const taggedTaskWithSubtasks = { + tag: 'master', + tasks: [ + { + id: 1, + title: 'Parent Task', + description: 'Task with subtasks', + status: 'in-progress', + dependencies: [], + priority: 'high', + subtasks: [ + { + id: 1, + title: 'Subtask 1.1', + description: 'First subtask', + status: 'done', + dependencies: [] + }, + { + id: 2, + title: 'Subtask 1.2', + description: 'Second subtask', + status: 'pending', + dependencies: [1] + } + ] + } + ] +}; + +/** + * Multiple tasks with dependencies in master tag + * Use: Testing dependency operations, task ordering + */ +export const taggedTasksWithDependencies = { + tag: 'master', + tasks: [ + { + id: 1, + title: 'Setup', + description: 'Initial setup task', + status: 'done', + dependencies: [], + priority: 'high' + }, + { + id: 2, + title: 'Core Feature', + description: 'Main feature implementation', + status: 'in-progress', + dependencies: [1], + priority: 'high' + }, + { + id: 3, + title: 'Polish', + description: 'Final touches', + status: 'pending', + dependencies: [2], + priority: 'low' + } + ] +}; + +/** + * Empty tag - no tasks + * Use: Testing edge cases, "add first task" scenarios + */ +export const taggedEmptyTasks = { + tag: 'master', + tasks: [] +}; + +/** + * Helper function to create custom tagged fixture + * @param {string} tagName - Tag name (default: 'master') + * @param {Array} tasks - Array of task objects + * @returns {Object} Tagged task data + * + * @example + * const customData = createTaggedFixture('feature-branch', [ + * { id: 1, title: 'Custom Task', status: 'pending', dependencies: [] } + * ]); + */ +export function createTaggedFixture(tagName = 'master', tasks = []) { + return { + tag: tagName, + tasks + }; +} diff --git a/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js b/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js index 09b3e2d3..adb226e7 100644 --- a/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js +++ b/tests/unit/scripts/modules/task-manager/analyze-task-complexity.test.js @@ -35,6 +35,7 @@ jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ aggregateTelemetry: jest.fn((telemetryArray) => telemetryArray[0] || {}), ensureTagMetadata: jest.fn((tagObj) => tagObj), getCurrentTag: jest.fn(() => 'master'), + resolveTag: jest.fn(() => 'master'), flattenTasksWithSubtasks: jest.fn((tasks) => tasks), getTagAwareFilePath: createGetTagAwareFilePathMock(), slugifyTagForFilePath: createSlugifyTagForFilePathMock(), diff --git a/tests/unit/scripts/modules/task-manager/complexity-report-tag-isolation.test.js b/tests/unit/scripts/modules/task-manager/complexity-report-tag-isolation.test.js index 67abff90..08d5952f 100644 --- a/tests/unit/scripts/modules/task-manager/complexity-report-tag-isolation.test.js +++ b/tests/unit/scripts/modules/task-manager/complexity-report-tag-isolation.test.js @@ -7,6 +7,34 @@ import { jest } from '@jest/globals'; import fs from 'fs'; import path from 'path'; +// Mock fs module - consolidated single registration +const mockExistsSync = jest.fn(); +const mockReadFileSync = jest.fn(); +const mockWriteFileSync = jest.fn(); +const mockUnlinkSync = jest.fn(); +const mockMkdirSync = jest.fn(); +const mockReaddirSync = jest.fn(() => []); +const mockStatSync = jest.fn(() => ({ isDirectory: () => false })); + +jest.unstable_mockModule('fs', () => ({ + default: { + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + unlinkSync: mockUnlinkSync, + mkdirSync: mockMkdirSync, + readdirSync: mockReaddirSync, + statSync: mockStatSync + }, + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + unlinkSync: mockUnlinkSync, + mkdirSync: mockMkdirSync, + readdirSync: mockReaddirSync, + statSync: mockStatSync +})); + // Mock the dependencies jest.unstable_mockModule('../../../../../src/utils/path-utils.js', () => ({ resolveComplexityReportOutputPath: jest.fn(), @@ -59,6 +87,7 @@ jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ aggregateTelemetry: jest.fn((telemetryArray) => telemetryArray[0] || {}), ensureTagMetadata: jest.fn((tagObj) => tagObj), getCurrentTag: jest.fn(() => 'master'), + resolveTag: jest.fn(() => 'master'), markMigrationForNotice: jest.fn(), performCompleteTagMigration: jest.fn(), setTasksForTag: jest.fn(), @@ -447,25 +476,30 @@ jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({ getContextWithColor: jest.fn((context) => context) })); -// Mock fs module -const mockWriteFileSync = jest.fn(); -const mockExistsSync = jest.fn(); -const mockReadFileSync = jest.fn(); -const mockMkdirSync = jest.fn(); +// fs module already mocked at top of file with shared spy references -jest.unstable_mockModule('fs', () => ({ - default: { - existsSync: mockExistsSync, - readFileSync: mockReadFileSync, - writeFileSync: mockWriteFileSync, - mkdirSync: mockMkdirSync - }, - existsSync: mockExistsSync, - readFileSync: mockReadFileSync, - writeFileSync: mockWriteFileSync, - mkdirSync: mockMkdirSync +// Mock @tm/bridge module +jest.unstable_mockModule('@tm/bridge', () => ({ + tryExpandViaRemote: jest.fn().mockResolvedValue(null) })); +// Mock bridge-utils module +jest.unstable_mockModule( + '../../../../../scripts/modules/bridge-utils.js', + () => ({ + createBridgeLogger: jest.fn(() => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn() + }, + report: jest.fn(), + isMCP: false + })) + }) +); + // Import the mocked modules const { resolveComplexityReportOutputPath, findComplexityReportPath } = await import('../../../../../src/utils/path-utils.js'); diff --git a/tests/unit/scripts/modules/task-manager/expand-task.test.js b/tests/unit/scripts/modules/task-manager/expand-task.test.js index 29723062..a43bf42b 100644 --- a/tests/unit/scripts/modules/task-manager/expand-task.test.js +++ b/tests/unit/scripts/modules/task-manager/expand-task.test.js @@ -1,8 +1,8 @@ +import fs from 'fs'; /** * Tests for the expand-task.js module */ import { jest } from '@jest/globals'; -import fs from 'fs'; import { createGetTagAwareFilePathMock, createSlugifyTagForFilePathMock @@ -25,6 +25,8 @@ jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ findTaskById: jest.fn(), findProjectRoot: jest.fn((tasksPath) => '/mock/project/root'), getCurrentTag: jest.fn(() => 'master'), + resolveTag: jest.fn(() => 'master'), + addComplexityToTask: jest.fn((task, complexity) => ({ ...task, complexity })), ensureTagMetadata: jest.fn((tagObj) => tagObj), flattenTasksWithSubtasks: jest.fn((tasks) => { const allTasks = []; @@ -202,6 +204,28 @@ jest.unstable_mockModule('cli-table3', () => ({ })) })); +// Mock @tm/bridge module +jest.unstable_mockModule('@tm/bridge', () => ({ + tryExpandViaRemote: jest.fn().mockResolvedValue(null) +})); + +// Mock bridge-utils module +jest.unstable_mockModule( + '../../../../../scripts/modules/bridge-utils.js', + () => ({ + createBridgeLogger: jest.fn(() => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn() + }, + report: jest.fn(), + isMCP: false + })) + }) +); + // Mock process.exit to prevent Jest worker crashes const mockExit = jest.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit called with "${code}"`); @@ -232,6 +256,8 @@ const { getDefaultSubtasks } = await import( '../../../../../scripts/modules/config-manager.js' ); +const { tryExpandViaRemote } = await import('@tm/bridge'); + // Import the module under test const { default: expandTask } = await import( '../../../../../scripts/modules/task-manager/expand-task.js' @@ -1276,4 +1302,124 @@ describe('expandTask', () => { expect(callArgs.systemPrompt).toContain('7 specific subtasks'); }); }); + + describe('Remote Expansion via Bridge', () => { + const tasksPath = '/fake/path/tasks.json'; + const taskId = '2'; + const context = { tag: 'master' }; + + test('should use remote expansion result when tryExpandViaRemote succeeds', async () => { + // Arrange - Mock successful remote expansion + const remoteResult = { + success: true, + message: 'Task expanded successfully via remote', + data: { + subtasks: [ + { + id: 1, + title: 'Remote Subtask 1', + description: 'First remote subtask', + status: 'pending', + dependencies: [] + }, + { + id: 2, + title: 'Remote Subtask 2', + description: 'Second remote subtask', + status: 'pending', + dependencies: [1] + } + ] + } + }; + tryExpandViaRemote.mockResolvedValue(remoteResult); + + // Act + const result = await expandTask( + tasksPath, + taskId, + 2, + false, + '', + context, + false + ); + + // Assert - Should use remote result and NOT call local AI service + expect(tryExpandViaRemote).toHaveBeenCalled(); + expect(generateObjectService).not.toHaveBeenCalled(); + expect(result).toEqual(remoteResult); + }); + + test('should fallback to local expansion when tryExpandViaRemote returns null', async () => { + // Arrange - Mock remote returning null (no remote available) + tryExpandViaRemote.mockResolvedValue(null); + + // Act + await expandTask(tasksPath, taskId, 3, false, '', context, false); + + // Assert - Should fallback to local expansion + expect(tryExpandViaRemote).toHaveBeenCalled(); + expect(generateObjectService).toHaveBeenCalled(); + expect(writeJSON).toHaveBeenCalled(); + }); + + test('should propagate error when tryExpandViaRemote throws error', async () => { + // Arrange - Mock remote throwing error (it re-throws, doesn't return null) + tryExpandViaRemote.mockImplementation(() => + Promise.reject(new Error('Remote expansion service unavailable')) + ); + + // Act & Assert - Should propagate the error (not fallback to local) + await expect( + expandTask(tasksPath, taskId, 3, false, '', context, false) + ).rejects.toThrow('Remote expansion service unavailable'); + + expect(tryExpandViaRemote).toHaveBeenCalled(); + // Local expansion should NOT be called when remote throws + expect(generateObjectService).not.toHaveBeenCalled(); + }); + + test('should pass correct parameters to tryExpandViaRemote', async () => { + // Arrange + const taskIdStr = '2'; // Use task 2 which exists in master tag + const numSubtasks = 5; + const additionalContext = 'Extra context for expansion'; + const useResearch = false; // Note: useResearch is the 4th param, not 7th + const force = true; // Note: force is the 7th param + const contextObj = { + tag: 'master', // Use master tag where task 2 exists + projectRoot: '/mock/project' + }; + tryExpandViaRemote.mockResolvedValue(null); + + // Act + await expandTask( + tasksPath, + taskIdStr, + numSubtasks, + useResearch, // 4th param + additionalContext, // 5th param + contextObj, // 6th param + force // 7th param + ); + + // Assert - Verify tryExpandViaRemote was called with correct params + // Note: The actual call has a flat structure, not nested context + expect(tryExpandViaRemote).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: taskIdStr, + numSubtasks, + additionalContext, + useResearch, + force, + projectRoot: '/mock/project', + tag: 'master', + isMCP: expect.any(Boolean), + outputFormat: expect.any(String), + report: expect.any(Function) + }) + ); + }); + }); }); diff --git a/tests/unit/scripts/modules/task-manager/update-subtask-by-id.test.js b/tests/unit/scripts/modules/task-manager/update-subtask-by-id.test.js index f3dfe5f0..fa11ef34 100644 --- a/tests/unit/scripts/modules/task-manager/update-subtask-by-id.test.js +++ b/tests/unit/scripts/modules/task-manager/update-subtask-by-id.test.js @@ -23,14 +23,25 @@ jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ isEmpty: jest.fn(() => false), resolveEnvVariable: jest.fn(), findTaskById: jest.fn(), - getCurrentTag: jest.fn(() => 'master') + getCurrentTag: jest.fn(() => 'master'), + resolveTag: jest.fn(() => 'master'), + addComplexityToTask: jest.fn((task, complexity) => ({ ...task, complexity })), + getTasksForTag: jest.fn((data, tag) => data[tag]?.tasks || []), + setTasksForTag: jest.fn(), + ensureTagMetadata: jest.fn((tagObj) => tagObj) })); jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({ + displayBanner: jest.fn(), getStatusWithColor: jest.fn((s) => s), startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })), stopLoadingIndicator: jest.fn(), - displayAiUsageSummary: jest.fn() + succeedLoadingIndicator: jest.fn(), + failLoadingIndicator: jest.fn(), + warnLoadingIndicator: jest.fn(), + infoLoadingIndicator: jest.fn(), + displayAiUsageSummary: jest.fn(), + displayContextAnalysis: jest.fn() })); jest.unstable_mockModule( @@ -81,6 +92,38 @@ jest.unstable_mockModule( }) ); +// Mock @tm/bridge module +jest.unstable_mockModule('@tm/bridge', () => ({ + tryUpdateViaRemote: jest.fn().mockResolvedValue(null) +})); + +// Mock bridge-utils module +jest.unstable_mockModule( + '../../../../../scripts/modules/bridge-utils.js', + () => ({ + createBridgeLogger: jest.fn(() => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn() + }, + report: jest.fn(), + isMCP: false + })) + }) +); + +// Mock fuzzyTaskSearch module +jest.unstable_mockModule( + '../../../../../scripts/modules/utils/fuzzyTaskSearch.js', + () => ({ + FuzzyTaskSearch: jest.fn().mockImplementation(() => ({ + search: jest.fn().mockReturnValue([]) + })) + }) +); + // Import mocked utils to leverage mocks later const { readJSON, log } = await import( '../../../../../scripts/modules/utils.js' diff --git a/tests/unit/scripts/modules/task-manager/update-task-by-id.test.js b/tests/unit/scripts/modules/task-manager/update-task-by-id.test.js index db53cd1a..1499e6e1 100644 --- a/tests/unit/scripts/modules/task-manager/update-task-by-id.test.js +++ b/tests/unit/scripts/modules/task-manager/update-task-by-id.test.js @@ -21,14 +21,25 @@ jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ isEmpty: jest.fn(() => false), resolveEnvVariable: jest.fn(), findTaskById: jest.fn(), - getCurrentTag: jest.fn(() => 'master') + getCurrentTag: jest.fn(() => 'master'), + resolveTag: jest.fn(() => 'master'), + addComplexityToTask: jest.fn((task, complexity) => ({ ...task, complexity })), + getTasksForTag: jest.fn((data, tag) => data[tag]?.tasks || []), + setTasksForTag: jest.fn(), + ensureTagMetadata: jest.fn((tagObj) => tagObj) })); jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({ + displayBanner: jest.fn(), getStatusWithColor: jest.fn((s) => s), startLoadingIndicator: jest.fn(() => ({ stop: jest.fn() })), stopLoadingIndicator: jest.fn(), - displayAiUsageSummary: jest.fn() + succeedLoadingIndicator: jest.fn(), + failLoadingIndicator: jest.fn(), + warnLoadingIndicator: jest.fn(), + infoLoadingIndicator: jest.fn(), + displayAiUsageSummary: jest.fn(), + displayContextAnalysis: jest.fn() })); jest.unstable_mockModule( @@ -72,13 +83,99 @@ jest.unstable_mockModule( }) ); +// Mock @tm/bridge module +jest.unstable_mockModule('@tm/bridge', () => ({ + tryUpdateViaRemote: jest.fn().mockResolvedValue(null) +})); + +// Mock bridge-utils module +jest.unstable_mockModule( + '../../../../../scripts/modules/bridge-utils.js', + () => ({ + createBridgeLogger: jest.fn(() => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn() + }, + report: jest.fn(), + isMCP: false + })) + }) +); + +// Mock prompt-manager module +jest.unstable_mockModule( + '../../../../../scripts/modules/prompt-manager.js', + () => ({ + getPromptManager: jest.fn().mockReturnValue({ + loadPrompt: jest.fn((promptId, params) => ({ + systemPrompt: + 'You are an AI assistant that helps update a software development task with new requirements and information.', + userPrompt: `Update the following task based on the provided information: ${params?.updatePrompt || 'User prompt for task update'}`, + metadata: { + templateId: 'update-task', + version: '1.0.0', + variant: 'default', + parameters: params || {} + } + })) + }) + }) +); + +// Mock contextGatherer module +jest.unstable_mockModule( + '../../../../../scripts/modules/utils/contextGatherer.js', + () => ({ + ContextGatherer: jest.fn().mockImplementation(() => ({ + gather: jest.fn().mockResolvedValue({ + fullContext: '', + summary: '' + }) + })) + }) +); + +// Mock fuzzyTaskSearch module +jest.unstable_mockModule( + '../../../../../scripts/modules/utils/fuzzyTaskSearch.js', + () => ({ + FuzzyTaskSearch: jest.fn().mockImplementation(() => ({ + search: jest.fn().mockReturnValue([]), + findRelevantTasks: jest.fn().mockReturnValue([]), + getTaskIds: jest.fn().mockReturnValue([]) + })) + }) +); + const { readJSON, log } = await import( '../../../../../scripts/modules/utils.js' ); +const { tryUpdateViaRemote } = await import('@tm/bridge'); +const { createBridgeLogger } = await import( + '../../../../../scripts/modules/bridge-utils.js' +); +const { getPromptManager } = await import( + '../../../../../scripts/modules/prompt-manager.js' +); +const { ContextGatherer } = await import( + '../../../../../scripts/modules/utils/contextGatherer.js' +); +const { FuzzyTaskSearch } = await import( + '../../../../../scripts/modules/utils/fuzzyTaskSearch.js' +); const { default: updateTaskById } = await import( '../../../../../scripts/modules/task-manager/update-task-by-id.js' ); +// Import test fixtures for consistent sample data +import { + taggedEmptyTasks, + taggedOneTask +} from '../../../../fixtures/sample-tasks.js'; + describe('updateTaskById validation', () => { beforeEach(() => { jest.clearAllMocks(); @@ -120,7 +217,7 @@ describe('updateTaskById validation', () => { test('throws error when task ID not found', async () => { const fs = await import('fs'); fs.existsSync.mockReturnValue(true); - readJSON.mockReturnValue({ tag: 'master', tasks: [] }); + readJSON.mockReturnValue(taggedEmptyTasks); await expect( updateTaskById( 'tasks/tasks.json', @@ -133,7 +230,8 @@ describe('updateTaskById validation', () => { 'json' ) ).rejects.toThrow('Task with ID 42 not found'); - expect(log).toHaveBeenCalled(); + // Note: The error is reported through the bridge logger (report), + // not the log function, so we don't assert on log being called }); }); @@ -339,3 +437,444 @@ describe('updateTaskById success path with generateObjectService', () => { ).rejects.toThrow('Updated task missing required fields'); }); }); + +describe('Remote Update via Bridge', () => { + let fs; + let generateObjectService; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + fs = await import('fs'); + const aiServices = await import( + '../../../../../scripts/modules/ai-services-unified.js' + ); + generateObjectService = aiServices.generateObjectService; + }); + + test('should use remote update result when tryUpdateViaRemote succeeds', async () => { + // Arrange - Mock successful remote update + const remoteResult = { + success: true, + message: 'Task updated successfully via remote', + data: { + task: { + id: 1, + title: 'Updated via Remote', + description: 'Updated description from remote', + status: 'in-progress', + dependencies: [], + priority: 'high', + details: 'Remote update details', + testStrategy: 'Remote test strategy', + subtasks: [] + } + } + }; + tryUpdateViaRemote.mockResolvedValue(remoteResult); + + fs.existsSync.mockReturnValue(true); + readJSON.mockReturnValue({ + tag: 'master', + tasks: [ + { + id: 1, + title: 'Original Task', + description: 'Original description', + status: 'pending', + dependencies: [], + priority: 'medium', + details: 'Original details', + testStrategy: 'Original test strategy', + subtasks: [] + } + ] + }); + + // Act + const result = await updateTaskById( + 'tasks/tasks.json', + 1, + 'Update this task', + false, + { tag: 'master' }, + 'json' + ); + + // Assert - Should use remote result and NOT call local AI service + expect(tryUpdateViaRemote).toHaveBeenCalled(); + expect(generateObjectService).not.toHaveBeenCalled(); + expect(result).toEqual(remoteResult); + }); + + test('should fallback to local update when tryUpdateViaRemote returns null', async () => { + // Arrange - Mock remote returning null (no remote available) + tryUpdateViaRemote.mockResolvedValue(null); + + fs.existsSync.mockReturnValue(true); + readJSON.mockReturnValue({ + tag: 'master', + tasks: [ + { + id: 1, + title: 'Task', + description: 'Description', + status: 'pending', + dependencies: [], + priority: 'medium', + details: 'Details', + testStrategy: 'Test strategy', + subtasks: [] + } + ] + }); + + generateObjectService.mockResolvedValue({ + mainResult: { + task: { + id: 1, + title: 'Updated Task', + description: 'Updated description', + status: 'in-progress', + dependencies: [], + priority: 'high', + details: 'Updated details', + testStrategy: 'Updated test strategy', + subtasks: [] + } + }, + telemetryData: {} + }); + + // Act + await updateTaskById( + 'tasks/tasks.json', + 1, + 'Update this task', + false, + { tag: 'master' }, + 'json' + ); + + // Assert - Should fallback to local update + expect(tryUpdateViaRemote).toHaveBeenCalled(); + expect(generateObjectService).toHaveBeenCalled(); + }); + + test('should propagate error when tryUpdateViaRemote throws error', async () => { + // Arrange - Mock remote throwing error (it re-throws, doesn't return null) + tryUpdateViaRemote.mockImplementation(() => + Promise.reject(new Error('Remote update service unavailable')) + ); + + fs.existsSync.mockReturnValue(true); + readJSON.mockReturnValue({ + tag: 'master', + tasks: [ + { + id: 1, + title: 'Task', + description: 'Description', + status: 'pending', + dependencies: [], + priority: 'medium', + details: 'Details', + testStrategy: 'Test strategy', + subtasks: [] + } + ] + }); + + generateObjectService.mockResolvedValue({ + mainResult: { + task: { + id: 1, + title: 'Updated Task', + description: 'Updated description', + status: 'in-progress', + dependencies: [], + priority: 'high', + details: 'Updated details', + testStrategy: 'Updated test strategy', + subtasks: [] + } + }, + telemetryData: {} + }); + + // Act & Assert - Should propagate the error (not fallback to local) + await expect( + updateTaskById( + 'tasks/tasks.json', + 1, + 'Update this task', + false, + { tag: 'master' }, + 'json' + ) + ).rejects.toThrow('Remote update service unavailable'); + + expect(tryUpdateViaRemote).toHaveBeenCalled(); + // Local update should NOT be called when remote throws + expect(generateObjectService).not.toHaveBeenCalled(); + }); +}); + +describe('Prompt Manager Integration', () => { + let fs; + let generateObjectService; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + fs = await import('fs'); + const aiServices = await import( + '../../../../../scripts/modules/ai-services-unified.js' + ); + generateObjectService = aiServices.generateObjectService; + tryUpdateViaRemote.mockResolvedValue(null); // No remote + }); + + test('should use prompt manager to load update prompts', async () => { + // Arrange + fs.existsSync.mockReturnValue(true); + readJSON.mockReturnValue({ + tag: 'master', + tasks: [ + { + id: 1, + title: 'Task', + description: 'Description', + status: 'pending', + dependencies: [], + priority: 'medium', + details: 'Details', + testStrategy: 'Test strategy', + subtasks: [] + } + ] + }); + + generateObjectService.mockResolvedValue({ + mainResult: { + task: { + id: 1, + title: 'Updated Task', + description: 'Updated description', + status: 'in-progress', + dependencies: [], + priority: 'high', + details: 'Updated details', + testStrategy: 'Updated test strategy', + subtasks: [] + } + }, + telemetryData: {} + }); + + // Act + await updateTaskById( + 'tasks/tasks.json', + 1, + 'Update this task with new requirements', + false, + { tag: 'master', projectRoot: '/mock/project' }, + 'json' + ); + + // Assert - Prompt manager should be called + expect(getPromptManager).toHaveBeenCalled(); + const promptManagerInstance = getPromptManager.mock.results[0].value; + expect(promptManagerInstance.loadPrompt).toHaveBeenCalled(); + }); +}); + +describe('Context Gathering Integration', () => { + let fs; + let generateObjectService; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + fs = await import('fs'); + const aiServices = await import( + '../../../../../scripts/modules/ai-services-unified.js' + ); + generateObjectService = aiServices.generateObjectService; + tryUpdateViaRemote.mockResolvedValue(null); // No remote + }); + + test('should gather project context when projectRoot is provided', async () => { + // Arrange + const mockContextGatherer = { + gather: jest.fn().mockResolvedValue({ + fullContext: 'Project context from files', + summary: 'Context summary' + }) + }; + ContextGatherer.mockImplementation(() => mockContextGatherer); + + fs.existsSync.mockReturnValue(true); + readJSON.mockReturnValue({ + tag: 'master', + tasks: [ + { + id: 1, + title: 'Task', + description: 'Description', + status: 'pending', + dependencies: [], + priority: 'medium', + details: 'Details', + testStrategy: 'Test strategy', + subtasks: [] + } + ] + }); + + generateObjectService.mockResolvedValue({ + mainResult: { + task: { + id: 1, + title: 'Updated Task', + description: 'Updated description', + status: 'in-progress', + dependencies: [], + priority: 'high', + details: 'Updated details', + testStrategy: 'Updated test strategy', + subtasks: [] + } + }, + telemetryData: {} + }); + + // Act + await updateTaskById( + 'tasks/tasks.json', + 1, + 'Update with context', + false, + { tag: 'master', projectRoot: '/mock/project' }, + 'json' + ); + + // Assert - Context gatherer should be instantiated and used + expect(ContextGatherer).toHaveBeenCalledWith('/mock/project', 'master'); + expect(mockContextGatherer.gather).toHaveBeenCalled(); + }); +}); + +describe('Fuzzy Task Search Integration', () => { + let fs; + let generateObjectService; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + fs = await import('fs'); + const aiServices = await import( + '../../../../../scripts/modules/ai-services-unified.js' + ); + generateObjectService = aiServices.generateObjectService; + tryUpdateViaRemote.mockResolvedValue(null); // No remote + }); + + test('should use fuzzy search to find related tasks for context', async () => { + // Arrange + const mockFuzzySearch = { + findRelevantTasks: jest.fn().mockReturnValue([ + { id: 2, title: 'Related Task 1', score: 0.9 }, + { id: 3, title: 'Related Task 2', score: 0.85 } + ]), + getTaskIds: jest.fn().mockReturnValue(['2', '3']) + }; + FuzzyTaskSearch.mockImplementation(() => mockFuzzySearch); + + fs.existsSync.mockReturnValue(true); + readJSON.mockReturnValue({ + tag: 'master', + tasks: [ + { + id: 1, + title: 'Task to update', + description: 'Description', + status: 'pending', + dependencies: [], + priority: 'medium', + details: 'Details', + testStrategy: 'Test strategy', + subtasks: [] + }, + { + id: 2, + title: 'Related Task 1', + description: 'Related description', + status: 'done', + dependencies: [], + priority: 'medium', + details: 'Related details', + testStrategy: 'Related test strategy', + subtasks: [] + }, + { + id: 3, + title: 'Related Task 2', + description: 'Another related description', + status: 'pending', + dependencies: [], + priority: 'low', + details: 'More details', + testStrategy: 'Test strategy', + subtasks: [] + } + ] + }); + + generateObjectService.mockResolvedValue({ + mainResult: { + task: { + id: 1, + title: 'Updated Task', + description: 'Updated description', + status: 'in-progress', + dependencies: [], + priority: 'high', + details: 'Updated details', + testStrategy: 'Updated test strategy', + subtasks: [] + } + }, + telemetryData: {} + }); + + // Act + await updateTaskById( + 'tasks/tasks.json', + 1, + 'Update with related task context', + false, + { tag: 'master' }, + 'json' + ); + + // Assert - Fuzzy search should be instantiated and used + expect(FuzzyTaskSearch).toHaveBeenCalled(); + expect(mockFuzzySearch.findRelevantTasks).toHaveBeenCalledWith( + expect.stringContaining('Task to update'), + expect.objectContaining({ + maxResults: 5, + includeSelf: true + }) + ); + expect(mockFuzzySearch.getTaskIds).toHaveBeenCalled(); + }); +});